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>
}

View File

@@ -0,0 +1,179 @@
package kr.co.vividnext.sodalive.v2.chat
import kr.co.vividnext.sodalive.chat.room.ChatMessageType
import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListQueryDto
import kr.co.vividnext.sodalive.v2.chat.service.ChatRoomListService
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessageType
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatRoomRepository
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.data.domain.PageRequest
import java.time.LocalDateTime
class ChatRoomListServiceTest {
private lateinit var aiRoomRepository: ChatRoomRepository
private lateinit var dmRoomRepository: UserCreatorChatRoomRepository
private lateinit var service: ChatRoomListService
@BeforeEach
fun setUp() {
aiRoomRepository = Mockito.mock(ChatRoomRepository::class.java)
dmRoomRepository = Mockito.mock(UserCreatorChatRoomRepository::class.java)
service = ChatRoomListService(
aiRoomRepository = aiRoomRepository,
dmRoomRepository = dmRoomRepository,
cloudFrontHost = "https://cdn.test"
)
}
@Test
@DisplayName("전체 채팅 리스트는 AI와 DM을 최신순으로 병합하고 30개 페이징한다")
fun shouldMergeAiAndDmRoomsByLastMessageAt() {
val member = member(1L)
val aiLastAt = LocalDateTime.of(2026, 5, 14, 11, 0)
val dmLastAt = LocalDateTime.of(2026, 5, 14, 12, 0)
Mockito.`when`(aiRoomRepository.findAiChatListRooms(1L, null, null, null, PageRequest.of(0, 31))).thenReturn(
listOf(
ChatRoomListQueryDto(
roomId = 10L,
chatType = "AI",
targetName = "AI 캐릭터",
targetImagePath = "character/a.png",
lastMessage = "AI hello",
messageType = ChatMessageType.TEXT.name,
lastMessageAt = aiLastAt
)
)
)
Mockito.`when`(dmRoomRepository.findDmChatListRooms(1L, null, null, null, PageRequest.of(0, 31))).thenReturn(
listOf(
ChatRoomListQueryDto(
roomId = 20L,
chatType = "DM",
targetName = "creator",
targetImagePath = null,
lastMessage = "안녕하세요. 문의드립니다. 길게 보냅니다.",
messageType = UserCreatorChatMessageType.TEXT.name,
lastMessageAt = dmLastAt
)
)
)
val response = service.getRooms(member, filter = "ALL", cursor = null, limit = 30)
assertFalse(response.hasMore)
assertEquals(listOf("DM", "AI"), response.rooms.map { it.chatType })
assertEquals("creator", response.rooms[0].targetName)
assertEquals("https://cdn.test/profile/default-profile.png", response.rooms[0].targetImageUrl)
assertEquals("안녕하세요. 문의드립니다. ...", response.rooms[0].lastMessage)
assertEquals("2026-05-14T12:00:00Z", response.rooms[0].lastMessageAt)
}
@Test
@DisplayName("DM 필터는 DM 방만 조회하고 음성 메시지 요약 문구를 사용한다")
fun shouldReturnOnlyDmRoomsWithVoicePreview() {
val member = member(1L)
Mockito.`when`(dmRoomRepository.findDmChatListRooms(1L, null, null, null, PageRequest.of(0, 31))).thenReturn(
listOf(
ChatRoomListQueryDto(
roomId = 20L,
chatType = "DM",
targetName = "creator",
targetImagePath = "profile/creator.png",
lastMessage = null,
messageType = UserCreatorChatMessageType.VOICE.name,
lastMessageAt = LocalDateTime.of(2026, 5, 14, 12, 0)
)
)
)
val response = service.getRooms(member, filter = "DM", cursor = null, limit = 30)
assertEquals(1, response.rooms.size)
assertEquals("DM", response.rooms[0].chatType)
assertEquals("[음성 메시지]", response.rooms[0].lastMessage)
assertEquals("https://cdn.test/profile/creator.png", response.rooms[0].targetImageUrl)
Mockito.verifyNoInteractions(aiRoomRepository)
}
@Test
@DisplayName("31번째 항목이 있으면 hasMore와 nextCursor를 반환한다")
fun shouldReturnNextCursorWhenMoreThanLimit() {
val member = member(1L)
val rows = (1L..31L).map { index ->
ChatRoomListQueryDto(
roomId = index,
chatType = "AI",
targetName = "AI $index",
targetImagePath = null,
lastMessage = "message $index",
messageType = ChatMessageType.TEXT.name,
lastMessageAt = LocalDateTime.of(2026, 5, 14, 12, 0).minusMinutes(index)
)
}
Mockito.`when`(aiRoomRepository.findAiChatListRooms(1L, null, null, null, PageRequest.of(0, 31))).thenReturn(rows)
val response = service.getRooms(member, filter = "AI", cursor = null, limit = 30)
assertEquals(30, response.rooms.size)
assertTrue(response.hasMore)
assertEquals("AI", response.rooms.last().chatType)
assertEquals("30", response.nextCursor?.substringAfterLast(":"))
}
@Test
@DisplayName("커서는 동일 시간의 다음 정렬 항목을 누락하지 않는다")
fun shouldKeepRoomsWithSameTimestampAfterCursor() {
val member = member(1L)
val cursorAt = LocalDateTime.of(2026, 5, 14, 12, 0)
Mockito.`when`(aiRoomRepository.findAiChatListRooms(1L, cursorAt, "DM", 20L, PageRequest.of(0, 31))).thenReturn(
listOf(
ChatRoomListQueryDto(
roomId = 30L,
chatType = "AI",
targetName = "AI same time",
targetImagePath = "",
lastMessage = "same time",
messageType = ChatMessageType.TEXT.name,
lastMessageAt = cursorAt
)
)
)
Mockito.`when`(dmRoomRepository.findDmChatListRooms(1L, cursorAt, "DM", 20L, PageRequest.of(0, 31))).thenReturn(
listOf(
ChatRoomListQueryDto(
roomId = 20L,
chatType = "DM",
targetName = "cursor row",
targetImagePath = "profile/cursor.png",
lastMessage = "cursor",
messageType = UserCreatorChatMessageType.TEXT.name,
lastMessageAt = cursorAt
),
ChatRoomListQueryDto(
roomId = 10L,
chatType = "DM",
targetName = "older",
targetImagePath = "profile/older.png",
lastMessage = "older",
messageType = UserCreatorChatMessageType.TEXT.name,
lastMessageAt = cursorAt.minusMinutes(1)
)
)
)
val response = service.getRooms(member, filter = "ALL", cursor = "2026-05-14T12:00:00:DM:20", limit = 30)
assertEquals(listOf(30L, 10L), response.rooms.map { it.roomId })
assertEquals("https://cdn.test/profile/default-profile.png", response.rooms[0].targetImageUrl)
}
private fun member(id: Long) = Member(password = "pw", nickname = "user").apply { this.id = id }
}