From ab73f15e966708b91a70398e552162111534fc17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:12:28 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=8C=80=EC=9B=90=20=EB=AA=A8=EC=A7=91?= =?UTF-8?q?=20=EC=B1=84=ED=8C=85=EB=B0=A9=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TeamRecruitmentChatMemberRepository.java | 22 +++ .../TeamRecruitmentChatMessageRepository.java | 35 +++++ .../controller/TeamRecruitmentChatApi.java | 13 ++ .../TeamRecruitmentChatController.java | 18 ++- ...amRecruitmentChatRoomListItemResponse.java | 84 ++++++++++++ .../service/TeamRecruitmentChatService.java | 62 +++++++++ .../TeamRecruitmentOpenApiCustomizer.java | 6 + .../domain/TeamRecruitmentChatApiTest.java | 127 ++++++++++++++++++ .../TeamRecruitmentOpenApiContractTest.java | 23 ++++ .../TeamRecruitmentChatServiceTest.java | 89 ++++++++++++ 10 files changed, 474 insertions(+), 5 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/TeamRecruitmentChatRoomListItemResponse.java diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/repository/TeamRecruitmentChatMemberRepository.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/repository/TeamRecruitmentChatMemberRepository.java index d2decda7f3..421cb3fe82 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/repository/TeamRecruitmentChatMemberRepository.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/repository/TeamRecruitmentChatMemberRepository.java @@ -1,8 +1,11 @@ package in.koreatech.koin.domain.team.recruitment.repository; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatMember; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -16,5 +19,24 @@ public interface TeamRecruitmentChatMemberRepository extends Repository findAllByChatRoom_Id(Integer chatRoomId); + @Query(""" + SELECT member + FROM TeamRecruitmentChatMember member + JOIN FETCH member.chatRoom chatRoom + JOIN FETCH chatRoom.recruitment + WHERE member.user.id = :userId + """) + List findAllByUserIdWithChatRoomAndRecruitment(@Param("userId") Integer userId); + + @Query(""" + SELECT member + FROM TeamRecruitmentChatMember member + JOIN FETCH member.user + WHERE member.chatRoom.id IN :chatRoomIds + """) + List findAllWithUsersByChatRoomIds( + @Param("chatRoomIds") Collection chatRoomIds + ); + long countByChatRoom_Id(Integer chatRoomId); } diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/repository/TeamRecruitmentChatMessageRepository.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/repository/TeamRecruitmentChatMessageRepository.java index c0fb70887d..3d57c95611 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/repository/TeamRecruitmentChatMessageRepository.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/repository/TeamRecruitmentChatMessageRepository.java @@ -2,8 +2,11 @@ import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatMessage; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -13,6 +16,31 @@ public interface TeamRecruitmentChatMessageRepository extends Repository findLatestByChatRoomIds( + @Param("chatRoomIds") Collection chatRoomIds + ); + + @Query(""" + SELECT message.chatRoom.id AS chatRoomId, COUNT(message.id) AS unreadMessageCount + FROM TeamRecruitmentChatMessage message + JOIN message.chatRoom.members member + WHERE member.user.id = :userId + AND message.sender.id <> :userId + AND (member.lastReadMessageId IS NULL OR message.id > member.lastReadMessageId) + GROUP BY message.chatRoom.id + """) + List countUnreadMessagesByUserId(@Param("userId") Integer userId); + /** Returns the initial page in ascending message-id order. */ List findAllByChatRoom_IdOrderByIdAsc( Integer chatRoomId, @@ -32,4 +60,11 @@ List findAllByChatRoom_IdAndIdLessThanOrderByIdDesc( Integer beforeMessageId, Pageable pageable ); + + interface ChatRoomUnreadCount { + + Integer getChatRoomId(); + + long getUnreadMessageCount(); + } } diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatApi.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatApi.java index c7045b3a7d..e1c549ad89 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatApi.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatApi.java @@ -26,6 +26,7 @@ import in.koreatech.koin.domain.teamrecruitment.dto.ChatRoomResponse; import in.koreatech.koin.domain.teamrecruitment.dto.CreateChatMessageRequest; import in.koreatech.koin.domain.teamrecruitment.dto.DirectChatRoomResponse; +import in.koreatech.koin.domain.teamrecruitment.dto.TeamRecruitmentChatRoomListItemResponse; import in.koreatech.koin.global.code.ApiResponseCodes; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -35,6 +36,18 @@ @Tag(name = "(Normal) Team Recruitment Chat: 팀원 모집 채팅", description = "팀원 모집 채팅 API") public interface TeamRecruitmentChatApi { + @ApiResponseCodes({ + OK, + UNAUTHORIZED_USER, + FORBIDDEN_USER_TYPE, + }) + @Operation( + summary = "내 팀원 모집 채팅방 목록 조회", + description = "현재 사용자가 멤버인 TEAM/DIRECT 채팅방을 최근 메시지순으로 반환합니다. " + + "메시지가 없는 방과 READ_ONLY 방도 포함합니다." + ) + ResponseEntity> getChatRooms(Integer userId); + @ApiResponseCodes({ OK, TEAM_RECRUITMENT_CHAT_NOT_FOUND, diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatController.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatController.java index b9bbaae96a..6170f8df66 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatController.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatController.java @@ -17,6 +17,7 @@ import in.koreatech.koin.domain.teamrecruitment.dto.CreateChatMessageRequest; import in.koreatech.koin.domain.teamrecruitment.dto.DirectChatRoomCreationResult; import in.koreatech.koin.domain.teamrecruitment.dto.DirectChatRoomResponse; +import in.koreatech.koin.domain.teamrecruitment.dto.TeamRecruitmentChatRoomListItemResponse; import in.koreatech.koin.domain.teamrecruitment.service.TeamRecruitmentChatService; import in.koreatech.koin.global.auth.Auth; @@ -26,12 +27,19 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/chatroom/team-recruitment/{recruitmentId}") +@RequestMapping("/chatroom/team-recruitment") public class TeamRecruitmentChatController implements TeamRecruitmentChatApi { private final TeamRecruitmentChatService chatService; - @GetMapping("/{chatRoomId}") + @GetMapping + public ResponseEntity> getChatRooms( + @Auth(permit = {STUDENT}) Integer userId + ) { + return ResponseEntity.ok(chatService.getChatRooms(userId)); + } + + @GetMapping("/{recruitmentId}/{chatRoomId}") public ResponseEntity getChatRoom( @Auth(permit = {STUDENT}) Integer userId, @PathVariable Integer recruitmentId, @@ -40,7 +48,7 @@ public ResponseEntity getChatRoom( return ResponseEntity.ok(chatService.getChatRoom(userId, recruitmentId, chatRoomId)); } - @PostMapping("/applications/{applicationId}/direct") + @PostMapping("/{recruitmentId}/applications/{applicationId}/direct") public ResponseEntity getOrCreateDirectChatRoom( @Auth(permit = {STUDENT}) Integer userId, @PathVariable Integer recruitmentId, @@ -51,7 +59,7 @@ public ResponseEntity getOrCreateDirectChatRoom( return ResponseEntity.status(status).body(result.response()); } - @GetMapping("/{chatRoomId}/messages") + @GetMapping("/{recruitmentId}/{chatRoomId}/messages") public ResponseEntity> getMessages( @Auth(permit = {STUDENT}) Integer userId, @PathVariable Integer recruitmentId, @@ -63,7 +71,7 @@ public ResponseEntity> getMessages( return ResponseEntity.ok(chatService.getMessages(userId, recruitmentId, chatRoomId, afterMessageId, beforeMessageId, limit)); } - @PostMapping("/{chatRoomId}/messages") + @PostMapping("/{recruitmentId}/{chatRoomId}/messages") public ResponseEntity createMessage( @Auth(permit = {STUDENT}) Integer userId, @PathVariable Integer recruitmentId, diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/TeamRecruitmentChatRoomListItemResponse.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/TeamRecruitmentChatRoomListItemResponse.java new file mode 100644 index 0000000000..b20a1906ba --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/TeamRecruitmentChatRoomListItemResponse.java @@ -0,0 +1,84 @@ +package in.koreatech.koin.domain.teamrecruitment.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomType; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatMessage; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatRoom; +import in.koreatech.koin.domain.user.model.User; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record TeamRecruitmentChatRoomListItemResponse( + @Schema(description = "모집글 ID", example = "17", requiredMode = REQUIRED) + Integer recruitmentId, + + @Schema(description = "채팅방 ID", example = "31", requiredMode = REQUIRED) + Integer chatRoomId, + + @Schema(description = "채팅방 이름. TEAM은 모집글 제목, DIRECT는 상대방 닉네임입니다.", + example = "AI 아이디어 공모전 팀원 모집", requiredMode = REQUIRED) + String roomName, + + @Schema(description = "채팅방 타입", example = "TEAM", requiredMode = REQUIRED) + String roomType, + + @Schema(description = "채팅방 상태", example = "ACTIVE", requiredMode = REQUIRED) + String status, + + @Schema(description = "DIRECT 상대방 유저 ID. TEAM은 null입니다.", example = "22", + nullable = true, requiredMode = REQUIRED) + Integer counterpartId, + + @Schema(description = "DIRECT 상대방 닉네임. TEAM은 null입니다.", example = "김철수", + nullable = true, requiredMode = REQUIRED) + String counterpartNickname, + + @Schema(description = "최근 메시지 ID. 메시지가 없으면 null입니다.", example = "901", + nullable = true, requiredMode = REQUIRED) + Integer lastMessageId, + + @Schema(description = "최근 메시지 내용. 메시지가 없으면 null입니다.", example = "안녕하세요!", + nullable = true, requiredMode = REQUIRED) + String lastMessageContent, + + @Schema(description = "최근 메시지 전송 시각. 메시지가 없으면 null입니다.", + example = "2026-08-26T11:20:30.123456", nullable = true, requiredMode = REQUIRED) + LocalDateTime lastMessageAt, + + @Schema(description = "최근 메시지의 이미지 여부. 메시지가 없으면 null입니다.", example = "false", + nullable = true, requiredMode = REQUIRED) + Boolean lastMessageIsImage, + + @Schema(description = "현재 사용자가 읽지 않은 메시지 수", example = "2", requiredMode = REQUIRED) + int unreadMessageCount +) { + + public static TeamRecruitmentChatRoomListItemResponse of( + TeamRecruitmentChatRoom chatRoom, + User counterpart, + TeamRecruitmentChatMessage lastMessage, + int unreadMessageCount + ) { + boolean direct = chatRoom.getRoomType() == TeamRecruitmentChatRoomType.DIRECT; + return new TeamRecruitmentChatRoomListItemResponse( + chatRoom.getRecruitment().getId(), + chatRoom.getId(), + direct && counterpart != null ? counterpart.getNickname() : chatRoom.getRecruitment().getTitle(), + chatRoom.getRoomType().name(), + chatRoom.getStatus().name(), + direct && counterpart != null ? counterpart.getId() : null, + direct && counterpart != null ? counterpart.getNickname() : null, + lastMessage == null ? null : lastMessage.getId(), + lastMessage == null ? null : lastMessage.getContent(), + lastMessage == null ? null : lastMessage.getCreatedAt(), + lastMessage == null ? null : lastMessage.getIsImage(), + unreadMessageCount + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java index 29ba6d27b2..0d1076c651 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java @@ -5,10 +5,13 @@ import java.time.ZoneId; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -33,6 +36,7 @@ import in.koreatech.koin.domain.teamrecruitment.dto.CreateChatMessageRequest; import in.koreatech.koin.domain.teamrecruitment.dto.DirectChatRoomCreationResult; import in.koreatech.koin.domain.teamrecruitment.dto.DirectChatRoomResponse; +import in.koreatech.koin.domain.teamrecruitment.dto.TeamRecruitmentChatRoomListItemResponse; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.global.exception.CustomException; import lombok.RequiredArgsConstructor; @@ -67,6 +71,64 @@ public class TeamRecruitmentChatService { private final ObjectMapper objectMapper; private final Clock clock; + public List getChatRooms(Integer userId) { + List memberships = + memberRepository.findAllByUserIdWithChatRoomAndRecruitment(userId); + if (memberships.isEmpty()) { + return List.of(); + } + + List chatRooms = memberships.stream() + .map(TeamRecruitmentChatMember::getChatRoom) + .toList(); + List chatRoomIds = chatRooms.stream() + .map(TeamRecruitmentChatRoom::getId) + .toList(); + List directChatRoomIds = chatRooms.stream() + .filter(room -> room.getRoomType() == TeamRecruitmentChatRoomType.DIRECT) + .map(TeamRecruitmentChatRoom::getId) + .toList(); + + Map counterpartsByChatRoomId = directChatRoomIds.isEmpty() + ? Map.of() + : memberRepository.findAllWithUsersByChatRoomIds(directChatRoomIds).stream() + .filter(member -> !member.getUser().getId().equals(userId)) + .collect(Collectors.toMap( + member -> member.getChatRoom().getId(), + TeamRecruitmentChatMember::getUser, + (first, ignored) -> first + )); + Map latestMessagesByChatRoomId = messageRepository + .findLatestByChatRoomIds(chatRoomIds).stream() + .collect(Collectors.toMap( + message -> message.getChatRoom().getId(), + Function.identity() + )); + Map unreadCountsByChatRoomId = messageRepository.countUnreadMessagesByUserId(userId).stream() + .collect(Collectors.toMap( + TeamRecruitmentChatMessageRepository.ChatRoomUnreadCount::getChatRoomId, + TeamRecruitmentChatMessageRepository.ChatRoomUnreadCount::getUnreadMessageCount + )); + + return chatRooms.stream() + .map(chatRoom -> TeamRecruitmentChatRoomListItemResponse.of( + chatRoom, + counterpartsByChatRoomId.get(chatRoom.getId()), + latestMessagesByChatRoomId.get(chatRoom.getId()), + Math.toIntExact(unreadCountsByChatRoomId.getOrDefault(chatRoom.getId(), 0L)) + )) + .sorted(Comparator + .comparing( + TeamRecruitmentChatRoomListItemResponse::lastMessageAt, + Comparator.nullsLast(Comparator.reverseOrder()) + ) + .thenComparing( + TeamRecruitmentChatRoomListItemResponse::chatRoomId, + Comparator.reverseOrder() + )) + .toList(); + } + public ChatRoomResponse getChatRoom(Integer userId, Integer recruitmentId, Integer chatRoomId) { TeamRecruitmentChatRoom chatRoom = chatRoomRepository.findById(chatRoomId) .orElseThrow(() -> CustomException.of(TEAM_RECRUITMENT_CHAT_NOT_FOUND)); diff --git a/src/main/java/in/koreatech/koin/global/config/TeamRecruitmentOpenApiCustomizer.java b/src/main/java/in/koreatech/koin/global/config/TeamRecruitmentOpenApiCustomizer.java index 69a0a9394e..6b95ae8a3e 100644 --- a/src/main/java/in/koreatech/koin/global/config/TeamRecruitmentOpenApiCustomizer.java +++ b/src/main/java/in/koreatech/koin/global/config/TeamRecruitmentOpenApiCustomizer.java @@ -44,6 +44,12 @@ public void customise(OpenAPI openApi) { ); configureDateTime(schemas, "ApplicationCreatedResponse", "created_at", SPACE_LOCAL_DATE_TIME_PATTERN); configureDateTime(schemas, "RecruitmentDetail", "created_at", SPACE_LOCAL_DATE_TIME_PATTERN); + configureDateTime( + schemas, + "TeamRecruitmentChatRoomListItemResponse", + "last_message_at", + ISO_LOCAL_DATE_TIME_PATTERN + ); configureChatMessageTimestamp(openApi, schemas); } diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentChatApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentChatApiTest.java index 361553bea2..ddd5168fa5 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentChatApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentChatApiTest.java @@ -1,8 +1,10 @@ package in.koreatech.koin.acceptance.domain; import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentCategory.PROJECT; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentApplicationStatus.ACCEPTED; import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomStatus.ACTIVE; import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomStatus.READ_ONLY; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomType.DIRECT; import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomType.TEAM; import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentMeetingType.ONLINE; import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentNotificationTargetType.CHAT_ROOM; @@ -11,6 +13,7 @@ import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentStatus.RECRUITING; import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentType.GENERAL; import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.nullValue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -34,11 +37,13 @@ import in.koreatech.koin.domain.student.model.Department; import in.koreatech.koin.domain.student.model.Student; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitment; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentApplication; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatMember; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatMessage; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatRoom; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentNotification; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentOutboxEvent; +import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentApplicationRepository; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentChatMemberRepository; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentChatMessageRepository; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentChatRoomRepository; @@ -60,6 +65,9 @@ class TeamRecruitmentChatApiTest extends AcceptanceTest { @Autowired private TeamRecruitmentRepository recruitmentRepository; + @Autowired + private TeamRecruitmentApplicationRepository applicationRepository; + @Autowired private TeamRecruitmentChatRoomRepository chatRoomRepository; @@ -140,6 +148,73 @@ void teamChatRoomRequiresMembership() throws Exception { .andExpect(jsonPath("$.code").value("TEAM_RECRUITMENT_CHAT_NOT_FOUND")); } + @Test + @DisplayName("내 팀원 모집 채팅방 목록은 TEAM과 DIRECT를 최근 메시지순으로 반환한다") + void listMyTeamRecruitmentChatRooms() throws Exception { + TeamRecruitment recruitment = saveRecruitment("목록에서 보이는 팀 모집"); + TeamRecruitmentChatRoom teamRoom = saveTeamRoom(recruitment, ACTIVE); + TeamRecruitmentApplication application = saveAcceptedApplication(recruitment); + TeamRecruitmentChatRoom directRoom = saveDirectRoom(recruitment, application, READ_ONLY); + + TeamRecruitmentChatMessage readTeamMessage = saveMessage(teamRoom, author.getUser(), "이미 읽은 팀 메시지", false); + TeamRecruitmentChatMember applicantTeamMember = chatMemberRepository + .findByChatRoom_IdAndUser_Id(teamRoom.getId(), applicant.getUser().getId()) + .orElseThrow(); + applicantTeamMember.advanceLastReadMessageId(readTeamMessage.getId()); + TeamRecruitmentChatMessage unreadTeamMessage = saveMessage( + teamRoom, author.getUser(), "새로운 팀 메시지", false); + TeamRecruitmentChatMessage latestDirectMessage = saveMessage( + directRoom, applicant.getUser(), "https://static.koreatech.in/team-chat/image.png", true); + + TeamRecruitment emptyRecruitment = saveRecruitment("아직 대화가 없는 팀 모집"); + TeamRecruitmentChatRoom emptyRoom = saveTeamRoom(emptyRecruitment, ACTIVE); + entityManager.flush(); + + mockMvc.perform(get("/chatroom/team-recruitment") + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(3)) + .andExpect(jsonPath("$[0].recruitment_id").value(recruitment.getId())) + .andExpect(jsonPath("$[0].chat_room_id").value(directRoom.getId())) + .andExpect(jsonPath("$[0].room_name").value(author.getUser().getNickname())) + .andExpect(jsonPath("$[0].room_type").value("DIRECT")) + .andExpect(jsonPath("$[0].status").value("READ_ONLY")) + .andExpect(jsonPath("$[0].counterpart_id").value(author.getUser().getId())) + .andExpect(jsonPath("$[0].counterpart_nickname").value(author.getUser().getNickname())) + .andExpect(jsonPath("$[0].last_message_id").value(latestDirectMessage.getId())) + .andExpect(jsonPath("$[0].last_message_content") + .value("https://static.koreatech.in/team-chat/image.png")) + .andExpect(jsonPath("$[0].last_message_at").isString()) + .andExpect(jsonPath("$[0].last_message_is_image").value(true)) + .andExpect(jsonPath("$[0].unread_message_count").value(0)) + .andExpect(jsonPath("$[1].recruitment_id").value(recruitment.getId())) + .andExpect(jsonPath("$[1].chat_room_id").value(teamRoom.getId())) + .andExpect(jsonPath("$[1].room_name").value(recruitment.getTitle())) + .andExpect(jsonPath("$[1].room_type").value("TEAM")) + .andExpect(jsonPath("$[1].counterpart_id").value(nullValue())) + .andExpect(jsonPath("$[1].counterpart_nickname").value(nullValue())) + .andExpect(jsonPath("$[1].last_message_id").value(unreadTeamMessage.getId())) + .andExpect(jsonPath("$[1].last_message_content").value("새로운 팀 메시지")) + .andExpect(jsonPath("$[1].last_message_is_image").value(false)) + .andExpect(jsonPath("$[1].unread_message_count").value(1)) + .andExpect(jsonPath("$[2].recruitment_id").value(emptyRecruitment.getId())) + .andExpect(jsonPath("$[2].chat_room_id").value(emptyRoom.getId())) + .andExpect(jsonPath("$[2].last_message_id").value(nullValue())) + .andExpect(jsonPath("$[2].last_message_content").value(nullValue())) + .andExpect(jsonPath("$[2].last_message_at").value(nullValue())) + .andExpect(jsonPath("$[2].last_message_is_image").value(nullValue())) + .andExpect(jsonPath("$[2].unread_message_count").value(0)); + + mockMvc.perform(get("/chatroom/team-recruitment") + .header("Authorization", "Bearer " + outsiderToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + + mockMvc.perform(get("/chatroom/team-recruitment")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("UNAUTHORIZED_USER")); + } + @Test @DisplayName("TEAM 채팅은 메시지 polling cursor와 unread read를 실제 HTTP로 반영하고 알림 outbox를 만든다") void teamChatMessagePollingAndNotificationOutbox() throws Exception { @@ -413,6 +488,58 @@ private TeamRecruitmentChatRoom saveTeamRoom( return room; } + private TeamRecruitmentApplication saveAcceptedApplication(TeamRecruitment recruitment) { + return applicationRepository.save(TeamRecruitmentApplication.builder() + .recruitment(recruitment) + .applicant(applicant.getUser()) + .motivation("지원 동기") + .availability("월수금 20시 이후") + .status(ACCEPTED) + .profileSnapshot("{}") + .snapshotVersion(1) + .build()); + } + + private TeamRecruitmentChatRoom saveDirectRoom( + TeamRecruitment recruitment, + TeamRecruitmentApplication application, + in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomStatus status + ) { + TeamRecruitmentChatRoom room = chatRoomRepository.save(TeamRecruitmentChatRoom.builder() + .recruitment(recruitment) + .roomScopeKey("DIRECT-" + application.getId()) + .roomType(DIRECT) + .application(application) + .status(status) + .build()); + chatMemberRepository.save(TeamRecruitmentChatMember.builder() + .chatRoom(room) + .user(author.getUser()) + .build()); + chatMemberRepository.save(TeamRecruitmentChatMember.builder() + .chatRoom(room) + .user(applicant.getUser()) + .build()); + return room; + } + + private TeamRecruitmentChatMessage saveMessage( + TeamRecruitmentChatRoom room, + User sender, + String content, + boolean isImage + ) { + TeamRecruitmentChatMessage message = chatMessageRepository.save(TeamRecruitmentChatMessage.builder() + .chatRoom(room) + .sender(sender) + .senderNickname(sender.getNickname()) + .content(content) + .isImage(isImage) + .build()); + entityManager.flush(); + return message; + } + private org.springframework.test.web.servlet.ResultActions sendMessage( TeamRecruitmentChatRoom room, TeamRecruitment recruitment, diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentOpenApiContractTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentOpenApiContractTest.java index 13cb37dc8f..52776e15f2 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentOpenApiContractTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentOpenApiContractTest.java @@ -22,6 +22,7 @@ import in.koreatech.koin.domain.team.recruitment.dto.ApplicationCreatedResponse; import in.koreatech.koin.domain.teamrecruitment.dto.ChatMessageResponse; import in.koreatech.koin.domain.teamrecruitment.dto.ChatRoomResponse; +import in.koreatech.koin.domain.teamrecruitment.dto.TeamRecruitmentChatRoomListItemResponse; import in.koreatech.koin.domain.teamrecruitment.dto.TeamRecruitmentNotificationResponse; class TeamRecruitmentOpenApiContractTest extends AcceptanceTest { @@ -51,6 +52,7 @@ class TeamRecruitmentOpenApiContractTest extends AcceptanceTest { "PUT /team-recruitments/{recruitmentId}/applications/{applicationId}/status", "GET /team-recruitment-profiles/me", "PUT /team-recruitment-profiles/me", + "GET /chatroom/team-recruitment", "GET /chatroom/team-recruitment/{recruitmentId}/{chatRoomId}", "POST /chatroom/team-recruitment/{recruitmentId}/applications/{applicationId}/direct", "GET /chatroom/team-recruitment/{recruitmentId}/{chatRoomId}/messages", @@ -80,6 +82,18 @@ void teamRecruitmentContractMatchesRuntime() throws Exception { assertNullable(chatRoom, "counterpart"); assertInlineObject(chatRoom, "counterpart", "id", "nickname"); + JsonNode chatRoomListItem = schema(openApi, "TeamRecruitmentChatRoomListItemResponse"); + assertRequired(chatRoomListItem, "recruitment_id", "chat_room_id", "room_name", "room_type", "status", + "counterpart_id", "counterpart_nickname", "last_message_id", "last_message_content", "last_message_at", + "last_message_is_image", "unread_message_count"); + assertNullable(chatRoomListItem, "counterpart_id"); + assertNullable(chatRoomListItem, "counterpart_nickname"); + assertNullable(chatRoomListItem, "last_message_id"); + assertNullable(chatRoomListItem, "last_message_content"); + assertNullable(chatRoomListItem, "last_message_at"); + assertNullable(chatRoomListItem, "last_message_is_image"); + assertDateTime(chatRoomListItem, "last_message_at", ISO_LOCAL_DATE_TIME_PATTERN); + assertRequiredNullable(schema(openApi, "ApplicationCreatedResponse"), "role"); assertInlineObject(schema(openApi, "ApplicationCreatedResponse"), "role", "id", "name"); assertRequiredNullable(schema(openApi, "ApplicantDetail"), "role"); @@ -150,6 +164,9 @@ void teamRecruitmentDtosMatchRuntimeJson() { new ApplicationCreatedResponse(51, 17, PENDING, null, timestamp)); JsonNode chatRoom = objectMapper.valueToTree( new ChatRoomResponse(31, "팀 채팅방", "TEAM", "ACTIVE", 1, 2, null)); + JsonNode chatRoomListItem = objectMapper.valueToTree(new TeamRecruitmentChatRoomListItemResponse( + 17, 31, "팀 채팅방", "TEAM", "ACTIVE", null, null, + null, null, null, null, 0)); JsonNode notification = objectMapper.valueToTree(new TeamRecruitmentNotificationResponse( 101, "NEW_APPLICATION", "APPLICANT_MANAGEMENT", 17, null, null, null, "새로운 지원자가 있어요.", false, timestamp)); @@ -159,6 +176,12 @@ void teamRecruitmentDtosMatchRuntimeJson() { assertThat(application.path("role").isNull()).isTrue(); assertThat(application.path("created_at").asText()).isEqualTo("2026-08-26 11:20:30"); assertThat(chatRoom.path("counterpart").isNull()).isTrue(); + assertThat(chatRoomListItem.path("counterpart_id").isNull()).isTrue(); + assertThat(chatRoomListItem.path("counterpart_nickname").isNull()).isTrue(); + assertThat(chatRoomListItem.path("last_message_id").isNull()).isTrue(); + assertThat(chatRoomListItem.path("last_message_content").isNull()).isTrue(); + assertThat(chatRoomListItem.path("last_message_at").isNull()).isTrue(); + assertThat(chatRoomListItem.path("last_message_is_image").isNull()).isTrue(); assertThat(notification.path("application_id").isNull()).isTrue(); assertThat(notification.path("chat_room_id").isNull()).isTrue(); assertThat(notification.path("sender_nickname").isNull()).isTrue(); diff --git a/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java index 093d03ee41..9c76602565 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java @@ -6,10 +6,12 @@ import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import java.time.Clock; import java.time.Instant; +import java.time.LocalDateTime; import java.time.ZoneId; import java.util.List; import java.util.Optional; @@ -35,11 +37,13 @@ import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitment; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentApplication; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatMember; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatMessage; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatRoom; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentRole; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentApplicationRepository; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentChatMemberRepository; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentChatMessageRepository; +import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentChatMessageRepository.ChatRoomUnreadCount; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentChatRoomRepository; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentNotificationRepository; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentOutboxEventRepository; @@ -47,6 +51,7 @@ import in.koreatech.koin.domain.teamrecruitment.dto.ChatRoomResponse; import in.koreatech.koin.domain.teamrecruitment.dto.CreateChatMessageRequest; import in.koreatech.koin.domain.teamrecruitment.dto.DirectChatRoomCreationResult; +import in.koreatech.koin.domain.teamrecruitment.dto.TeamRecruitmentChatRoomListItemResponse; import in.koreatech.koin.domain.teamrecruitment.service.TeamRecruitmentChatService; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.unit.fixture.UserFixture; @@ -137,6 +142,90 @@ void setUpClock() { .isEqualTo(ApiResponseCode.TEAM_RECRUITMENT_CHAT_FORBIDDEN)); } + @Test + void 내_채팅방_목록은_최근_메시지순으로_TEAM과_DIRECT_정보를_반환한다() { + TeamRecruitment recruitment = mock(TeamRecruitment.class); + TeamRecruitmentChatRoom teamRoom = mock(TeamRecruitmentChatRoom.class); + TeamRecruitmentChatRoom directRoom = mock(TeamRecruitmentChatRoom.class); + TeamRecruitmentChatMember currentTeamMember = mock(TeamRecruitmentChatMember.class); + TeamRecruitmentChatMember currentDirectMember = mock(TeamRecruitmentChatMember.class); + TeamRecruitmentChatMember counterpartMember = mock(TeamRecruitmentChatMember.class); + TeamRecruitmentChatMessage teamMessage = mock(TeamRecruitmentChatMessage.class); + TeamRecruitmentChatMessage directMessage = mock(TeamRecruitmentChatMessage.class); + ChatRoomUnreadCount unreadCount = mock(ChatRoomUnreadCount.class); + User currentUser = UserFixture.id_설정_코인_유저(USER_ID); + User counterpart = UserFixture.id_설정_코인_유저(OTHER_USER_ID); + LocalDateTime teamMessageAt = LocalDateTime.of(2026, 8, 28, 12, 0); + LocalDateTime directMessageAt = teamMessageAt.plusMinutes(1); + + when(currentTeamMember.getChatRoom()).thenReturn(teamRoom); + when(currentDirectMember.getChatRoom()).thenReturn(directRoom); + when(currentDirectMember.getUser()).thenReturn(currentUser); + when(teamRoom.getId()).thenReturn(20); + when(directRoom.getId()).thenReturn(21); + when(teamRoom.getRecruitment()).thenReturn(recruitment); + when(directRoom.getRecruitment()).thenReturn(recruitment); + when(recruitment.getId()).thenReturn(RECRUITMENT_ID); + when(recruitment.getTitle()).thenReturn("팀원 모집"); + when(teamRoom.getRoomType()).thenReturn(TeamRecruitmentChatRoomType.TEAM); + when(directRoom.getRoomType()).thenReturn(TeamRecruitmentChatRoomType.DIRECT); + when(teamRoom.getStatus()).thenReturn(TeamRecruitmentChatRoomStatus.ACTIVE); + when(directRoom.getStatus()).thenReturn(TeamRecruitmentChatRoomStatus.READ_ONLY); + when(counterpartMember.getChatRoom()).thenReturn(directRoom); + when(counterpartMember.getUser()).thenReturn(counterpart); + when(teamMessage.getChatRoom()).thenReturn(teamRoom); + when(teamMessage.getId()).thenReturn(100); + when(teamMessage.getContent()).thenReturn("팀 메시지"); + when(teamMessage.getCreatedAt()).thenReturn(teamMessageAt); + when(teamMessage.getIsImage()).thenReturn(false); + when(directMessage.getChatRoom()).thenReturn(directRoom); + when(directMessage.getId()).thenReturn(101); + when(directMessage.getContent()).thenReturn("개인 메시지"); + when(directMessage.getCreatedAt()).thenReturn(directMessageAt); + when(directMessage.getIsImage()).thenReturn(false); + when(unreadCount.getChatRoomId()).thenReturn(20); + when(unreadCount.getUnreadMessageCount()).thenReturn(2L); + when(memberRepository.findAllByUserIdWithChatRoomAndRecruitment(USER_ID)) + .thenReturn(List.of(currentTeamMember, currentDirectMember)); + when(memberRepository.findAllWithUsersByChatRoomIds(List.of(21))) + .thenReturn(List.of(currentDirectMember, counterpartMember)); + when(messageRepository.findLatestByChatRoomIds(List.of(20, 21))) + .thenReturn(List.of(teamMessage, directMessage)); + when(messageRepository.countUnreadMessagesByUserId(USER_ID)).thenReturn(List.of(unreadCount)); + + List responses = chatService.getChatRooms(USER_ID); + + assertThat(responses).extracting(TeamRecruitmentChatRoomListItemResponse::chatRoomId) + .containsExactly(directRoom.getId(), teamRoom.getId()); + assertThat(responses.get(0)) + .extracting( + TeamRecruitmentChatRoomListItemResponse::roomName, + TeamRecruitmentChatRoomListItemResponse::counterpartId, + TeamRecruitmentChatRoomListItemResponse::counterpartNickname, + TeamRecruitmentChatRoomListItemResponse::lastMessageId, + TeamRecruitmentChatRoomListItemResponse::unreadMessageCount + ) + .containsExactly(counterpart.getNickname(), OTHER_USER_ID, counterpart.getNickname(), 101, 0); + assertThat(responses.get(1)) + .extracting( + TeamRecruitmentChatRoomListItemResponse::roomName, + TeamRecruitmentChatRoomListItemResponse::counterpartId, + TeamRecruitmentChatRoomListItemResponse::counterpartNickname, + TeamRecruitmentChatRoomListItemResponse::lastMessageId, + TeamRecruitmentChatRoomListItemResponse::unreadMessageCount + ) + .containsExactly("팀원 모집", null, null, 100, 2); + } + + @Test + void 내_채팅방이_없으면_빈_목록을_반환한다() { + when(memberRepository.findAllByUserIdWithChatRoomAndRecruitment(USER_ID)).thenReturn(List.of()); + + assertThat(chatService.getChatRooms(USER_ID)).isEmpty(); + + verifyNoInteractions(messageRepository); + } + @Test void GENERAL_TEAM_채팅방에_작성자만_있으면_인원은_2명_중_1명이다() { ChatRoomResponse response = getChatRoom(