Compare commits

..

3 Commits

Author SHA1 Message Date
27ed9f61d0 fix(chat): 채팅방 메시지 전송 API 반환값 수정
- 기존: SendChatMessageResponse으로 메시지 리스트를 한 번 더 Wrapping해서 보냄

- 수정: 메시지 리스트 반환
2025-08-14 22:00:42 +09:00
df77e31043 feat(chat): 채팅방 입장 API와 메시지 페이징 초기 로드 구현
- GET /api/chat/room/{chatRoomId}/enter 엔드포인트 추가
- 참여 검증 후 roomId, character(아이디/이름/프로필/타입) 제공
- 최신 20개 메시지 조회(내림차순 조회 후 createdAt 오름차순으로 정렬)
- hasMoreMessages 플래그 계산(가장 오래된 메시지 이전 존재 여부 판단)
2025-08-14 21:56:27 +09:00
2d65bdb8ee feat(chat): 채팅방 메시지 조회 API에 커서 기반 페이징 도입 및 createdAt 추가
cursor(< messageId) 기준의 커서 페이징 도입, 경계 exclusive 처리
limit 파라미터로 페이지 사이즈 가변화 (기본 20)
응답 스키마를 ChatMessagesPageResponse(messages, hasMore, nextCursor)로 변경
메시지 정렬을 createdAt 오름차순(표시 시간 순)으로 반환
ChatMessageItemDto에 createdAt(epoch millis) 필드 추가
레포지토리에 Pageable 기반 조회 및 이전 데이터 존재 여부 검사 메서드 추가
컨트롤러/서비스 시그니처 및 내부 로직 업데이트
2025-08-14 21:43:42 +09:00
4 changed files with 181 additions and 40 deletions

View File

@@ -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
* - 채팅방 참여 여부 검증
@@ -100,6 +82,23 @@ class ChatRoomController(
ApiResponse.ok(isActive)
}
/**
* 채팅방 입장 API
* - 참여 여부 검증
* - 최신 20개 메시지를 createdAt 오름차순으로 반환
*/
@GetMapping("/{chatRoomId}/enter")
fun enterChatRoom(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable chatRoomId: Long
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
val response = chatRoomService.enterChatRoom(member, chatRoomId)
ApiResponse.ok(response)
}
/**
* 채팅방 나가기 API
* - URL에 chatRoomId 포함
@@ -119,6 +118,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
* - 참여 여부 검증(미참여시 "잘못된 접근입니다")

View File

@@ -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<ChatMessageItemDto>,
val hasMore: Boolean,
val nextCursor: Long?
)
/**
@@ -116,13 +126,6 @@ data class SendChatMessageRequest(
val message: String
)
/**
* 채팅 메시지 전송 응답 DTO (캐릭터 메시지 리스트)
*/
data class SendChatMessageResponse(
val characterMessages: List<ChatMessageItemDto>
)
/**
* 외부 API 채팅 전송 응답 DTO
*/
@@ -151,3 +154,20 @@ data class ExternalCharacterMessage(
@JsonProperty("timestamp") val timestamp: String,
@JsonProperty("messageType") val messageType: String
)
/**
* 채팅방 입장 응답 DTO
*/
data class ChatRoomEnterCharacterDto(
val characterId: Long,
val name: String,
val profileImageUrl: String,
val characterType: String
)
data class ChatRoomEnterResponse(
val roomId: Long,
val character: ChatRoomEnterCharacterDto,
val messages: List<ChatMessageItemDto>,
val hasMoreMessages: Boolean
)

View File

@@ -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<ChatMessage, Long> {
fun findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(chatRoom: ChatRoom): ChatMessage?
// 기존 20개 고정 메서드는 유지 (기존 호출 호환)
fun findTop20ByChatRoomAndIsActiveTrueOrderByIdDesc(chatRoom: ChatRoom): List<ChatMessage>
fun findTop20ByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(
chatRoom: ChatRoom,
id: Long
): 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
}

View File

@@ -7,13 +7,15 @@ 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.ChatRoomEnterCharacterDto
import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomEnterResponse
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
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSendResponse
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionCreateResponse
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionGetResponse
import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageResponse
import kr.co.vividnext.sodalive.chat.room.repository.ChatMessageRepository
import kr.co.vividnext.sodalive.chat.room.repository.ChatParticipantRepository
import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository
@@ -246,6 +248,70 @@ class ChatRoomService(
return fetchSessionActive(room.sessionId)
}
@Transactional(readOnly = true)
fun enterChatRoom(member: Member, chatRoomId: Long): ChatRoomEnterResponse {
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
SodaException("채팅방을 찾을 수 없습니다.")
}
// 참여 여부 검증
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
?: throw SodaException("잘못된 접근입니다")
// 캐릭터 참여자 조회
val characterParticipant = participantRepository.findByChatRoomAndParticipantTypeAndIsActiveTrue(
room,
ParticipantType.CHARACTER
) ?: throw SodaException("잘못된 접근입니다")
val character = characterParticipant.character
?: throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.")
val imageUrl = "$imageHost/${character.imagePath ?: "profile/default-profile.png"}"
val characterDto = ChatRoomEnterCharacterDto(
characterId = character.id!!,
name = character.name,
profileImageUrl = imageUrl,
characterType = character.characterType.name
)
// 메시지 최신 20개 조회 후 createdAt 오름차순으로 반환
val pageable = PageRequest.of(0, 20)
val fetched = messageRepository.findByChatRoomAndIsActiveTrueOrderByIdDesc(room, pageable)
val nextCursor: Long? = fetched.minByOrNull { it.id ?: Long.MAX_VALUE }?.id
val hasMore: Boolean = if (nextCursor != null) {
messageRepository.existsByChatRoomAndIsActiveTrueAndIdLessThan(room, nextCursor)
} else {
false
}
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 senderImageUrl = "$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 = senderImageUrl,
mine = sender.member?.id == member.id,
createdAt = createdAtMillis
)
}
return ChatRoomEnterResponse(
roomId = room.id!!,
character = characterDto,
messages = items,
hasMoreMessages = hasMore
)
}
private fun fetchSessionActive(sessionId: String): Boolean {
try {
val factory = SimpleClientHttpRequestFactory()
@@ -358,39 +424,58 @@ class ChatRoomService(
}
@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 {
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
fun sendMessage(member: Member, chatRoomId: Long, message: String): SendChatMessageResponse {
fun sendMessage(member: Member, chatRoomId: Long, message: String): List<ChatMessageItemDto> {
// 1) 방 존재 확인
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
SodaException("채팅방을 찾을 수 없습니다.")
@@ -441,10 +526,13 @@ 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))
return listOf(dto)
}
private fun callExternalApiForChatSendWithRetry(