diff --git a/docs/plan-task/20260513_유저크리에이터채팅방개편.md b/docs/plan-task/20260513_유저크리에이터채팅방개편.md index 74006ec4..c9e5239a 100644 --- a/docs/plan-task/20260513_유저크리에이터채팅방개편.md +++ b/docs/plan-task/20260513_유저크리에이터채팅방개편.md @@ -12,6 +12,12 @@ - 앱 백그라운드 전환, 채팅방 화면 이탈, 연결 종료, 타임아웃 시 실시간 수신 상태를 해제한다. - typing indicator는 요구사항에서 제거한다. - 상대방이 방에 입장 중이라고 판단된 상태에서 실시간 전송이 실패해도 보완 푸시는 발송하지 않는다. +- 채팅 리스트 API는 전체, AI 채팅, DM 채팅 필터를 하나의 통합 API에서 제공한다. +- DM은 유저-크리에이터 채팅방을 의미하는 클라이언트/문서 표기명으로 사용한다. +- 채팅 리스트에는 내가 참여 중인 방만 노출하고, 최종 대화 시간은 UTC 기준으로 내려 클라이언트가 표시 방식을 결정한다. +- 채팅 리스트 API는 `/api/v2/chat/rooms`를 사용하고 최신순 30개씩 cursor 기반으로 페이징한다. +- 마지막 메시지가 없는 방은 채팅 리스트에 노출하지 않고, 음성 메시지의 마지막 대화 요약은 `[음성 메시지]`를 사용한다. +- 상대방 회원 또는 AI 캐릭터의 프로필 이미지가 없으면 기본 이미지를 내려준다. ## 접근안 비교 @@ -30,6 +36,22 @@ - 단점: 신규 엔티티, API, 실시간 연결 관리 구현이 필요하다. - 결론: 이번 개편의 권장안으로 채택한다. +- [x] Option D: AI/DM 채팅 리스트 API를 각각 작성 검토 + - 장점: 각 도메인의 조회 조건은 단순하게 유지할 수 있다. + - 단점: 클라이언트가 전체 리스트를 만들기 위해 두 API를 호출하고 병합/정렬해야 한다. + - 결론: 전체 필터 요구사항과 맞지 않아 채택하지 않는다. + +- [x] Option E: 통합 채팅 리스트 API에서 필터로 구분 검토 + - 장점: 클라이언트는 하나의 API로 전체, AI, DM 탭을 처리할 수 있다. + - 장점: 참여 중인 방만 노출, 최신 대화순 정렬, 마지막 메시지 요약 정책을 서버에서 일관되게 적용할 수 있다. + - 단점: 서버에서 AI 채팅방과 DM 채팅방의 응답 모델을 하나로 맞추는 조립 계층이 필요하다. + - 결론: 이번 채팅 리스트 API의 채택안이다. + +- [x] Option F: 기존 AI 채팅 리스트 API에 DM을 추가 검토 + - 장점: 새 endpoint 수를 줄일 수 있다. + - 단점: 기존 AI 채팅 API의 의미가 넓어지고, DM 도메인 결합이 생긴다. + - 결론: 공개 API 의미가 불명확해지므로 채택하지 않는다. + ## 구현 계획 항목 - [x] 신규 도메인 패키지와 엔티티 설계 @@ -88,6 +110,36 @@ - 기존 AI `chat/room` API 동작을 변경하지 않는다. - 신규 도메인에서 필요한 공통 로직만 재사용하고, 외부 AI 세션 또는 쿼터 정책을 끌어오지 않는다. +- [x] 채팅 리스트 API 설계 + - API는 `GET /api/v2/chat/rooms`를 사용한다. + - query parameter는 `filter`와 `limit`, `cursor`를 둔다. + - `filter` 값은 `ALL`, `AI`, `DM` 중 하나이며 기본값은 `ALL`이다. + - 인증된 회원이 참여 중인 AI 채팅방과 DM 채팅방만 조회한다. + - 기본 정렬은 최종 대화 시간 내림차순이다. + - 최신순 30개씩 조회한다. + - 페이징은 기존 채팅 메시지 조회 관례와 맞춰 cursor 기반으로 설계한다. + - 마지막 메시지가 없는 방은 리스트에서 제외한다. + +- [x] 채팅 리스트 응답 DTO 설계 + - 페이지 응답 필드는 `rooms`, `hasMore`, `nextCursor`를 사용한다. + - 각 방 응답 필드는 `roomId`, `chatType`, `targetName`, `targetImageUrl`, `lastMessage`, `lastMessageAt`을 사용한다. + - `chatType`은 `AI` 또는 `DM`이다. + - `roomId`는 해당 타입의 방 입장 API에 전달할 식별자이다. + - `targetName`은 AI 채팅이면 캐릭터명, DM이면 나를 제외한 참여 회원 닉네임이다. + - `targetImageUrl`은 AI 채팅이면 캐릭터 대표 이미지, DM이면 나를 제외한 참여 회원 프로필 이미지이다. + - 상대방 회원 또는 AI 캐릭터의 프로필 이미지가 없으면 `targetImageUrl`에는 기본 이미지 URL을 내려준다. + - `lastMessage`는 서버에서 15글자까지 자르고, 원문이 15글자를 초과하면 말줄임표를 붙인다. + - 음성 메시지의 `lastMessage` 문구는 `[음성 메시지]`로 둔다. + - `lastMessageAt`은 UTC 기준 ISO-8601 문자열을 사용한다. + +- [x] 채팅 리스트 조회 정책 설계 + - DM 방은 `UserCreatorChatParticipant` 기준으로 현재 회원이 활성 참여자인 방만 조회한다. + - AI 방은 기존 AI 채팅 참여자 모델 기준으로 현재 회원이 참여자인 방만 조회한다. + - 비활성 메시지는 마지막 메시지 산정에서 제외한다. + - 상대방 정보가 비활성 또는 삭제 상태일 때의 닉네임 표시 정책은 기존 회원/캐릭터 응답 관례를 따른다. + - 상대방 회원 또는 AI 캐릭터 프로필 이미지가 없으면 기본 이미지를 사용한다. + - 통합 조회 시 도메인별 최신 메시지 후보를 조회한 뒤 공통 DTO로 변환하고, `lastMessageAt` 기준으로 병합 정렬한다. + ## 참고 파일 - `src/main/kotlin/kr/co/vividnext/sodalive/message/Message.kt` @@ -153,6 +205,13 @@ CREATE TABLE user_creator_chat_message ( - SSE 연결이 끊기면 3초부터 재연결하고, 연속 실패 시 3초, 6초, 12초, 24초, 최대 30초까지 지수 백오프한다. 연동할 API: +0. 채팅방 리스트 조회 + - `GET /api/v2/chat/rooms?filter=ALL&limit=30` + - `filter`: `ALL`, `AI`, `DM` + - 최신순 30개씩 cursor 기반으로 조회한다. + - response data: `{ "rooms", "hasMore", "nextCursor" }` + - room item: `{ "roomId", "chatType", "targetName", "targetImageUrl", "lastMessage", "lastMessageAt" }` + 1. 방 생성/조회 - `POST /api/v2/user-creator-chat/rooms/create` - body: `{ "creatorId": number }` @@ -200,6 +259,33 @@ CREATE TABLE user_creator_chat_message ( - `senderProfileImageUrl`: string ``` +## 채팅 리스트 API 응답 예시 + +```json +{ + "rooms": [ + { + "roomId": 123, + "chatType": "DM", + "targetName": "creator_nick", + "targetImageUrl": "https://cdn.example.com/profile/creator.png", + "lastMessage": "안녕하세요. 문의드...", + "lastMessageAt": "2026-05-14T03:12:30Z" + }, + { + "roomId": 456, + "chatType": "AI", + "targetName": "AI 캐릭터", + "targetImageUrl": "https://cdn.example.com/default/profile.png", + "lastMessage": "[음성 메시지]", + "lastMessageAt": "2026-05-14T02:40:10Z" + } + ], + "hasMore": true, + "nextCursor": "2026-05-14T02:40:10Z:456:AI" +} +``` + ## 검증 기록 ### 1차 문서 작성 @@ -246,3 +332,23 @@ CREATE TABLE user_creator_chat_message ( - 무엇을: 실제 채팅방 탈퇴로 오해될 수 있는 `enter`, `leave` 표현을 제거하고, 방 화면 열기는 `open`, 실시간 수신 해제는 `events/disconnect`로 변경했다. - 왜: 현재 기능은 DB 참여자 삭제/비활성화가 아니라 최신 메시지 조회와 SSE/presence 해제이므로, 일반적인 채팅방 입장/탈퇴 의미와 혼동되지 않게 하기 위해서다. - 어떻게: 테스트에서 `disconnectRealtime` 메서드가 필요하도록 먼저 변경해 컴파일 실패를 확인한 뒤, 컨트롤러 URL과 서비스/DTO 함수명을 수정했다. 클라이언트 연동 문서도 새 API 의미와 URL로 갱신했다. + +### 10차 채팅 리스트 API 문서화 +- 무엇을: 전체, AI 채팅, DM 채팅 필터를 지원하는 통합 채팅 리스트 API 요구사항과 응답 DTO 초안을 문서에 추가했다. +- 왜: 클라이언트가 내가 참여 중인 채팅방만 최신 대화순으로 표시하고, 방 입장에 필요한 `roomId`와 상대방 정보, 마지막 대화 요약, UTC 기준 최종 대화 시간을 받아야 하기 때문이다. +- 어떻게: 기존 유저-크리에이터 채팅방 개편 문서를 후속 요구사항으로 재사용하고, 통합 API 접근안을 채택했다. 응답 필드는 `roomId`, `chatType`, `targetName`, `targetImageUrl`, `lastMessage`, `lastMessageAt`으로 정리했으며, 마지막 대화는 서버에서 15글자 초과 시 말줄임표를 붙이고 최종 대화 시간은 UTC ISO-8601 문자열로 내려주도록 기록했다. + +### 11차 채팅 리스트 API 정책 확정 +- 무엇을: 채팅 리스트 API 접근안 Option B 채택, `/api/v2/chat/rooms` URL, 30개 단위 최신순 페이징, 마지막 메시지 없는 방 제외, 음성 메시지 요약 문구, 기본 이미지 정책, 짧은 DTO 필드명을 문서에 반영했다. +- 왜: 사용자 결정사항을 구현 전 계약으로 고정하고, 상대방 표시 정보의 응답 필드명을 더 짧게 만들기 위해서다. +- 어떻게: 응답 필드명을 `targetName`, `targetImageUrl`로 변경하고, 음성 메시지 요약은 `[음성 메시지]`, 이미지가 없는 경우 기본 이미지 URL 사용, 기본 조회 개수는 30개로 갱신했다. + +### 12차 채팅 리스트 API 구현 +- 무엇을: `/api/v2/chat/rooms` 통합 채팅 리스트 API, 응답 DTO, 서비스, AI/DM 조회 쿼리, 단위 테스트를 추가했다. +- 왜: 문서에서 확정한 전체/AI/DM 필터, 내가 참여 중인 방만 조회, 최신순 30개 cursor 페이징, 마지막 메시지 요약, 기본 이미지, UTC ISO-8601 시간 응답을 구현하기 위해서다. +- 어떻게: 실패 테스트를 먼저 작성해 신규 서비스/DTO/쿼리 부재로 컴파일 실패를 확인한 뒤 최소 구현을 추가했다. 이후 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.chat.ChatRoomListServiceTest' --rerun-tasks`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest'`, `./gradlew ktlintCheck`, `./gradlew build` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. Kotlin LSP는 이 환경에 서버가 없어 diagnostics를 수행할 수 없었다. + +### 13차 코드 리뷰 반영 +- 무엇을: 채팅 리스트 cursor 조건, UTC ISO 시간 변환, 빈 이미지 경로 기본 이미지 처리를 보완했다. +- 왜: 같은 `lastMessageAt`을 가진 방이 여러 개일 때 cursor 이후 항목이 누락되지 않아야 하고, 문서 기준 UTC 시간과 기본 이미지 정책을 정확히 지켜야 하기 때문이다. +- 어떻게: cursor를 `lastMessageAt`, `chatType`, `roomId` tuple 기준으로 적용하고 repository seek 조건도 같은 정렬 기준에 맞췄다. `lastMessageAt`은 `ZoneOffset.UTC` 기준 ISO 문자열로 변환하고, 빈 이미지 경로도 기본 이미지로 대체했다. 동일 timestamp cursor 테스트를 추가한 뒤 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.chat.ChatRoomListServiceTest' --rerun-tasks`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest'`, `./gradlew ktlintCheck`, `./gradlew build` 실행 결과 `BUILD SUCCESSFUL`을 확인했고, 재리뷰에서 남은 Critical/Important 결함 없음 승인을 받았다. diff --git a/docs/prd/20260513_유저크리에이터채팅방개편_prd.md b/docs/prd/20260513_유저크리에이터채팅방개편_prd.md index 1ad0d37e..e1be5e5f 100644 --- a/docs/prd/20260513_유저크리에이터채팅방개편_prd.md +++ b/docs/prd/20260513_유저크리에이터채팅방개편_prd.md @@ -20,6 +20,9 @@ - 메시지 발송 시 상대방이 해당 방에 입장해 있으면 푸시를 보내지 않고 실시간으로 메시지를 표시한다. - 메시지 발송 시 상대방이 해당 방에 입장해 있지 않으면 푸시 알림을 발송한다. - 기존 AI 채팅 도메인의 엔티티라도 재활용할 수 있는지 코드 근거에 따라 결정한다. +- 사용자가 참여 중인 AI 채팅방과 DM 채팅방을 하나의 채팅 리스트 API에서 조회할 수 있게 한다. +- 채팅 리스트는 전체, AI 채팅, DM 채팅 필터를 지원한다. +- 채팅 리스트 응답은 방 입장에 필요한 `roomId`, 상대방 표시 정보, 마지막 메시지 요약, 최종 대화 시간을 제공한다. --- @@ -30,6 +33,7 @@ - 텍스트/음성 외 이미지, 파일, 이모티콘, 읽음 확인, 메시지 수정 기능은 이번 범위에 포함하지 않는다. - typing indicator는 이번 범위에 포함하지 않는다. - 관리자 화면 개편은 이번 범위에 포함하지 않는다. +- 채팅 리스트 API에서 메시지 본문 전체, 읽지 않은 메시지 수, 고정/숨김/삭제 상태, 검색 기능은 이번 범위에 포함하지 않는다. --- @@ -46,6 +50,9 @@ - 크리에이터는 방에 들어와 있지 않을 때 새 메시지가 오면 푸시로 알림을 받고 싶다. - 크리에이터는 방에 들어와 있을 때 새 메시지가 오면 푸시 없이 화면에 바로 표시되기를 원한다. - 양쪽 사용자는 상대방이 방에 들어와 있을 때 새 메시지를 즉시 확인하고 싶다. +- 사용자는 내가 참여 중인 모든 채팅방을 최신 대화순으로 보고 싶다. +- 사용자는 AI 채팅방만 또는 DM 채팅방만 필터링해서 보고 싶다. +- 사용자는 리스트에서 상대방 닉네임, 프로필 이미지, 마지막 대화 요약, 최종 대화 시간을 확인한 뒤 방에 입장하고 싶다. --- @@ -111,6 +118,26 @@ - 한 사용자가 여러 기기에서 같은 방에 입장할 수 있다면, 하나 이상의 활성 연결이 있을 때 입장 중으로 판단한다. - 앱 백그라운드 전환 또는 화면 이탈 이벤트가 서버에 도달하지 못해도 SSE 연결 종료와 Redis TTL 만료로 방 입장 상태가 해제되어야 한다. +### 채팅 리스트 API + +#### Requirements +- 인증된 회원이 참여 중인 채팅방만 조회한다. +- 필터는 `ALL`, `AI`, `DM` 3가지를 지원한다. +- `AI`는 기존 AI 캐릭터 채팅방을 의미한다. +- `DM`은 유저-크리에이터 채팅방을 의미하며, API 문서와 클라이언트 표시 용어에서 User-Creator 채팅 대신 DM으로 명명한다. +- 기본 정렬은 최종 대화 시간 내림차순이다. +- 채팅 리스트는 최신순 30개씩 조회하고 cursor 기반으로 다음 페이지를 조회한다. +- 응답 항목은 방 입장을 위한 `roomId`, `chatType`, 상대방 닉네임, 상대방 프로필 이미지, 마지막 대화 요약, 최종 대화 시간을 포함한다. +- 마지막 대화 요약은 서버에서 15글자까지 내려주고, 15글자를 초과하면 말줄임표를 붙인다. +- 최종 대화 시간은 UTC 기준 값을 내려주고, 클라이언트가 표시 방식과 로컬 타임존 변환을 처리한다. +- 마지막 메시지가 없는 방은 채팅 리스트에 노출하지 않는다. + +#### Edge Cases +- 상대방 회원 또는 AI 캐릭터의 프로필 이미지가 없으면 기본 이미지를 사용한다. +- 마지막 메시지가 음성 메시지이면 본문 요약 대신 `[음성 메시지]`를 내려준다. +- 마지막 메시지가 비활성화되었거나 표시할 수 없는 상태라면 해당 메시지를 제외하고 다음 최신 표시 가능 메시지를 기준으로 요약한다. +- DM 채팅방에서 현재 회원이 유저인지 크리에이터인지와 관계없이 상대방은 나를 제외한 참여자로 계산한다. + --- ## 8. Technical Constraints @@ -147,6 +174,24 @@ - 단점: 신규 엔티티, 저장소, 실시간 연결 관리, API가 필요해 초기 구현량이 늘어난다. - 판단: 이번 요구사항의 권장안이다. +### 채팅 리스트 API 접근안 + +#### Option A: AI/DM 리스트 API를 각각 작성 +- 장점: 각 도메인의 조회 조건과 응답 조립을 단순하게 유지할 수 있다. +- 단점: 클라이언트가 전체 리스트를 만들기 위해 두 API를 호출하고 병합/정렬해야 한다. +- 판단: 전체 필터 요구사항과 맞지 않아 권장하지 않는다. + +#### Option B: 통합 리스트 API에서 필터로 구분 +- 장점: 클라이언트는 하나의 API로 전체, AI, DM 탭을 처리할 수 있다. +- 장점: 참여 중인 방만 노출, 최신 대화순 정렬, 마지막 메시지 요약 정책을 서버에서 일관되게 적용할 수 있다. +- 단점: 서버에서 AI 채팅방과 DM 채팅방의 응답 모델을 하나로 맞추는 조립 계층이 필요하다. +- 판단: 이번 채팅 리스트 API의 채택안이다. + +#### Option C: 기존 AI 채팅 리스트 API에 DM을 추가 +- 장점: 새 endpoint 수를 줄일 수 있다. +- 단점: 기존 AI 채팅 API의 의미가 넓어지고, DM 도메인 결합이 생긴다. +- 판단: 공개 API 의미가 불명확해지므로 권장하지 않는다. + --- ## 10. Metrics @@ -158,5 +203,12 @@ --- -## 11. Open Questions -- 없음. URL prefix와 DTO 필드명은 구현 직전 추천안을 제시해 확정하고, SSE 인증과 재연결 간격은 본 문서의 Technical Constraints 기준을 따른다. +## 11. Confirmed Defaults +- 채팅 리스트에서 마지막 메시지가 없는 방은 노출하지 않는다. +- 음성 메시지의 마지막 대화 요약 문구는 `[음성 메시지]`를 사용한다. +- 채팅 리스트 API URL prefix는 `/api/v2/chat/rooms`를 사용한다. +- 채팅 리스트 DTO 필드명은 `roomId`, `chatType`, `targetName`, `targetImageUrl`, `lastMessage`, `lastMessageAt`을 사용한다. +- `targetName`은 AI 채팅이면 캐릭터명, DM이면 나를 제외한 참여 회원 닉네임이다. +- `targetImageUrl`은 AI 채팅이면 캐릭터 대표 이미지, DM이면 나를 제외한 참여 회원 프로필 이미지이며, 이미지가 없으면 기본 이미지 URL을 내려준다. +- 채팅 리스트는 최신순 30개씩 cursor 기반으로 페이징한다. +- SSE 인증과 재연결 간격은 본 문서의 Technical Constraints 기준을 따른다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt index 07d4a690..70ae1858 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt @@ -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 { @@ -64,5 +66,52 @@ interface ChatRoomRepository : JpaRepository { pageable: Pageable ): List + @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 + fun findByIdAndIsActiveTrue(id: Long): ChatRoom? } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/controller/ChatRoomListController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/controller/ChatRoomListController.kt new file mode 100644 index 00000000..cabb5a45 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/controller/ChatRoomListController.kt @@ -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)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/dto/ChatRoomListDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/dto/ChatRoomListDtos.kt new file mode 100644 index 00000000..cff85658 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/dto/ChatRoomListDtos.kt @@ -0,0 +1,28 @@ +package kr.co.vividnext.sodalive.v2.chat.dto + +import java.time.LocalDateTime + +data class ChatRoomListPageResponse( + val rooms: List, + 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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/service/ChatRoomListService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/service/ChatRoomListService.kt new file mode 100644 index 00000000..20bd7986 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/service/ChatRoomListService.kt @@ -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 { 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 = "[음성 메시지]" + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt index 5870bc72..ceb1ceea 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt @@ -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 { @@ -32,4 +35,51 @@ interface UserCreatorChatRoomRepository : JpaRepository :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 } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListServiceTest.kt new file mode 100644 index 00000000..1273a4f0 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListServiceTest.kt @@ -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 } +}