feat(chat): 채팅방 리스트 조회 API를 추가한다
This commit is contained in:
@@ -12,6 +12,12 @@
|
|||||||
- 앱 백그라운드 전환, 채팅방 화면 이탈, 연결 종료, 타임아웃 시 실시간 수신 상태를 해제한다.
|
- 앱 백그라운드 전환, 채팅방 화면 이탈, 연결 종료, 타임아웃 시 실시간 수신 상태를 해제한다.
|
||||||
- typing indicator는 요구사항에서 제거한다.
|
- typing indicator는 요구사항에서 제거한다.
|
||||||
- 상대방이 방에 입장 중이라고 판단된 상태에서 실시간 전송이 실패해도 보완 푸시는 발송하지 않는다.
|
- 상대방이 방에 입장 중이라고 판단된 상태에서 실시간 전송이 실패해도 보완 푸시는 발송하지 않는다.
|
||||||
|
- 채팅 리스트 API는 전체, AI 채팅, DM 채팅 필터를 하나의 통합 API에서 제공한다.
|
||||||
|
- DM은 유저-크리에이터 채팅방을 의미하는 클라이언트/문서 표기명으로 사용한다.
|
||||||
|
- 채팅 리스트에는 내가 참여 중인 방만 노출하고, 최종 대화 시간은 UTC 기준으로 내려 클라이언트가 표시 방식을 결정한다.
|
||||||
|
- 채팅 리스트 API는 `/api/v2/chat/rooms`를 사용하고 최신순 30개씩 cursor 기반으로 페이징한다.
|
||||||
|
- 마지막 메시지가 없는 방은 채팅 리스트에 노출하지 않고, 음성 메시지의 마지막 대화 요약은 `[음성 메시지]`를 사용한다.
|
||||||
|
- 상대방 회원 또는 AI 캐릭터의 프로필 이미지가 없으면 기본 이미지를 내려준다.
|
||||||
|
|
||||||
## 접근안 비교
|
## 접근안 비교
|
||||||
|
|
||||||
@@ -30,6 +36,22 @@
|
|||||||
- 단점: 신규 엔티티, API, 실시간 연결 관리 구현이 필요하다.
|
- 단점: 신규 엔티티, 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] 신규 도메인 패키지와 엔티티 설계
|
- [x] 신규 도메인 패키지와 엔티티 설계
|
||||||
@@ -88,6 +110,36 @@
|
|||||||
- 기존 AI `chat/room` API 동작을 변경하지 않는다.
|
- 기존 AI `chat/room` API 동작을 변경하지 않는다.
|
||||||
- 신규 도메인에서 필요한 공통 로직만 재사용하고, 외부 AI 세션 또는 쿼터 정책을 끌어오지 않는다.
|
- 신규 도메인에서 필요한 공통 로직만 재사용하고, 외부 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`
|
- `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초까지 지수 백오프한다.
|
- SSE 연결이 끊기면 3초부터 재연결하고, 연속 실패 시 3초, 6초, 12초, 24초, 최대 30초까지 지수 백오프한다.
|
||||||
|
|
||||||
연동할 API:
|
연동할 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. 방 생성/조회
|
1. 방 생성/조회
|
||||||
- `POST /api/v2/user-creator-chat/rooms/create`
|
- `POST /api/v2/user-creator-chat/rooms/create`
|
||||||
- body: `{ "creatorId": number }`
|
- body: `{ "creatorId": number }`
|
||||||
@@ -200,6 +259,33 @@ CREATE TABLE user_creator_chat_message (
|
|||||||
- `senderProfileImageUrl`: string
|
- `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차 문서 작성
|
### 1차 문서 작성
|
||||||
@@ -246,3 +332,23 @@ CREATE TABLE user_creator_chat_message (
|
|||||||
- 무엇을: 실제 채팅방 탈퇴로 오해될 수 있는 `enter`, `leave` 표현을 제거하고, 방 화면 열기는 `open`, 실시간 수신 해제는 `events/disconnect`로 변경했다.
|
- 무엇을: 실제 채팅방 탈퇴로 오해될 수 있는 `enter`, `leave` 표현을 제거하고, 방 화면 열기는 `open`, 실시간 수신 해제는 `events/disconnect`로 변경했다.
|
||||||
- 왜: 현재 기능은 DB 참여자 삭제/비활성화가 아니라 최신 메시지 조회와 SSE/presence 해제이므로, 일반적인 채팅방 입장/탈퇴 의미와 혼동되지 않게 하기 위해서다.
|
- 왜: 현재 기능은 DB 참여자 삭제/비활성화가 아니라 최신 메시지 조회와 SSE/presence 해제이므로, 일반적인 채팅방 입장/탈퇴 의미와 혼동되지 않게 하기 위해서다.
|
||||||
- 어떻게: 테스트에서 `disconnectRealtime` 메서드가 필요하도록 먼저 변경해 컴파일 실패를 확인한 뒤, 컨트롤러 URL과 서비스/DTO 함수명을 수정했다. 클라이언트 연동 문서도 새 API 의미와 URL로 갱신했다.
|
- 어떻게: 테스트에서 `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 결함 없음 승인을 받았다.
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
- 메시지 발송 시 상대방이 해당 방에 입장해 있으면 푸시를 보내지 않고 실시간으로 메시지를 표시한다.
|
- 메시지 발송 시 상대방이 해당 방에 입장해 있으면 푸시를 보내지 않고 실시간으로 메시지를 표시한다.
|
||||||
- 메시지 발송 시 상대방이 해당 방에 입장해 있지 않으면 푸시 알림을 발송한다.
|
- 메시지 발송 시 상대방이 해당 방에 입장해 있지 않으면 푸시 알림을 발송한다.
|
||||||
- 기존 AI 채팅 도메인의 엔티티라도 재활용할 수 있는지 코드 근거에 따라 결정한다.
|
- 기존 AI 채팅 도메인의 엔티티라도 재활용할 수 있는지 코드 근거에 따라 결정한다.
|
||||||
|
- 사용자가 참여 중인 AI 채팅방과 DM 채팅방을 하나의 채팅 리스트 API에서 조회할 수 있게 한다.
|
||||||
|
- 채팅 리스트는 전체, AI 채팅, DM 채팅 필터를 지원한다.
|
||||||
|
- 채팅 리스트 응답은 방 입장에 필요한 `roomId`, 상대방 표시 정보, 마지막 메시지 요약, 최종 대화 시간을 제공한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -30,6 +33,7 @@
|
|||||||
- 텍스트/음성 외 이미지, 파일, 이모티콘, 읽음 확인, 메시지 수정 기능은 이번 범위에 포함하지 않는다.
|
- 텍스트/음성 외 이미지, 파일, 이모티콘, 읽음 확인, 메시지 수정 기능은 이번 범위에 포함하지 않는다.
|
||||||
- typing indicator는 이번 범위에 포함하지 않는다.
|
- typing indicator는 이번 범위에 포함하지 않는다.
|
||||||
- 관리자 화면 개편은 이번 범위에 포함하지 않는다.
|
- 관리자 화면 개편은 이번 범위에 포함하지 않는다.
|
||||||
|
- 채팅 리스트 API에서 메시지 본문 전체, 읽지 않은 메시지 수, 고정/숨김/삭제 상태, 검색 기능은 이번 범위에 포함하지 않는다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -46,6 +50,9 @@
|
|||||||
- 크리에이터는 방에 들어와 있지 않을 때 새 메시지가 오면 푸시로 알림을 받고 싶다.
|
- 크리에이터는 방에 들어와 있지 않을 때 새 메시지가 오면 푸시로 알림을 받고 싶다.
|
||||||
- 크리에이터는 방에 들어와 있을 때 새 메시지가 오면 푸시 없이 화면에 바로 표시되기를 원한다.
|
- 크리에이터는 방에 들어와 있을 때 새 메시지가 오면 푸시 없이 화면에 바로 표시되기를 원한다.
|
||||||
- 양쪽 사용자는 상대방이 방에 들어와 있을 때 새 메시지를 즉시 확인하고 싶다.
|
- 양쪽 사용자는 상대방이 방에 들어와 있을 때 새 메시지를 즉시 확인하고 싶다.
|
||||||
|
- 사용자는 내가 참여 중인 모든 채팅방을 최신 대화순으로 보고 싶다.
|
||||||
|
- 사용자는 AI 채팅방만 또는 DM 채팅방만 필터링해서 보고 싶다.
|
||||||
|
- 사용자는 리스트에서 상대방 닉네임, 프로필 이미지, 마지막 대화 요약, 최종 대화 시간을 확인한 뒤 방에 입장하고 싶다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -111,6 +118,26 @@
|
|||||||
- 한 사용자가 여러 기기에서 같은 방에 입장할 수 있다면, 하나 이상의 활성 연결이 있을 때 입장 중으로 판단한다.
|
- 한 사용자가 여러 기기에서 같은 방에 입장할 수 있다면, 하나 이상의 활성 연결이 있을 때 입장 중으로 판단한다.
|
||||||
- 앱 백그라운드 전환 또는 화면 이탈 이벤트가 서버에 도달하지 못해도 SSE 연결 종료와 Redis TTL 만료로 방 입장 상태가 해제되어야 한다.
|
- 앱 백그라운드 전환 또는 화면 이탈 이벤트가 서버에 도달하지 못해도 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
|
## 8. Technical Constraints
|
||||||
@@ -147,6 +174,24 @@
|
|||||||
- 단점: 신규 엔티티, 저장소, 실시간 연결 관리, API가 필요해 초기 구현량이 늘어난다.
|
- 단점: 신규 엔티티, 저장소, 실시간 연결 관리, 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
|
## 10. Metrics
|
||||||
@@ -158,5 +203,12 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Open Questions
|
## 11. Confirmed Defaults
|
||||||
- 없음. URL prefix와 DTO 필드명은 구현 직전 추천안을 제시해 확정하고, SSE 인증과 재연결 간격은 본 문서의 Technical Constraints 기준을 따른다.
|
- 채팅 리스트에서 마지막 메시지가 없는 방은 노출하지 않는다.
|
||||||
|
- 음성 메시지의 마지막 대화 요약 문구는 `[음성 메시지]`를 사용한다.
|
||||||
|
- 채팅 리스트 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 기준을 따른다.
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import org.springframework.data.jpa.repository.JpaRepository
|
|||||||
import org.springframework.data.jpa.repository.Query
|
import org.springframework.data.jpa.repository.Query
|
||||||
import org.springframework.data.repository.query.Param
|
import org.springframework.data.repository.query.Param
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListQueryDto as V2ChatRoomListQueryDto
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
interface ChatRoomRepository : JpaRepository<ChatRoom, Long> {
|
interface ChatRoomRepository : JpaRepository<ChatRoom, Long> {
|
||||||
@@ -64,5 +66,52 @@ interface ChatRoomRepository : JpaRepository<ChatRoom, Long> {
|
|||||||
pageable: Pageable
|
pageable: Pageable
|
||||||
): List<ChatRoomListQueryDto>
|
): 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?
|
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
|
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 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.JpaRepository
|
||||||
import org.springframework.data.jpa.repository.Query
|
import org.springframework.data.jpa.repository.Query
|
||||||
import org.springframework.data.repository.query.Param
|
import org.springframework.data.repository.query.Param
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
interface UserCreatorChatRoomRepository : JpaRepository<UserCreatorChatRoom, Long> {
|
interface UserCreatorChatRoomRepository : JpaRepository<UserCreatorChatRoom, Long> {
|
||||||
@@ -32,4 +35,51 @@ interface UserCreatorChatRoomRepository : JpaRepository<UserCreatorChatRoom, Lon
|
|||||||
@Param("firstMemberId") firstMemberId: Long,
|
@Param("firstMemberId") firstMemberId: Long,
|
||||||
@Param("secondMemberId") secondMemberId: Long
|
@Param("secondMemberId") secondMemberId: Long
|
||||||
): UserCreatorChatRoom?
|
): 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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user