feat(chat): 채팅방 입장 API와 메시지 페이징 초기 로드 구현

- GET /api/chat/room/{chatRoomId}/enter 엔드포인트 추가
- 참여 검증 후 roomId, character(아이디/이름/프로필/타입) 제공
- 최신 20개 메시지 조회(내림차순 조회 후 createdAt 오름차순으로 정렬)
- hasMoreMessages 플래그 계산(가장 오래된 메시지 이전 존재 여부 판단)
This commit is contained in:
Klaus 2025-08-14 21:56:27 +09:00
parent 2d65bdb8ee
commit df77e31043
3 changed files with 100 additions and 0 deletions

View File

@ -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 포함

View File

@ -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
)

View File

@ -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()