캐릭터 챗봇 #338

Merged
klaus merged 119 commits from test into main 2025-09-10 06:08:47 +00:00
4 changed files with 79 additions and 30 deletions
Showing only changes of commit 2d65bdb8ee - Show all commits

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
* - 채팅방 참여 여부 검증
@ -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
* - 참여 여부 검증(미참여시 "잘못된 접근입니다")

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?
)
/**

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