캐릭터 챗봇 #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 |      * 세션 상태 조회 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)) | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user