캐릭터 챗봇 #338
| @@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.PathVariable | |||||||
| import org.springframework.web.bind.annotation.PostMapping | import org.springframework.web.bind.annotation.PostMapping | ||||||
| import org.springframework.web.bind.annotation.RequestBody | import org.springframework.web.bind.annotation.RequestBody | ||||||
| import org.springframework.web.bind.annotation.RequestMapping | import org.springframework.web.bind.annotation.RequestMapping | ||||||
|  | import org.springframework.web.bind.annotation.RequestParam | ||||||
| import org.springframework.web.bind.annotation.RestController | import org.springframework.web.bind.annotation.RestController | ||||||
|  |  | ||||||
| @RestController | @RestController | ||||||
| @@ -62,6 +63,24 @@ 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 | ||||||
|      * - 채팅방 참여 여부 검증 |      * - 채팅방 참여 여부 검증 | ||||||
|   | |||||||
| @@ -24,6 +24,16 @@ data class ChatRoomListItemDto( | |||||||
|     val lastMessagePreview: String? |     val lastMessagePreview: String? | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 채팅방 메시지 아이템 DTO (API 응답용) | ||||||
|  |  */ | ||||||
|  | data class ChatMessageItemDto( | ||||||
|  |     val messageId: Long, | ||||||
|  |     val message: String, | ||||||
|  |     val profileImageUrl: String, | ||||||
|  |     val mine: Boolean | ||||||
|  | ) | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * 채팅방 목록 쿼리 DTO (레포지토리 투영용) |  * 채팅방 목록 쿼리 DTO (레포지토리 투영용) | ||||||
|  */ |  */ | ||||||
|   | |||||||
| @@ -8,4 +8,11 @@ import org.springframework.stereotype.Repository | |||||||
| @Repository | @Repository | ||||||
| interface CharacterChatMessageRepository : JpaRepository<CharacterChatMessage, Long> { | interface CharacterChatMessageRepository : JpaRepository<CharacterChatMessage, Long> { | ||||||
|     fun findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(chatRoom: CharacterChatRoom): CharacterChatMessage? |     fun findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(chatRoom: CharacterChatRoom): CharacterChatMessage? | ||||||
|  |  | ||||||
|  |     fun findTop20ByChatRoomAndIsActiveTrueOrderByIdDesc(chatRoom: CharacterChatRoom): List<CharacterChatMessage> | ||||||
|  |  | ||||||
|  |     fun findTop20ByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc( | ||||||
|  |         chatRoom: CharacterChatRoom, | ||||||
|  |         id: Long | ||||||
|  |     ): List<CharacterChatMessage> | ||||||
| } | } | ||||||
|   | |||||||
| @@ -5,6 +5,7 @@ import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService | |||||||
| import kr.co.vividnext.sodalive.chat.room.CharacterChatParticipant | import kr.co.vividnext.sodalive.chat.room.CharacterChatParticipant | ||||||
| import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom | import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom | ||||||
| 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.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 | ||||||
| @@ -324,4 +325,36 @@ class ChatRoomService( | |||||||
|         // 최종 실패 로그 (예외 미전파) |         // 최종 실패 로그 (예외 미전파) | ||||||
|         log.error("[chat] 외부 세션 종료 최종 실패: sessionId={}, attempts={}", sessionId, maxAttempts) |         log.error("[chat] 외부 세션 종료 최종 실패: sessionId={}, attempts={}", sessionId, maxAttempts) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     @Transactional(readOnly = true) | ||||||
|  |     fun getChatMessages(member: Member, chatRoomId: Long, beforeMessageId: Long?): List<ChatMessageItemDto> { | ||||||
|  |         val room = chatRoomRepository.findById(chatRoomId).orElseThrow { | ||||||
|  |             SodaException("채팅방을 찾을 수 없습니다.") | ||||||
|  |         } | ||||||
|  |         val participant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) | ||||||
|  |         if (participant == null) { | ||||||
|  |             throw SodaException("잘못된 접근입니다") | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         val messages = if (beforeMessageId != null) { | ||||||
|  |             messageRepository.findTop20ByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(room, beforeMessageId) | ||||||
|  |         } else { | ||||||
|  |             messageRepository.findTop20ByChatRoomAndIsActiveTrueOrderByIdDesc(room) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return messages.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"}" | ||||||
|  |             ChatMessageItemDto( | ||||||
|  |                 messageId = msg.id!!, | ||||||
|  |                 message = msg.message, | ||||||
|  |                 profileImageUrl = imageUrl, | ||||||
|  |                 mine = sender.member?.id == member.id | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user