feat(chat-room): 채팅방 목록 API 응답 구조 개편 및 최근 메시지/프로필 이미지 제공\n\n- 페이징 객체 제거: ApiResponse<List<ChatRoomListItemDto>> 형태로 반환\n- 메시지 보낸 시간 필드 제거\n- 상대방(캐릭터) 프로필 이미지 URL 제공 (imageHost/imagePath 조합 -> imageUrl)\n- 가장 최근 메시지 1개 미리보기 제공 (최대 25자, 초과 시 ... 처리)\n- 목록 조회 쿼리 투영 DTO 및 정렬 로직 개선 (최근 메시지 없으면 방 생성 시간 사용)\n- 비인증/미본인인증 사용자: 빈 리스트 반환

This commit is contained in:
Klaus 2025-08-08 14:27:25 +09:00
parent 1bafbed17c
commit 4d1f84cc5c
5 changed files with 109 additions and 5 deletions

View File

@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
@ -42,4 +43,21 @@ class ChatRoomController(
val response = chatRoomService.createOrGetChatRoom(member, request.characterId)
ApiResponse.ok(response)
}
/**
* 내가 참여 중인 채팅방 목록 조회 API
* - 페이징(기본 20)
* - 가장 최근 메시지 기준 내림차순
*/
@GetMapping("/list")
fun listMyChatRooms(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null || member.auth == null) {
ApiResponse.ok(emptyList())
} else {
val response = chatRoomService.listMyChatRooms(member)
ApiResponse.ok(response)
}
}
}

View File

@ -14,6 +14,25 @@ data class CreateChatRoomResponse(
val chatRoomId: Long
)
/**
* 채팅방 목록 아이템 DTO (API 응답용)
*/
data class ChatRoomListItemDto(
val chatRoomId: Long,
val title: String,
val imageUrl: String,
val lastMessagePreview: String?
)
/**
* 채팅방 목록 쿼리 DTO (레포지토리 투영용)
*/
data class ChatRoomListQueryDto(
val chatRoomId: Long,
val title: String,
val imagePath: String?
)
/**
* 외부 API 채팅 세션 응답 DTO
*/

View File

@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.chat.room.repository
import kr.co.vividnext.sodalive.chat.room.CharacterChatMessage
import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface CharacterChatMessageRepository : JpaRepository<CharacterChatMessage, Long> {
fun findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(chatRoom: CharacterChatRoom): CharacterChatMessage?
}

View File

@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.chat.room.repository
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom
import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto
import kr.co.vividnext.sodalive.member.Member
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
@ -13,10 +14,6 @@ interface CharacterChatRoomRepository : JpaRepository<CharacterChatRoom, Long> {
/**
* 특정 멤버와 캐릭터가 참여 중인 활성화된 채팅방을 찾는 쿼리
*
* @param member 멤버
* @param character 캐릭터
* @return 활성화된 채팅방 (없으면 null)
*/
@Query(
"""
@ -32,4 +29,32 @@ interface CharacterChatRoomRepository : JpaRepository<CharacterChatRoom, Long> {
@Param("member") member: Member,
@Param("character") character: ChatCharacter
): CharacterChatRoom?
/**
* 멤버가 참여 중인 채팅방을 최근 메시지 시간 순으로 페이징 조회
* - 메시지가 없으면 생성 시간(createdAt)으로 대체
*/
@Query(
value = """
SELECT new kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto(
r.id,
r.title,
pc.character.imagePath
)
FROM CharacterChatRoom r
JOIN r.participants p
JOIN r.participants pc
LEFT JOIN r.messages m
WHERE p.member = :member
AND p.isActive = true
AND pc.participantType = kr.co.vividnext.sodalive.chat.room.ParticipantType.CHARACTER
AND pc.isActive = true
AND r.isActive = true
GROUP BY r.id, r.title, r.createdAt, pc.character.imagePath
ORDER BY COALESCE(MAX(m.createdAt), r.createdAt) DESC
"""
)
fun findMemberRoomsOrderByLastMessageDesc(
@Param("member") member: Member
): List<ChatRoomListQueryDto>
}

View File

@ -5,8 +5,11 @@ import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.chat.room.CharacterChatParticipant
import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom
import kr.co.vividnext.sodalive.chat.room.ParticipantType
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.CreateChatRoomResponse
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionResponse
import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatMessageRepository
import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatParticipantRepository
import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatRoomRepository
import kr.co.vividnext.sodalive.common.SodaException
@ -26,6 +29,7 @@ import java.util.UUID
class ChatRoomService(
private val chatRoomRepository: CharacterChatRoomRepository,
private val participantRepository: CharacterChatParticipantRepository,
private val messageRepository: CharacterChatMessageRepository,
private val characterService: ChatCharacterService,
@Value("\${weraser.api-key}")
@ -35,7 +39,10 @@ class ChatRoomService(
private val apiUrl: String,
@Value("\${server.env}")
private val serverEnv: String
private val serverEnv: String,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
/**
@ -164,4 +171,28 @@ class ChatRoomService(
throw SodaException("${e.message}, 채팅방 생성에 실패했습니다. 다시 시도해 주세요.")
}
}
@Transactional(readOnly = true)
fun listMyChatRooms(member: Member): List<ChatRoomListItemDto> {
val rooms: List<ChatRoomListQueryDto> = chatRoomRepository.findMemberRoomsOrderByLastMessageDesc(member)
return rooms.map { q ->
val room = CharacterChatRoom(
sessionId = "",
title = q.title,
isActive = true
).apply { id = q.chatRoomId }
val latest = messageRepository.findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(room)
val preview = latest?.message?.let { msg ->
if (msg.length <= 25) msg else msg.substring(0, 25) + "..."
}
ChatRoomListItemDto(
chatRoomId = q.chatRoomId,
title = q.title,
imageUrl = "$imageHost/${q.imagePath ?: "profile/default-profile.png"}",
lastMessagePreview = preview
)
}
}
}