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.common.SodaException
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import org.springframework.security.core.annotation.AuthenticationPrincipal 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.PostMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
@ -42,4 +43,21 @@ class ChatRoomController(
val response = chatRoomService.createOrGetChatRoom(member, request.characterId) val response = chatRoomService.createOrGetChatRoom(member, request.characterId)
ApiResponse.ok(response) 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 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 * 외부 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.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom 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 kr.co.vividnext.sodalive.member.Member
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.Query
@ -13,10 +14,6 @@ interface CharacterChatRoomRepository : JpaRepository<CharacterChatRoom, Long> {
/** /**
* 특정 멤버와 캐릭터가 참여 중인 활성화된 채팅방을 찾는 쿼리 * 특정 멤버와 캐릭터가 참여 중인 활성화된 채팅방을 찾는 쿼리
*
* @param member 멤버
* @param character 캐릭터
* @return 활성화된 채팅방 (없으면 null)
*/ */
@Query( @Query(
""" """
@ -32,4 +29,32 @@ interface CharacterChatRoomRepository : JpaRepository<CharacterChatRoom, Long> {
@Param("member") member: Member, @Param("member") member: Member,
@Param("character") character: ChatCharacter @Param("character") character: ChatCharacter
): CharacterChatRoom? ): 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.CharacterChatParticipant
import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom
import kr.co.vividnext.sodalive.chat.room.ParticipantType 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.CreateChatRoomResponse
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionResponse 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.CharacterChatParticipantRepository
import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatRoomRepository import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatRoomRepository
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
@ -26,6 +29,7 @@ import java.util.UUID
class ChatRoomService( class ChatRoomService(
private val chatRoomRepository: CharacterChatRoomRepository, private val chatRoomRepository: CharacterChatRoomRepository,
private val participantRepository: CharacterChatParticipantRepository, private val participantRepository: CharacterChatParticipantRepository,
private val messageRepository: CharacterChatMessageRepository,
private val characterService: ChatCharacterService, private val characterService: ChatCharacterService,
@Value("\${weraser.api-key}") @Value("\${weraser.api-key}")
@ -35,7 +39,10 @@ class ChatRoomService(
private val apiUrl: String, private val apiUrl: String,
@Value("\${server.env}") @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}, 채팅방 생성에 실패했습니다. 다시 시도해 주세요.") 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
)
}
}
} }