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) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 채팅방 입장 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 | ||||
|      * - URL에 chatRoomId 포함 | ||||
|   | ||||
| @@ -161,3 +161,20 @@ data class ExternalCharacterMessage( | ||||
|     @JsonProperty("timestamp") val timestamp: 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.dto.ChatMessageItemDto | ||||
| 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.ChatRoomListQueryDto | ||||
| import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomResponse | ||||
| @@ -247,6 +249,70 @@ class ChatRoomService( | ||||
|         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 { | ||||
|         try { | ||||
|             val factory = SimpleClientHttpRequestFactory() | ||||
|   | ||||
		Reference in New Issue
	
	Block a user