From c4066109f9d9a78c317709ec16f7b6999882c9bc Mon Sep 17 00:00:00 2001 From: popeye Date: Thu, 14 May 2026 00:43:48 +0900 Subject: [PATCH] feat(chat): add room user summaries --- .../chat/ChatServiceApplication.java | 2 + .../chat/domain/dto/ChatRoomResponse.java | 31 ++++++++++ .../service/chatroom/ChatRoomServiceImpl.java | 39 +++++++++++- .../chat/infra/client/MemberClient.java | 16 +++++ .../src/main/resources/application-aws.yml | 3 + .../src/main/resources/application.yml | 3 + .../chatroom/ChatRoomServiceImplTest.java | 62 +++++++++++++++++++ 7 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 chat-service/src/main/java/com/comatching/chat/infra/client/MemberClient.java diff --git a/chat-service/src/main/java/com/comatching/chat/ChatServiceApplication.java b/chat-service/src/main/java/com/comatching/chat/ChatServiceApplication.java index 10bca7e..4e6f003 100644 --- a/chat-service/src/main/java/com/comatching/chat/ChatServiceApplication.java +++ b/chat-service/src/main/java/com/comatching/chat/ChatServiceApplication.java @@ -5,6 +5,7 @@ import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; +import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.context.annotation.ComponentScan; @SpringBootApplication( @@ -15,6 +16,7 @@ } ) @ComponentScan(basePackages = {"com.comatching.chat", "com.comatching.common"}) +@EnableFeignClients(basePackages = "com.comatching.chat") public class ChatServiceApplication { public static void main(String[] args) { diff --git a/chat-service/src/main/java/com/comatching/chat/domain/dto/ChatRoomResponse.java b/chat-service/src/main/java/com/comatching/chat/domain/dto/ChatRoomResponse.java index 0b3a4cc..c67c81c 100644 --- a/chat-service/src/main/java/com/comatching/chat/domain/dto/ChatRoomResponse.java +++ b/chat-service/src/main/java/com/comatching/chat/domain/dto/ChatRoomResponse.java @@ -3,17 +3,24 @@ import java.time.LocalDateTime; import com.comatching.chat.domain.entity.ChatRoom; +import com.comatching.common.domain.vo.KoreanAge; +import com.comatching.common.dto.member.ProfileResponse; public record ChatRoomResponse( String id, Long matchingId, Long initiatorUserId, Long targetUserId, + UserSummary otherUser, String lastMessage, LocalDateTime lastMessageTime, long unreadCount ) { public static ChatRoomResponse from(ChatRoom room, long unreadCount) { + return from(room, unreadCount, null); + } + + public static ChatRoomResponse from(ChatRoom room, long unreadCount, UserSummary otherUser) { String content = (room.getLastMessageInfo() != null) ? room.getLastMessageInfo().getContent() : null; LocalDateTime time = (room.getLastMessageInfo() != null) ? room.getLastMessageInfo().getSentAt() : null; @@ -22,9 +29,33 @@ public static ChatRoomResponse from(ChatRoom room, long unreadCount) { room.getMatchingId(), room.getInitiatorUserId(), room.getTargetUserId(), + otherUser, content, time, unreadCount ); } + + public record UserSummary( + Long memberId, + String nickname, + String profileImageUrl, + String university, + Integer age + ) { + public static UserSummary from(ProfileResponse profile) { + KoreanAge age = KoreanAge.fromBirthDate(profile.birthDate()); + return new UserSummary( + profile.memberId(), + profile.nickname(), + profile.profileImageUrl(), + profile.university(), + age != null ? age.getValue() : null + ); + } + + public static UserSummary memberOnly(Long memberId) { + return new UserSummary(memberId, null, null, null, null); + } + } } diff --git a/chat-service/src/main/java/com/comatching/chat/domain/service/chatroom/ChatRoomServiceImpl.java b/chat-service/src/main/java/com/comatching/chat/domain/service/chatroom/ChatRoomServiceImpl.java index f86d424..17989b6 100644 --- a/chat-service/src/main/java/com/comatching/chat/domain/service/chatroom/ChatRoomServiceImpl.java +++ b/chat-service/src/main/java/com/comatching/chat/domain/service/chatroom/ChatRoomServiceImpl.java @@ -4,18 +4,23 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.comatching.chat.domain.dto.ChatRoomResponse; +import com.comatching.chat.domain.dto.ChatRoomResponse.UserSummary; import com.comatching.chat.domain.entity.ChatRoom; import com.comatching.chat.domain.repository.ChatMessageRepository; import com.comatching.chat.domain.repository.ChatRoomRepository; import com.comatching.chat.domain.repository.UnreadCountCondition; import com.comatching.chat.domain.service.block.BlockService; +import com.comatching.chat.infra.client.MemberClient; import com.comatching.common.dto.event.matching.MatchingSuccessEvent; +import com.comatching.common.dto.member.ProfileResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -29,6 +34,7 @@ public class ChatRoomServiceImpl implements ChatRoomService { private final ChatRoomRepository chatRoomRepository; private final ChatMessageRepository chatMessageRepository; private final BlockService blockService; + private final MemberClient memberClient; @Override public void createChatRoom(MatchingSuccessEvent event) { @@ -62,9 +68,14 @@ public List getMyChatRooms(Long memberId) { .toList(); Map unreadCountsByRoom = getUnreadCountsByRoom(visibleRooms, memberId); + Map profilesByMemberId = getProfilesByMemberId(visibleRooms, memberId); return visibleRooms.stream() - .map(room -> ChatRoomResponse.from(room, unreadCountsByRoom.getOrDefault(room.getId(), 0L))) + .map(room -> { + Long otherUserId = getOtherUserId(room, memberId); + UserSummary otherUser = toUserSummary(otherUserId, profilesByMemberId.get(otherUserId)); + return ChatRoomResponse.from(room, unreadCountsByRoom.getOrDefault(room.getId(), 0L), otherUser); + }) .toList(); } @@ -106,6 +117,32 @@ private Map getUnreadCountsByRoom(List rooms, Long membe return chatMessageRepository.countUnreadMessagesByRoom(unreadCountConditions, memberId); } + private Map getProfilesByMemberId(List rooms, Long memberId) { + if (rooms.isEmpty()) { + return Map.of(); + } + + List otherUserIds = rooms.stream() + .map(room -> getOtherUserId(room, memberId)) + .distinct() + .toList(); + + List profiles = memberClient.getProfiles(otherUserIds); + if (profiles == null || profiles.isEmpty()) { + return Map.of(); + } + + return profiles.stream() + .collect(Collectors.toMap(ProfileResponse::memberId, Function.identity(), (left, right) -> left)); + } + + private UserSummary toUserSummary(Long memberId, ProfileResponse profile) { + if (profile == null) { + return UserSummary.memberOnly(memberId); + } + return UserSummary.from(profile); + } + private LocalDateTime getMyLastReadAt(ChatRoom room, Long memberId) { return memberId.equals(room.getInitiatorUserId()) ? room.getInitiatorLastReadAt() diff --git a/chat-service/src/main/java/com/comatching/chat/infra/client/MemberClient.java b/chat-service/src/main/java/com/comatching/chat/infra/client/MemberClient.java new file mode 100644 index 0000000..0622867 --- /dev/null +++ b/chat-service/src/main/java/com/comatching/chat/infra/client/MemberClient.java @@ -0,0 +1,16 @@ +package com.comatching.chat.infra.client; + +import java.util.List; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import com.comatching.common.dto.member.ProfileResponse; + +@FeignClient(name = "user-service", path = "/api/internal/users", url = "${user-service.url}") +public interface MemberClient { + + @PostMapping("/profiles/bulk") + List getProfiles(@RequestBody List memberIds); +} diff --git a/chat-service/src/main/resources/application-aws.yml b/chat-service/src/main/resources/application-aws.yml index d6b6afb..5d62d6e 100644 --- a/chat-service/src/main/resources/application-aws.yml +++ b/chat-service/src/main/resources/application-aws.yml @@ -2,6 +2,9 @@ server: port: 9003 +user-service: + url: http://user-service:9000 + spring: application: name: chat-service diff --git a/chat-service/src/main/resources/application.yml b/chat-service/src/main/resources/application.yml index 3b3f34d..131dccf 100644 --- a/chat-service/src/main/resources/application.yml +++ b/chat-service/src/main/resources/application.yml @@ -1,6 +1,9 @@ server: port: 9003 +user-service: + url: http://localhost:9000 + spring: application: name: chat-service diff --git a/chat-service/src/test/java/com/comatching/chat/domain/service/chatroom/ChatRoomServiceImplTest.java b/chat-service/src/test/java/com/comatching/chat/domain/service/chatroom/ChatRoomServiceImplTest.java index 4c746a4..db18169 100644 --- a/chat-service/src/test/java/com/comatching/chat/domain/service/chatroom/ChatRoomServiceImplTest.java +++ b/chat-service/src/test/java/com/comatching/chat/domain/service/chatroom/ChatRoomServiceImplTest.java @@ -4,6 +4,7 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.*; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; import java.util.Map; @@ -24,6 +25,8 @@ import com.comatching.chat.domain.repository.ChatRoomRepository; import com.comatching.chat.domain.repository.UnreadCountCondition; import com.comatching.chat.domain.service.block.BlockService; +import com.comatching.chat.infra.client.MemberClient; +import com.comatching.common.dto.member.ProfileResponse; @ExtendWith(MockitoExtension.class) class ChatRoomServiceImplTest { @@ -41,6 +44,9 @@ class ChatRoomServiceImplTest { @Mock private BlockService blockService; + @Mock + private MemberClient memberClient; + @InjectMocks private ChatRoomServiceImpl chatRoomService; @@ -58,6 +64,11 @@ void getMyChatRooms_batchesUnreadCounts() { given(blockService.getBlockedUserIds(MEMBER_ID)).willReturn(Set.of()); given(chatMessageRepository.countUnreadMessagesByRoom(anyList(), eq(MEMBER_ID))) .willReturn(Map.of("room-1", 2L, "room-2", 3L)); + given(memberClient.getProfiles(anyList())) + .willReturn(List.of( + profile(OTHER_MEMBER_ID, "첫번째상대", "https://img.example/first.png", "코매칭대", LocalDate.now().minusYears(23)), + profile(SECOND_OTHER_MEMBER_ID, "두번째상대", "https://img.example/second.png", "매칭대", LocalDate.now().minusYears(24)) + )); // when List result = chatRoomService.getMyChatRooms(MEMBER_ID); @@ -65,14 +76,49 @@ void getMyChatRooms_batchesUnreadCounts() { // then assertThat(result).extracting(ChatRoomResponse::unreadCount) .containsExactly(2L, 3L); + ChatRoomResponse.UserSummary firstOtherUser = result.get(0).otherUser(); + assertThat(firstOtherUser.memberId()).isEqualTo(OTHER_MEMBER_ID); + assertThat(firstOtherUser.nickname()).isEqualTo("첫번째상대"); + assertThat(firstOtherUser.profileImageUrl()).isEqualTo("https://img.example/first.png"); + assertThat(firstOtherUser.university()).isEqualTo("코매칭대"); + assertThat(firstOtherUser.age()).isEqualTo(24); then(chatMessageRepository).should().countUnreadMessagesByRoom( argThat(conditions -> containsCondition(conditions, "room-1", firstReadAt) && containsCondition(conditions, "room-2", secondReadAt)), eq(MEMBER_ID) ); + then(memberClient).should().getProfiles(List.of(OTHER_MEMBER_ID, SECOND_OTHER_MEMBER_ID)); then(chatMessageRepository).should(never()).countUnreadMessages(anyString(), any(), anyLong()); } + @Test + @DisplayName("채팅방 목록에서 차단된 상대는 프로필 조회 대상에서 제외한다") + void getMyChatRooms_excludesBlockedRoomsFromProfileLookup() { + // given + LocalDateTime firstReadAt = LocalDateTime.of(2026, 1, 1, 12, 0); + LocalDateTime blockedReadAt = LocalDateTime.of(2026, 1, 1, 13, 0); + ChatRoom visibleRoom = chatRoom("room-1", 100L, MEMBER_ID, OTHER_MEMBER_ID, firstReadAt); + ChatRoom blockedRoom = chatRoom("room-2", 101L, MEMBER_ID, SECOND_OTHER_MEMBER_ID, blockedReadAt); + + given(chatRoomRepository.findMyChatRooms(eq(MEMBER_ID), any(Sort.class))) + .willReturn(List.of(visibleRoom, blockedRoom)); + given(blockService.getBlockedUserIds(MEMBER_ID)).willReturn(Set.of(SECOND_OTHER_MEMBER_ID)); + given(chatMessageRepository.countUnreadMessagesByRoom(anyList(), eq(MEMBER_ID))) + .willReturn(Map.of("room-1", 7L)); + given(memberClient.getProfiles(anyList())) + .willReturn(List.of( + profile(OTHER_MEMBER_ID, "보이는상대", "https://img.example/visible.png", "코매칭대", LocalDate.now().minusYears(22)) + )); + + // when + List result = chatRoomService.getMyChatRooms(MEMBER_ID); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).otherUser().memberId()).isEqualTo(OTHER_MEMBER_ID); + then(memberClient).should().getProfiles(List.of(OTHER_MEMBER_ID)); + } + @Test @DisplayName("전체 안 읽은 메시지 수 조회에서 차단된 방을 제외하고 배치 합산한다") void getTotalUnreadCount_batchesVisibleRooms() { @@ -114,6 +160,22 @@ private ChatRoom chatRoom(String id, Long matchingId, Long initiatorId, Long tar return room; } + private ProfileResponse profile( + Long memberId, + String nickname, + String profileImageUrl, + String university, + LocalDate birthDate + ) { + return ProfileResponse.builder() + .memberId(memberId) + .nickname(nickname) + .profileImageUrl(profileImageUrl) + .university(university) + .birthDate(birthDate) + .build(); + } + private boolean containsCondition(List conditions, String roomId, LocalDateTime lastReadAt) { return conditions.stream() .anyMatch(condition -> roomId.equals(condition.roomId()) && lastReadAt.equals(condition.lastReadAt()));