feat(chat): 채팅방 리스트 조회 API를 추가한다

This commit is contained in:
2026-05-14 16:12:14 +09:00
parent 3a2c21c896
commit acd0393a0e
8 changed files with 650 additions and 2 deletions

View File

@@ -9,6 +9,8 @@ import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListQueryDto as V2ChatRoomListQueryDto
@Repository
interface ChatRoomRepository : JpaRepository<ChatRoom, Long> {
@@ -64,5 +66,52 @@ interface ChatRoomRepository : JpaRepository<ChatRoom, Long> {
pageable: Pageable
): List<ChatRoomListQueryDto>
@Query(
"""
SELECT new kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListQueryDto(
r.id,
'AI',
pc.character.name,
pc.character.imagePath,
m.message,
str(m.messageType),
m.createdAt
)
FROM ChatRoom r
JOIN r.participants p
JOIN r.participants pc
JOIN r.messages m
WHERE p.member.id = :memberId
AND p.isActive = true
AND pc.participantType = kr.co.vividnext.sodalive.chat.room.ParticipantType.CHARACTER
AND pc.isActive = true
AND r.isActive = true
AND m.isActive = true
AND (
:cursorAt IS NULL
OR m.createdAt < :cursorAt
OR (m.createdAt = :cursorAt AND 'AI' < :cursorChatType)
OR (m.createdAt = :cursorAt AND 'AI' = :cursorChatType AND r.id < :cursorRoomId)
)
AND NOT EXISTS (
SELECT 1 FROM ChatMessage newer
WHERE newer.chatRoom = r
AND newer.isActive = true
AND (
newer.createdAt > m.createdAt
OR (newer.createdAt = m.createdAt AND newer.id > m.id)
)
)
ORDER BY m.createdAt DESC, r.id DESC
"""
)
fun findAiChatListRooms(
@Param("memberId") memberId: Long,
@Param("cursorAt") cursorAt: LocalDateTime?,
@Param("cursorChatType") cursorChatType: String?,
@Param("cursorRoomId") cursorRoomId: Long?,
pageable: Pageable
): List<V2ChatRoomListQueryDto>
fun findByIdAndIsActiveTrue(id: Long): ChatRoom?
}

View File

@@ -0,0 +1,28 @@
package kr.co.vividnext.sodalive.v2.chat.controller
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.chat.service.ChatRoomListService
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/chat/rooms")
class ChatRoomListController(
private val service: ChatRoomListService
) {
@GetMapping
fun getRooms(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@RequestParam(defaultValue = "ALL") filter: String,
@RequestParam(required = false) cursor: String?,
@RequestParam(defaultValue = "30") limit: Int
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(service.getRooms(member, filter, cursor, limit))
}
}

View File

@@ -0,0 +1,28 @@
package kr.co.vividnext.sodalive.v2.chat.dto
import java.time.LocalDateTime
data class ChatRoomListPageResponse(
val rooms: List<ChatRoomListItemResponse>,
val hasMore: Boolean,
val nextCursor: String?
)
data class ChatRoomListItemResponse(
val roomId: Long,
val chatType: String,
val targetName: String,
val targetImageUrl: String,
val lastMessage: String,
val lastMessageAt: String
)
data class ChatRoomListQueryDto(
val roomId: Long,
val chatType: String,
val targetName: String,
val targetImagePath: String?,
val lastMessage: String?,
val messageType: String,
val lastMessageAt: LocalDateTime
)

View File

