feat(chat): 채팅방 메시지 조회 API에 커서 기반 페이징 도입 및 createdAt 추가
cursor(< messageId) 기준의 커서 페이징 도입, 경계 exclusive 처리 limit 파라미터로 페이지 사이즈 가변화 (기본 20) 응답 스키마를 ChatMessagesPageResponse(messages, hasMore, nextCursor)로 변경 메시지 정렬을 createdAt 오름차순(표시 시간 순)으로 반환 ChatMessageItemDto에 createdAt(epoch millis) 필드 추가 레포지토리에 Pageable 기반 조회 및 이전 데이터 존재 여부 검사 메서드 추가 컨트롤러/서비스 시그니처 및 내부 로직 업데이트
This commit is contained in:
parent
4966aaeda9
commit
2d65bdb8ee
|
@ -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
|
* 세션 상태 조회 API
|
||||||
* - 채팅방 참여 여부 검증
|
* - 채팅방 참여 여부 검증
|
||||||
|
@ -119,6 +101,26 @@ class ChatRoomController(
|
||||||
ApiResponse.ok(true)
|
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
|
* 채팅방 메시지 전송 API
|
||||||
* - 참여 여부 검증(미참여시 "잘못된 접근입니다")
|
* - 참여 여부 검증(미참여시 "잘못된 접근입니다")
|
||||||
|
|
|
@ -38,7 +38,17 @@ data class ChatMessageItemDto(
|
||||||
val messageId: Long,
|
val messageId: Long,
|
||||||
val message: String,
|
val message: String,
|
||||||
val profileImageUrl: String,
|
val profileImageUrl: String,
|
||||||
val mine: Boolean
|
val mine: Boolean,
|
||||||
|
val createdAt: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 채팅방 메시지 페이지 응답 DTO
|
||||||
|
*/
|
||||||
|
data class ChatMessagesPageResponse(
|
||||||
|
val messages: List<ChatMessageItemDto>,
|
||||||
|
val hasMore: Boolean,
|
||||||
|
val nextCursor: Long?
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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.ChatMessage
|
||||||
import kr.co.vividnext.sodalive.chat.room.ChatRoom
|
import kr.co.vividnext.sodalive.chat.room.ChatRoom
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
|
|
||||||
|
@ -9,10 +10,23 @@ import org.springframework.stereotype.Repository
|
||||||
interface ChatMessageRepository : JpaRepository<ChatMessage, Long> {
|
interface ChatMessageRepository : JpaRepository<ChatMessage, Long> {
|
||||||
fun findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(chatRoom: ChatRoom): ChatMessage?
|
fun findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(chatRoom: ChatRoom): ChatMessage?
|
||||||
|
|
||||||
|
// 기존 20개 고정 메서드는 유지 (기존 호출 호환)
|
||||||
fun findTop20ByChatRoomAndIsActiveTrueOrderByIdDesc(chatRoom: ChatRoom): List<ChatMessage>
|
fun findTop20ByChatRoomAndIsActiveTrueOrderByIdDesc(chatRoom: ChatRoom): List<ChatMessage>
|
||||||
|
|
||||||
fun findTop20ByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(
|
fun findTop20ByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(
|
||||||
chatRoom: ChatRoom,
|
chatRoom: ChatRoom,
|
||||||
id: Long
|
id: Long
|
||||||
): List<ChatMessage>
|
): List<ChatMessage>
|
||||||
|
|
||||||
|
// 새로운 커서 기반 페이징용 메서드 (limit 가변)
|
||||||
|
fun findByChatRoomAndIsActiveTrueOrderByIdDesc(chatRoom: ChatRoom, pageable: Pageable): List<ChatMessage>
|
||||||
|
|
||||||
|
fun findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(
|
||||||
|
chatRoom: ChatRoom,
|
||||||
|
id: Long,
|
||||||
|
pageable: Pageable
|
||||||
|
): List<ChatMessage>
|
||||||
|
|
||||||
|
// 더 이전 데이터 존재 여부 확인
|
||||||
|
fun existsByChatRoomAndIsActiveTrueAndIdLessThan(chatRoom: ChatRoom, id: Long): Boolean
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.ChatRoom
|
||||||
import kr.co.vividnext.sodalive.chat.room.ParticipantType
|
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.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.ChatRoomListItemDto
|
||||||
import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto
|
import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto
|
||||||
import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomResponse
|
import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomResponse
|
||||||
|
@ -358,35 +359,54 @@ class ChatRoomService(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getChatMessages(member: Member, chatRoomId: Long, beforeMessageId: Long?): List<ChatMessageItemDto> {
|
fun getChatMessages(member: Member, chatRoomId: Long, cursor: Long?, limit: Int = 20): ChatMessagesPageResponse {
|
||||||
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
|
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
|
||||||
SodaException("채팅방을 찾을 수 없습니다.")
|
SodaException("채팅방을 찾을 수 없습니다.")
|
||||||
}
|
}
|
||||||
val participant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
|
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
|
||||||
if (participant == null) {
|
?: throw SodaException("잘못된 접근입니다")
|
||||||
throw SodaException("잘못된 접근입니다")
|
|
||||||
}
|
|
||||||
|
|
||||||
val messages = if (beforeMessageId != null) {
|
val pageable = PageRequest.of(0, limit)
|
||||||
messageRepository.findTop20ByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(room, beforeMessageId)
|
val fetched = if (cursor != null) {
|
||||||
|
messageRepository.findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(room, cursor, pageable)
|
||||||
} else {
|
} 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 sender = msg.participant
|
||||||
val profilePath = when (sender.participantType) {
|
val profilePath = when (sender.participantType) {
|
||||||
ParticipantType.USER -> sender.member?.profileImage
|
ParticipantType.USER -> sender.member?.profileImage
|
||||||
ParticipantType.CHARACTER -> sender.character?.imagePath
|
ParticipantType.CHARACTER -> sender.character?.imagePath
|
||||||
}
|
}
|
||||||
val imageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}"
|
val imageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}"
|
||||||
|
val createdAtMillis = msg.createdAt?.atZone(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
|
||||||
|
?: 0L
|
||||||
ChatMessageItemDto(
|
ChatMessageItemDto(
|
||||||
messageId = msg.id!!,
|
messageId = msg.id!!,
|
||||||
message = msg.message,
|
message = msg.message,
|
||||||
profileImageUrl = imageUrl,
|
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
|
@Transactional
|
||||||
|
@ -441,7 +461,10 @@ class ChatRoomService(
|
||||||
messageId = savedCharacterMsg.id!!,
|
messageId = savedCharacterMsg.id!!,
|
||||||
message = savedCharacterMsg.message,
|
message = savedCharacterMsg.message,
|
||||||
profileImageUrl = imageUrl,
|
profileImageUrl = imageUrl,
|
||||||
mine = false
|
mine = false,
|
||||||
|
createdAt = savedCharacterMsg.createdAt?.atZone(java.time.ZoneId.systemDefault())?.toInstant()
|
||||||
|
?.toEpochMilli()
|
||||||
|
?: 0L
|
||||||
)
|
)
|
||||||
|
|
||||||
return SendChatMessageResponse(characterMessages = listOf(dto))
|
return SendChatMessageResponse(characterMessages = listOf(dto))
|
||||||
|
|
Loading…
Reference in New Issue