From 2d65bdb8ee6d8c6af8baf52c2749d60de52107ad Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 14 Aug 2025 21:43:42 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A1=B0=ED=9A=8C=20API=EC=97=90?= =?UTF-8?q?=20=EC=BB=A4=EC=84=9C=20=EA=B8=B0=EB=B0=98=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=95=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20createdAt=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cursor(< messageId) 기준의 커서 페이징 도입, 경계 exclusive 처리 limit 파라미터로 페이지 사이즈 가변화 (기본 20) 응답 스키마를 ChatMessagesPageResponse(messages, hasMore, nextCursor)로 변경 메시지 정렬을 createdAt 오름차순(표시 시간 순)으로 반환 ChatMessageItemDto에 createdAt(epoch millis) 필드 추가 레포지토리에 Pageable 기반 조회 및 이전 데이터 존재 여부 검사 메서드 추가 컨트롤러/서비스 시그니처 및 내부 로직 업데이트 --- .../room/controller/ChatRoomController.kt | 38 ++++++++-------- .../sodalive/chat/room/dto/ChatRoomDto.kt | 12 ++++- .../room/repository/ChatMessageRepository.kt | 14 ++++++ .../chat/room/service/ChatRoomService.kt | 45 ++++++++++++++----- 4 files changed, 79 insertions(+), 30 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt index 1fbde14..948d6e2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt @@ -65,24 +65,6 @@ class ChatRoomController( } } - /** - * 채팅방 메시지 조회 API - * - 참여 여부 검증(미참여시 "잘못된 접근입니다") - * - messageId가 있으면 해당 ID 이전 20개, 없으면 최신 20개 - */ - @GetMapping("/{chatRoomId}/messages") - fun getChatMessages( - @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, - @PathVariable chatRoomId: Long, - @RequestParam(required = false) messageId: Long? - ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") - - val response = chatRoomService.getChatMessages(member, chatRoomId, messageId) - ApiResponse.ok(response) - } - /** * 세션 상태 조회 API * - 채팅방 참여 여부 검증 @@ -119,6 +101,26 @@ class ChatRoomController( ApiResponse.ok(true) } + /** + * 채팅방 메시지 조회 API + * - 참여 여부 검증(미참여시 "잘못된 접근입니다") + * - cursor(메시지ID)보다 더 과거의 메시지에서 limit만큼 조회(경계 exclusive) + * - cursor 미지정 시 최신부터 limit만큼 기준으로 페이징 + */ + @GetMapping("/{chatRoomId}/messages") + fun getChatMessages( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable chatRoomId: Long, + @RequestParam(defaultValue = "20") limit: Int, + @RequestParam(required = false) cursor: Long? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + + val response = chatRoomService.getChatMessages(member, chatRoomId, cursor, limit) + ApiResponse.ok(response) + } + /** * 채팅방 메시지 전송 API * - 참여 여부 검증(미참여시 "잘못된 접근입니다") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt index aa2f930..48dd667 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt @@ -38,7 +38,17 @@ data class ChatMessageItemDto( val messageId: Long, val message: String, val profileImageUrl: String, - val mine: Boolean + val mine: Boolean, + val createdAt: Long +) + +/** + * 채팅방 메시지 페이지 응답 DTO + */ +data class ChatMessagesPageResponse( + val messages: List, + val hasMore: Boolean, + val nextCursor: Long? ) /** diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatMessageRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatMessageRepository.kt index de0c5a4..b6799e9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatMessageRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatMessageRepository.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.chat.room.repository import kr.co.vividnext.sodalive.chat.room.ChatMessage import kr.co.vividnext.sodalive.chat.room.ChatRoom +import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @@ -9,10 +10,23 @@ import org.springframework.stereotype.Repository interface ChatMessageRepository : JpaRepository { fun findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(chatRoom: ChatRoom): ChatMessage? + // 기존 20개 고정 메서드는 유지 (기존 호출 호환) fun findTop20ByChatRoomAndIsActiveTrueOrderByIdDesc(chatRoom: ChatRoom): List fun findTop20ByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc( chatRoom: ChatRoom, id: Long ): List + + // 새로운 커서 기반 페이징용 메서드 (limit 가변) + fun findByChatRoomAndIsActiveTrueOrderByIdDesc(chatRoom: ChatRoom, pageable: Pageable): List + + fun findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc( + chatRoom: ChatRoom, + id: Long, + pageable: Pageable + ): List + + // 더 이전 데이터 존재 여부 확인 + fun existsByChatRoomAndIsActiveTrueAndIdLessThan(chatRoom: ChatRoom, id: Long): Boolean } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index aa54e26..7ff7849 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.chat.room.ChatParticipant import kr.co.vividnext.sodalive.chat.room.ChatRoom import kr.co.vividnext.sodalive.chat.room.ParticipantType import kr.co.vividnext.sodalive.chat.room.dto.ChatMessageItemDto +import kr.co.vividnext.sodalive.chat.room.dto.ChatMessagesPageResponse import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListItemDto import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomResponse @@ -358,35 +359,54 @@ class ChatRoomService( } @Transactional(readOnly = true) - fun getChatMessages(member: Member, chatRoomId: Long, beforeMessageId: Long?): List { + fun getChatMessages(member: Member, chatRoomId: Long, cursor: Long?, limit: Int = 20): ChatMessagesPageResponse { val room = chatRoomRepository.findById(chatRoomId).orElseThrow { SodaException("채팅방을 찾을 수 없습니다.") } - val participant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) - if (participant == null) { - throw SodaException("잘못된 접근입니다") - } + participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) + ?: throw SodaException("잘못된 접근입니다") - val messages = if (beforeMessageId != null) { - messageRepository.findTop20ByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(room, beforeMessageId) + val pageable = PageRequest.of(0, limit) + val fetched = if (cursor != null) { + messageRepository.findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(room, cursor, pageable) } else { - messageRepository.findTop20ByChatRoomAndIsActiveTrueOrderByIdDesc(room) + messageRepository.findByChatRoomAndIsActiveTrueOrderByIdDesc(room, pageable) } - return messages.map { msg -> + // 가장 오래된 메시지 ID (nextCursor) 및 hasMore 계산 + val nextCursor: Long? = fetched.minByOrNull { it.id ?: Long.MAX_VALUE }?.id + val hasMore: Boolean = if (nextCursor != null) { + messageRepository.existsByChatRoomAndIsActiveTrueAndIdLessThan(room, nextCursor) + } else { + false + } + + // createdAt 오름차순으로 정렬하여 반환 + val messagesAsc = fetched.sortedBy { it.createdAt } + + val items = messagesAsc.map { msg -> val sender = msg.participant val profilePath = when (sender.participantType) { ParticipantType.USER -> sender.member?.profileImage ParticipantType.CHARACTER -> sender.character?.imagePath } val imageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}" + val createdAtMillis = msg.createdAt?.atZone(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli() + ?: 0L ChatMessageItemDto( messageId = msg.id!!, message = msg.message, profileImageUrl = imageUrl, - mine = sender.member?.id == member.id + mine = sender.member?.id == member.id, + createdAt = createdAtMillis ) } + + return ChatMessagesPageResponse( + messages = items, + hasMore = hasMore, + nextCursor = nextCursor + ) } @Transactional @@ -441,7 +461,10 @@ class ChatRoomService( messageId = savedCharacterMsg.id!!, message = savedCharacterMsg.message, profileImageUrl = imageUrl, - mine = false + mine = false, + createdAt = savedCharacterMsg.createdAt?.atZone(java.time.ZoneId.systemDefault())?.toInstant() + ?.toEpochMilli() + ?: 0L ) return SendChatMessageResponse(characterMessages = listOf(dto))