@@ -0,0 +1,156 @@
package kr.co.vividnext.sodalive.v2.chat.service
import kr.co.vividnext.sodalive.chat.room.ChatMessageType
import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse
import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListPageResponse
import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListQueryDto
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessageType
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatRoomRepository
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.ZoneOffset
@Service
@Transactional(readOnly = true)
class ChatRoomListService(
private val aiRoomRepository: ChatRoomRepository,
private val dmRoomRepository: UserCreatorChatRoomRepository,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
fun getRooms(
member: Member,
filter: String = ChatRoomListFilter.ALL.name,
cursor: String? = null,
limit: Int = DEFAULT_LIMIT
): ChatRoomListPageResponse {
val type = ChatRoomListFilter.from(filter)
val safeLimit = limit.coerceIn(1, DEFAULT_LIMIT)
val pageable = PageRequest.of(0, safeLimit + 1)
val parsedCursor = parseCursor(cursor)
val rows = when (type) {
ChatRoomListFilter.ALL -> {
aiRoomRepository.findAiChatListRooms(
member.id!!,
parsedCursor?.lastMessageAt,
parsedCursor?.chatType,
parsedCursor?.roomId,
pageable
) + dmRoomRepository.findDmChatListRooms(
member.id!!,
parsedCursor?.lastMessageAt,
parsedCursor?.chatType,
parsedCursor?.roomId,
pageable
)
}
ChatRoomListFilter.AI -> {
aiRoomRepository.findAiChatListRooms(
member.id!!,
parsedCursor?.lastMessageAt,
parsedCursor?.chatType,
parsedCursor?.roomId,
pageable
)
}
ChatRoomListFilter.DM -> {
dmRoomRepository.findDmChatListRooms(
member.id!!,
parsedCursor?.lastMessageAt,
parsedCursor?.chatType,
parsedCursor?.roomId,
pageable
)
}
}.sortedWith(
compareByDescending<ChatRoomListQueryDto> { it.lastMessageAt }
.thenByDescending { it.chatType }
.thenByDescending { it.roomId }
)
.filter { parsedCursor == null || it.isAfter(parsedCursor) }
val pageRows = rows.take(safeLimit)
return ChatRoomListPageResponse(
rooms = pageRows.map { it.toResponse() },
hasMore = rows.size > safeLimit,
nextCursor = if (rows.size > safeLimit) pageRows.lastOrNull()?.toCursor() else null
)
}
private fun ChatRoomListQueryDto.toResponse(): ChatRoomListItemResponse {
return ChatRoomListItemResponse(
roomId = roomId,
chatType = chatType,
targetName = targetName,
targetImageUrl = imageUrl(targetImagePath),
lastMessage = previewMessage(),
lastMessageAt = lastMessageAt.toUtcIsoString()
)
}
private fun ChatRoomListQueryDto.previewMessage(): String {
if (messageType == UserCreatorChatMessageType.VOICE.name) return VOICE_PREVIEW
if (messageType == ChatMessageType.IMAGE.name) return lastMessage.orEmpty().ifBlank { "이미지 메시지" }
val message = lastMessage.orEmpty()
return if (message.length > PREVIEW_LENGTH) message.take(PREVIEW_LENGTH) + "..." else message
}
private fun imageUrl(path: String?): String {
return "$cloudFrontHost/${path?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_IMAGE_PATH}"
}
private fun ChatRoomListQueryDto.toCursor(): String {
return "$lastMessageAt:$chatType:$roomId"
}
private fun parseCursor(cursor: String?): ChatRoomListCursor? {
if (cursor == null) return null
val roomId = cursor.substringAfterLast(":").toLong()
val cursorWithoutRoomId = cursor.substringBeforeLast(":")
val chatType = cursorWithoutRoomId.substringAfterLast(":")
val lastMessageAt = LocalDateTime.parse(cursorWithoutRoomId.substringBeforeLast(":"))
return ChatRoomListCursor(lastMessageAt, chatType, roomId)
}
private fun LocalDateTime.toUtcIsoString(): String {
return atOffset(ZoneOffset.UTC).toInstant().toString()
}
private fun ChatRoomListQueryDto.isAfter(cursor: ChatRoomListCursor): Boolean {
if (lastMessageAt.isBefore(cursor.lastMessageAt)) return true
if (lastMessageAt.isAfter(cursor.lastMessageAt)) return false
if (chatType < cursor.chatType) return true
if (chatType > cursor.chatType) return false
return roomId < cursor.roomId
}
private data class ChatRoomListCursor(
val lastMessageAt: LocalDateTime,
val chatType: String,
val roomId: Long
)
private enum class ChatRoomListFilter {
ALL, AI, DM;
companion object {
fun from(value: String): ChatRoomListFilter {
return values().firstOrNull { it.name == value.uppercase() }
?: throw SodaException(messageKey = "common.error.invalid_request")
}
}
}
companion object {
private const val DEFAULT_LIMIT = 30
private const val PREVIEW_LENGTH = 15
private const val DEFAULT_PROFILE_IMAGE_PATH = "profile/default-profile.png"
private const val VOICE_PREVIEW = "[음성 메시지]"
}
}

View File

@@ -1,10 +1,13 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.repository
import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListQueryDto
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatRoom
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
@Repository
interface UserCreatorChatRoomRepository : JpaRepository<UserCreatorChatRoom, Long> {
@@ -32,4 +35,51 @@ interface UserCreatorChatRoomRepository : JpaRepository<UserCreatorChatRoom, Lon
@Param("firstMemberId") firstMemberId: Long,
@Param("secondMemberId") secondMemberId: Long
): UserCreatorChatRoom?
@Query(
"""
select new kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListQueryDto(
r.id,
'DM',
opponent.member.nickname,
opponent.member.profileImage,
m.textMessage,
str(m.messageType),
m.createdAt
)
from UserCreatorChatRoom r
join r.participants mine
join r.participants opponent
join r.messages m
where r.isActive = true
and mine.isActive = true
and mine.member.id = :memberId
and opponent.isActive = true
and opponent.member.id <> :memberId
and m.isActive = true
and (
:cursorAt is null
or m.createdAt < :cursorAt
or (m.createdAt = :cursorAt and 'DM' < :cursorChatType)
or (m.createdAt = :cursorAt and 'DM' = :cursorChatType and r.id < :cursorRoomId)
)
and not exists (
select 1 from UserCreatorChatMessage newer
where newer.chatRoom = r
and newer.isActive = true
and (
newer.createdAt > m.createdAt
or (newer.createdAt = m.createdAt and newer.id > m.id)
)
)
order by m.createdAt desc, r.id desc
"""
)
fun findDmChatListRooms(
@Param("memberId") memberId: Long,
@Param("cursorAt") cursorAt: LocalDateTime?,
@Param("cursorChatType") cursorChatType: String?,
@Param("cursorRoomId") cursorRoomId: Long?,
pageable: Pageable
): List<ChatRoomListQueryDto>
}