Compare commits
3 Commits
4966aaeda9
...
27ed9f61d0
Author | SHA1 | Date | |
---|---|---|---|
27ed9f61d0 | |||
df77e31043 | |||
2d65bdb8ee |
@@ -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
|
||||||
* - 채팅방 참여 여부 검증
|
* - 채팅방 참여 여부 검증
|
||||||
@@ -100,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 포함
|
||||||
@@ -119,6 +118,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?
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,13 +126,6 @@ data class SendChatMessageRequest(
|
|||||||
val message: String
|
val message: String
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* 채팅 메시지 전송 응답 DTO (캐릭터 메시지 리스트)
|
|
||||||
*/
|
|
||||||
data class SendChatMessageResponse(
|
|
||||||
val characterMessages: List<ChatMessageItemDto>
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 외부 API 채팅 전송 응답 DTO
|
* 외부 API 채팅 전송 응답 DTO
|
||||||
*/
|
*/
|
||||||
@@ -151,3 +154,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
|
||||||
|
)
|
||||||
|
@@ -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,13 +7,15 @@ 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.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
|
||||||
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSendResponse
|
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSendResponse
|
||||||
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionCreateResponse
|
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionCreateResponse
|
||||||
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionGetResponse
|
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionGetResponse
|
||||||
import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageResponse
|
|
||||||
import kr.co.vividnext.sodalive.chat.room.repository.ChatMessageRepository
|
import kr.co.vividnext.sodalive.chat.room.repository.ChatMessageRepository
|
||||||
import kr.co.vividnext.sodalive.chat.room.repository.ChatParticipantRepository
|
import kr.co.vividnext.sodalive.chat.room.repository.ChatParticipantRepository
|
||||||
import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository
|
import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository
|
||||||
@@ -246,6 +248,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()
|
||||||
@@ -358,39 +424,58 @@ 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
|
||||||
fun sendMessage(member: Member, chatRoomId: Long, message: String): SendChatMessageResponse {
|
fun sendMessage(member: Member, chatRoomId: Long, message: String): List<ChatMessageItemDto> {
|
||||||
// 1) 방 존재 확인
|
// 1) 방 존재 확인
|
||||||
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
|
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
|
||||||
SodaException("채팅방을 찾을 수 없습니다.")
|
SodaException("채팅방을 찾을 수 없습니다.")
|
||||||
@@ -441,10 +526,13 @@ 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 listOf(dto)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun callExternalApiForChatSendWithRetry(
|
private fun callExternalApiForChatSendWithRetry(
|
||||||
|
Reference in New Issue
Block a user