feat(chat): 채팅방 리스트 조회 API를 추가한다
This commit is contained in:
@@ -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?
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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 = "[음성 메시지]"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user