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))