From 4d1f84cc5c644771bafb23b2c3d03dfddf8ab011 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 8 Aug 2025 14:27:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat-room):=20=EC=B1=84=ED=8C=85=EB=B0=A9?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20API=20=EC=9D=91=EB=8B=B5=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EA=B0=9C=ED=8E=B8=20=EB=B0=8F=20=EC=B5=9C=EA=B7=BC?= =?UTF-8?q?=20=EB=A9=94=EC=8B=9C=EC=A7=80/=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=A0=9C=EA=B3=B5\n\n-=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=95=20=EA=B0=9D=EC=B2=B4=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0:=20ApiResponse>=20=ED=98=95?= =?UTF-8?q?=ED=83=9C=EB=A1=9C=20=EB=B0=98=ED=99=98\n-=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EB=B3=B4=EB=82=B8=20=EC=8B=9C=EA=B0=84=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0\n-=20=EC=83=81=EB=8C=80=EB=B0=A9?= =?UTF-8?q?(=EC=BA=90=EB=A6=AD=ED=84=B0)=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20URL=20=EC=A0=9C=EA=B3=B5=20(image?= =?UTF-8?q?Host/imagePath=20=EC=A1=B0=ED=95=A9=20->=20imageUrl)\n-=20?= =?UTF-8?q?=EA=B0=80=EC=9E=A5=20=EC=B5=9C=EA=B7=BC=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=201=EA=B0=9C=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0?= =?UTF-8?q?=20=EC=A0=9C=EA=B3=B5=20(=EC=B5=9C=EB=8C=80=2025=EC=9E=90,=20?= =?UTF-8?q?=EC=B4=88=EA=B3=BC=20=EC=8B=9C=20...=20=EC=B2=98=EB=A6=AC)\n-?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=BF=BC=EB=A6=AC?= =?UTF-8?q?=20=ED=88=AC=EC=98=81=20DTO=20=EB=B0=8F=20=EC=A0=95=EB=A0=AC=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20(=EC=B5=9C=EA=B7=BC?= =?UTF-8?q?=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=97=86=EC=9C=BC=EB=A9=B4=20?= =?UTF-8?q?=EB=B0=A9=20=EC=83=9D=EC=84=B1=20=EC=8B=9C=EA=B0=84=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9)\n-=20=EB=B9=84=EC=9D=B8=EC=A6=9D/=EB=AF=B8=EB=B3=B8?= =?UTF-8?q?=EC=9D=B8=EC=9D=B8=EC=A6=9D=20=EC=82=AC=EC=9A=A9=EC=9E=90:=20?= =?UTF-8?q?=EB=B9=88=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../room/controller/ChatRoomController.kt | 18 ++++++++++ .../sodalive/chat/room/dto/ChatRoomDto.kt | 19 +++++++++++ .../CharacterChatMessageRepository.kt | 11 +++++++ .../repository/CharacterChatRoomRepository.kt | 33 ++++++++++++++++--- .../chat/room/service/ChatRoomService.kt | 33 ++++++++++++++++++- 5 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatMessageRepository.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt index e595c83..20c6892 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt @@ -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) + } + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt index 2b53285..030c52a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt @@ -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 */ diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatMessageRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatMessageRepository.kt new file mode 100644 index 0000000..a950cac --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatMessageRepository.kt @@ -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 { + fun findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(chatRoom: CharacterChatRoom): CharacterChatMessage? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatRoomRepository.kt index 469fc46..84d56a7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatRoomRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatRoomRepository.kt @@ -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 { /** * 특정 멤버와 캐릭터가 참여 중인 활성화된 채팅방을 찾는 쿼리 - * - * @param member 멤버 - * @param character 캐릭터 - * @return 활성화된 채팅방 (없으면 null) */ @Query( """ @@ -32,4 +29,32 @@ interface CharacterChatRoomRepository : JpaRepository { @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 } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 4a8f47f..7983ebb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -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 { + val rooms: List = 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 + ) + } + } }