캐릭터 챗봇 #338
| @@ -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 | ||||
|      * - 참여 여부 검증(미참여시 "잘못된 접근입니다") | ||||
|   | ||||
| @@ -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? | ||||
| ) | ||||
|  | ||||
| /** | ||||
|   | ||||
| @@ -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 | ||||
| } | ||||
|   | ||||
| @@ -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)) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user