feat(chat): 채팅방 입장 API와 메시지 페이징 초기 로드 구현
- GET /api/chat/room/{chatRoomId}/enter 엔드포인트 추가
- 참여 검증 후 roomId, character(아이디/이름/프로필/타입) 제공
- 최신 20개 메시지 조회(내림차순 조회 후 createdAt 오름차순으로 정렬)
- hasMoreMessages 플래그 계산(가장 오래된 메시지 이전 존재 여부 판단)
			
			
This commit is contained in:
		| @@ -82,6 +82,23 @@ class ChatRoomController( | |||||||
|         ApiResponse.ok(isActive) |         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 |      * 채팅방 나가기 API | ||||||
|      * - URL에 chatRoomId 포함 |      * - URL에 chatRoomId 포함 | ||||||
|   | |||||||
| @@ -161,3 +161,20 @@ data class ExternalCharacterMessage( | |||||||
|     @JsonProperty("timestamp") val timestamp: String, |     @JsonProperty("timestamp") val timestamp: String, | ||||||
|     @JsonProperty("messageType") val messageType: 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 | ||||||
|  | ) | ||||||
|   | |||||||
| @@ -8,6 +8,8 @@ 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.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.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 | ||||||
| @@ -247,6 +249,70 @@ class ChatRoomService( | |||||||
|         return fetchSessionActive(room.sessionId) |         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 { |     private fun fetchSessionActive(sessionId: String): Boolean { | ||||||
|         try { |         try { | ||||||
|             val factory = SimpleClientHttpRequestFactory() |             val factory = SimpleClientHttpRequestFactory() | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user