feat(user-creator-chat): 유저 크리에이터 채팅방을 추가한다
유저와 크리에이터 간 텍스트/음성 메시지, SSE presence, 조건부 푸시 흐름을 신규 도메인으로 분리한다.
This commit is contained in:
248
docs/plan-task/20260513_유저크리에이터채팅방개편.md
Normal file
248
docs/plan-task/20260513_유저크리에이터채팅방개편.md
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
# 유저-크리에이터 채팅방 개편 계획
|
||||||
|
|
||||||
|
> 이 문서는 현재 요청 범위인 문서 작성만 다룬다. 실제 구현 시에는 이 계획의 구현 항목을 기준으로 세부 API, 엔티티, 테스트를 별도 작업 단위로 쪼갠다.
|
||||||
|
|
||||||
|
## 결정 요약
|
||||||
|
|
||||||
|
- 새 기능부터 유저-크리에이터 채팅방을 제공한다.
|
||||||
|
- 기존 `message` 도메인의 과거 메시지는 신규 채팅방으로 마이그레이션하지 않고 별도 유지한다.
|
||||||
|
- 기존 AI `chat/room` 엔티티는 외부 AI 세션, 캐릭터 참여자, 이미지 메시지, 쿼터 관련 의미에 결합되어 있으므로 직접 재사용하지 않는다.
|
||||||
|
- 신규 유저-크리에이터 채팅방 도메인을 작성하되, 기존 `message`의 텍스트/음성 및 FCM 패턴과 AI `chat/room`의 방/참여자/메시지 구조는 참고한다.
|
||||||
|
- 실시간 전송 방식은 SSE(`SseEmitter`)로 결정한다.
|
||||||
|
- 앱 백그라운드 전환, 채팅방 화면 이탈, 연결 종료, 타임아웃 시 실시간 수신 상태를 해제한다.
|
||||||
|
- typing indicator는 요구사항에서 제거한다.
|
||||||
|
- 상대방이 방에 입장 중이라고 판단된 상태에서 실시간 전송이 실패해도 보완 푸시는 발송하지 않는다.
|
||||||
|
|
||||||
|
## 접근안 비교
|
||||||
|
|
||||||
|
- [x] Option A: 기존 `message` 도메인 확장 검토
|
||||||
|
- 장점: 텍스트/음성 메시지와 FCM 발행 흐름 재사용이 쉽다.
|
||||||
|
- 단점: 방, 참여자, presence, typing을 송수신함 모델에 섞어야 한다.
|
||||||
|
- 결론: 기존 메시지를 별도 유지하기로 했으므로 채택하지 않는다.
|
||||||
|
|
||||||
|
- [x] Option B: 기존 AI `chat/room` 엔티티 재활용 검토
|
||||||
|
- 장점: 방/참여자/메시지 구조와 cursor 조회 패턴을 참고할 수 있다.
|
||||||
|
- 단점: `ChatRoom.sessionId`는 AI 외부 세션 의미이고, `ChatParticipant`는 `USER/CHARACTER`와 `ChatCharacter` 참조를 전제로 하며, `ChatMessage`는 음성 메시지 필드가 없다.
|
||||||
|
- 결론: 엔티티만 재활용하더라도 사람 간 채팅 의미와 맞지 않아 채택하지 않고 구조만 참고한다.
|
||||||
|
|
||||||
|
- [x] Option C: 신규 유저-크리에이터 채팅방 도메인 작성 검토
|
||||||
|
- 장점: 신규 요구사항인 실시간 수신, 조건부 푸시, presence를 독립적으로 설계할 수 있다.
|
||||||
|
- 단점: 신규 엔티티, API, 실시간 연결 관리 구현이 필요하다.
|
||||||
|
- 결론: 이번 개편의 권장안으로 채택한다.
|
||||||
|
|
||||||
|
## 구현 계획 항목
|
||||||
|
|
||||||
|
- [x] 신규 도메인 패키지와 엔티티 설계
|
||||||
|
- 신규 패키지: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/`
|
||||||
|
- 신규 엔티티: `UserCreatorChatRoom`, `UserCreatorChatParticipant`, `UserCreatorChatMessage`
|
||||||
|
- 메시지 타입: `TEXT`, `VOICE`
|
||||||
|
- 채팅방 참여자는 `UserCreatorChatParticipant`를 기준으로 관리하고, `UserCreatorChatRoom`에는 특정 참여자 컬럼을 두지 않는다.
|
||||||
|
- AI `chat/room` 엔티티는 직접 재활용하지 않고, 방/참여자/메시지 분리 구조만 참고한다.
|
||||||
|
|
||||||
|
- [x] 채팅방 생성/열기 API 설계
|
||||||
|
- 구현 전 신규 채팅방 API의 URL prefix와 응답 DTO 필드명을 추천안으로 제시하고 확정한다.
|
||||||
|
- URL prefix는 기존 `/api/chat/room`과 충돌하지 않도록 `/api/v2/user-creator-chat/rooms`를 사용한다.
|
||||||
|
- 응답 DTO 필드명은 기존 `chat/room`의 응답 관례를 참고해 `roomId`, `messages`, `hasMore`, `nextCursor`, `messageId`, `messageType`, `mine`, `createdAt`, `textMessage`, `voiceMessageUrl`, `senderId`, `senderNickname`, `senderProfileImageUrl`을 우선 사용한다.
|
||||||
|
- 활성 방 조회 또는 생성 API를 정의한다.
|
||||||
|
- 채팅방 화면을 열 때 최신 메시지를 cursor 기반으로 조회한다.
|
||||||
|
- 기존 `chat/room`의 `getChatMessages` cursor 조회 패턴을 참고한다.
|
||||||
|
|
||||||
|
- [x] 텍스트 메시지 발송 API 설계
|
||||||
|
- 기존 `MessageService.sendTextMessage`의 수신자 활성 여부 검증, 차단 검증, FCM 이벤트 발행 흐름을 참고한다.
|
||||||
|
- 메시지 저장 후 상대방 presence를 확인해 실시간 전송 또는 푸시 발송 중 하나만 수행한다.
|
||||||
|
|
||||||
|
- [x] 음성 메시지 발송 API 설계
|
||||||
|
- 기존 `MessageService.sendVoiceMessage`의 S3 업로드 경로와 CloudFront 응답 방식을 참고한다.
|
||||||
|
- 파일 업로드와 메시지 저장 정합성 정책을 정한다.
|
||||||
|
|
||||||
|
- [x] 실시간 연결 방식 확정
|
||||||
|
- SSE(`SseEmitter`)를 사용한다.
|
||||||
|
- 현재 `build.gradle.kts`에는 `spring-boot-starter-web`이 있고 `spring-boot-starter-websocket`은 없으므로 신규 WebSocket 의존성을 추가하지 않는다.
|
||||||
|
- 클라이언트 메시지 발송과 방 화면 열기는 HTTP API로 처리하고 서버의 새 메시지 전달만 SSE로 처리한다.
|
||||||
|
- SSE 연결 인증은 기존 API 인증과 동일하게 `Authorization: Bearer <accessToken>` 헤더를 사용하는 방식을 우선 사용한다.
|
||||||
|
- 모바일 클라이언트에서 헤더 설정이 제한되는 라이브러리를 사용할 경우에만 단기 수명 SSE 전용 토큰을 발급해 query parameter로 전달하는 대안을 검토한다.
|
||||||
|
- 클라이언트 재연결 간격은 기본 3초로 시작하고, 연속 실패 시 3초, 6초, 12초, 24초, 최대 30초 지수 백오프로 확정한다.
|
||||||
|
- 서버는 SSE `retry` 값을 3초로 내려 클라이언트 기본 재연결 기준을 맞춘다.
|
||||||
|
|
||||||
|
- [x] Presence 정책 설계
|
||||||
|
- SSE 연결 시 해당 회원의 활성 연결을 등록한다.
|
||||||
|
- 앱 백그라운드 전환, 채팅방 화면 이탈, 연결 종료, 만료 시간 초과 시 활성 연결을 제거한다.
|
||||||
|
- 여러 기기에서 같은 방에 입장한 경우 하나 이상의 활성 연결이 있으면 입장 중으로 판단한다.
|
||||||
|
- 이미 Redis/Redisson 의존성이 있으므로 Redis TTL 기반 presence 저장을 우선 검토한다.
|
||||||
|
|
||||||
|
- [x] 조건부 푸시 발송 정책 설계
|
||||||
|
- 상대방이 같은 방에 입장 중이면 푸시를 발송하지 않는다.
|
||||||
|
- 상대방이 같은 방에 입장 중이 아니면 기존 `FcmEventType.SEND_MESSAGE` 패턴을 재사용하거나 신규 타입을 추가한다.
|
||||||
|
- 상대방이 같은 방에 입장 중이라고 판단된 상태에서 SSE 전송이 실패해도 보완 푸시는 발송하지 않는다.
|
||||||
|
- 신규 채팅방 딥링크가 필요하면 `FcmDeepLinkValue` 확장 여부를 검토한다.
|
||||||
|
|
||||||
|
- [x] 테스트 계획 수립
|
||||||
|
- 방 생성 중복 방지 테스트를 작성한다.
|
||||||
|
- 텍스트/음성 메시지 발송 성공 테스트를 작성한다.
|
||||||
|
- 상대방 presence 상태에 따른 실시간 전송/푸시 발송 분기 테스트를 작성한다.
|
||||||
|
- 앱 백그라운드 전환, 채팅방 화면 이탈, 연결 종료 시 presence 해제 테스트를 작성한다.
|
||||||
|
- SSE 전송 실패 시 보완 푸시를 발송하지 않는 테스트를 작성한다.
|
||||||
|
|
||||||
|
- [x] 기존 도메인 영향도 확인
|
||||||
|
- 기존 `message` API 동작을 변경하지 않는다.
|
||||||
|
- 기존 AI `chat/room` API 동작을 변경하지 않는다.
|
||||||
|
- 신규 도메인에서 필요한 공통 로직만 재사용하고, 외부 AI 세션 또는 쿼터 정책을 끌어오지 않는다.
|
||||||
|
|
||||||
|
## 참고 파일
|
||||||
|
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/message/Message.kt`
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/message/MessageService.kt`
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatRoom.kt`
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt`
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt`
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationService.kt`
|
||||||
|
|
||||||
|
## MySQL 테이블 생성 SQL
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE user_creator_chat_room (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '유저-크리에이터 채팅방 ID',
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT TRUE COMMENT '활성 여부',
|
||||||
|
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시',
|
||||||
|
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시',
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE user_creator_chat_participant (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '유저-크리에이터 채팅방 참여자 ID',
|
||||||
|
chat_room_id BIGINT NOT NULL COMMENT '유저-크리에이터 채팅방 ID',
|
||||||
|
member_id BIGINT NOT NULL COMMENT '참여 회원 ID',
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT TRUE COMMENT '활성 여부',
|
||||||
|
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시',
|
||||||
|
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_user_creator_chat_participant_room_member (chat_room_id, member_id),
|
||||||
|
KEY idx_user_creator_chat_participant_member (member_id),
|
||||||
|
CONSTRAINT fk_user_creator_chat_participant_room FOREIGN KEY (chat_room_id) REFERENCES user_creator_chat_room (id),
|
||||||
|
CONSTRAINT fk_user_creator_chat_participant_member FOREIGN KEY (member_id) REFERENCES member (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE user_creator_chat_message (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '유저-크리에이터 채팅 메시지 ID',
|
||||||
|
chat_room_id BIGINT NOT NULL COMMENT '유저-크리에이터 채팅방 ID',
|
||||||
|
participant_id BIGINT NOT NULL COMMENT '메시지 발신 참여자 ID',
|
||||||
|
message_type VARCHAR(20) NOT NULL COMMENT '메시지 타입(TEXT, VOICE)',
|
||||||
|
text_message TEXT NULL COMMENT '텍스트 메시지 본문',
|
||||||
|
voice_message VARCHAR(1024) NULL COMMENT '음성 메시지 파일 경로',
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT TRUE COMMENT '활성 여부',
|
||||||
|
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시',
|
||||||
|
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_user_creator_chat_message_room_id_id (chat_room_id, id),
|
||||||
|
KEY idx_user_creator_chat_message_participant (participant_id),
|
||||||
|
CONSTRAINT fk_user_creator_chat_message_room FOREIGN KEY (chat_room_id) REFERENCES user_creator_chat_room (id),
|
||||||
|
CONSTRAINT fk_user_creator_chat_message_participant FOREIGN KEY (participant_id) REFERENCES user_creator_chat_participant (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 클라이언트 연동 프롬프트
|
||||||
|
|
||||||
|
```text
|
||||||
|
신규 유저-크리에이터 채팅방 API를 연동한다.
|
||||||
|
|
||||||
|
공통 조건:
|
||||||
|
- 모든 요청은 `Authorization: Bearer <accessToken>` 헤더를 포함한다.
|
||||||
|
- API prefix는 `/api/v2/user-creator-chat/rooms`를 사용한다.
|
||||||
|
- 메시지 타입은 `TEXT`, `VOICE`만 처리한다.
|
||||||
|
- 앱이 백그라운드로 전환되거나 채팅방 화면에서 나가면 `POST /{roomId}/events/disconnect`를 호출하고 SSE 연결을 종료한다.
|
||||||
|
- SSE 연결이 끊기면 3초부터 재연결하고, 연속 실패 시 3초, 6초, 12초, 24초, 최대 30초까지 지수 백오프한다.
|
||||||
|
|
||||||
|
연동할 API:
|
||||||
|
1. 방 생성/조회
|
||||||
|
- `POST /api/v2/user-creator-chat/rooms/create`
|
||||||
|
- body: `{ "creatorId": number }`
|
||||||
|
- response data: `{ "roomId": number }`
|
||||||
|
|
||||||
|
2. 채팅방 화면 열기 및 최신 메시지 조회
|
||||||
|
- `GET /api/v2/user-creator-chat/rooms/{roomId}/open?limit=20`
|
||||||
|
- response data: `{ "roomId", "messages", "hasMore", "nextCursor" }`
|
||||||
|
|
||||||
|
3. 과거 메시지 조회
|
||||||
|
- `GET /api/v2/user-creator-chat/rooms/{roomId}/messages?cursor={nextCursor}&limit=20`
|
||||||
|
- response data: `{ "messages", "hasMore", "nextCursor" }`
|
||||||
|
|
||||||
|
4. SSE 연결
|
||||||
|
- `GET /api/v2/user-creator-chat/rooms/{roomId}/events`
|
||||||
|
- Accept: `text/event-stream`
|
||||||
|
- 이벤트 이름 `message`를 수신하면 payload를 현재 채팅방 메시지 목록에 append한다.
|
||||||
|
- 이벤트 이름 `connected`는 연결 확인용으로만 사용한다.
|
||||||
|
|
||||||
|
5. 텍스트 메시지 전송
|
||||||
|
- `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text`
|
||||||
|
- body: `{ "textMessage": string }`
|
||||||
|
- response data: `{ "message", "deliveredRealtime", "pushSent" }`
|
||||||
|
|
||||||
|
6. 음성 메시지 전송
|
||||||
|
- `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voice`
|
||||||
|
- multipart/form-data
|
||||||
|
- part `voiceMessageFile`: 음성 파일
|
||||||
|
- part `request`: `{}` JSON 문자열
|
||||||
|
- response data: `{ "message", "deliveredRealtime", "pushSent" }`
|
||||||
|
|
||||||
|
7. 실시간 연결 해제
|
||||||
|
- `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect`
|
||||||
|
- DB 참여자를 삭제하거나 비활성화하지 않고 SSE/presence 상태만 해제한다.
|
||||||
|
|
||||||
|
메시지 DTO 필드:
|
||||||
|
- `messageId`: number
|
||||||
|
- `messageType`: `TEXT` 또는 `VOICE`
|
||||||
|
- `mine`: boolean
|
||||||
|
- `createdAt`: epoch milliseconds
|
||||||
|
- `textMessage`: string 또는 null
|
||||||
|
- `voiceMessageUrl`: string 또는 null
|
||||||
|
- `senderId`: number
|
||||||
|
- `senderNickname`: string
|
||||||
|
- `senderProfileImageUrl`: string
|
||||||
|
```
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
|
||||||
|
### 1차 문서 작성
|
||||||
|
- 무엇을: 유저-크리에이터 메시지 채팅방 개편 PRD와 계획 TASK 문서를 작성했다.
|
||||||
|
- 왜: 구현 전에 PRD와 계획 TASK 문서를 작성하고, 기존 AI 채팅 도메인 재사용 여부를 코드 근거로 결정해야 하기 때문이다.
|
||||||
|
- 어떻게: 기존 `message`, AI `chat/room`, `fcm` 도메인 파일과 문서 작성 규칙을 확인했다. 문서에는 기존 메시지를 별도 유지하고 신규 기능부터 채팅방으로 제공한다는 사용자 결정을 반영했다. 문서 내 미완성 표식 검색에서 추가 수정 필요 항목이 없음을 확인했고, `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
|
||||||
|
### 2차 피드백 반영
|
||||||
|
- 무엇을: 실시간 전송 방식을 SSE로 확정하고, 앱 백그라운드/채팅방 화면 이탈 시 presence 해제, typing indicator 제거, 실시간 전송 실패 시 보완 푸시 미발송, AI `chat/room` 엔티티 직접 재활용 불가 판단을 문서에 반영했다.
|
||||||
|
- 왜: 사용자 피드백에서 신규 요구사항과 도메인 재활용 질문의 의도가 명확해졌고, 현재 코드베이스의 의존성 기준으로 WebSocket보다 SSE가 더 작은 변경이기 때문이다.
|
||||||
|
- 어떻게: `build.gradle.kts`의 의존성과 실시간 관련 구현 검색 결과를 확인했고, `ChatRoom`, `ChatParticipant`, `ChatMessage` 엔티티 필드가 유저-크리에이터 채팅 의미와 맞지 않는 점을 기준으로 문서를 수정했다. 문서 내 미완성 표식과 남은 미결정 표현 검색에서 추가 수정 필요 항목이 없음을 확인했고, Markdown diagnostics는 두 문서 모두 `No diagnostics found`, `./gradlew tasks --all`은 `BUILD SUCCESSFUL`이었다.
|
||||||
|
|
||||||
|
### 3차 API/SSE 정책 보강
|
||||||
|
- 무엇을: 신규 채팅방 API URL prefix와 응답 DTO 필드명은 구현 직전 추천 후 확정하는 절차를 추가하고, SSE 인증 방식과 클라이언트 재연결 간격 추천안을 확정해 문서에 반영했다.
|
||||||
|
- 왜: 구현 전에 API 계약을 한 번 더 확정하되, SSE 운영 정책은 계획 단계에서 명확히 고정해야 하기 때문이다.
|
||||||
|
- 어떻게: URL prefix 추천안은 `/api/v2/user-creator-chat/rooms`, SSE 인증은 `Authorization: Bearer <accessToken>` 헤더 우선, 헤더 제한 시 단기 수명 SSE 전용 토큰 대안, 재연결은 기본 3초 및 최대 30초 지수 백오프로 정리했다.
|
||||||
|
|
||||||
|
### 4차 구현
|
||||||
|
- 무엇을: `kr.co.vividnext.sodalive.v2.usercreatorchat` 신규 패키지 아래에 신규 엔티티, 리포지토리, 서비스, 컨트롤러, DTO, SSE presence 서비스를 추가했다.
|
||||||
|
- 왜: 기존 기능과 분리된 신규 버전 API로 개발하고, 기존 메시지/AI 채팅 도메인은 참고만 하기로 했기 때문이다.
|
||||||
|
- 어떻게: 실패 테스트를 먼저 작성해 신규 클래스 부재로 실패하는 것을 확인한 뒤 최소 구현을 추가했다. 이후 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest'`, `./gradlew ktlintCheck`, `./gradlew build`, 단독 `./gradlew test` 실행 결과 모두 `BUILD SUCCESSFUL`을 확인했다. Kotlin LSP는 이 환경에 서버가 없어 diagnostics를 수행할 수 없었고, Markdown diagnostics는 `No diagnostics found`였다. 신규 엔티티 기준 MySQL 테이블 생성 SQL과 클라이언트 연동 프롬프트를 이 문서에 추가했다.
|
||||||
|
|
||||||
|
### 5차 SQL 보완
|
||||||
|
- 무엇을: MySQL 테이블 생성 SQL의 `created_at`, `updated_at` 타입을 `TIMESTAMP` 기본값/자동 갱신 형식으로 변경하고 모든 컬럼에 `COMMENT`를 추가했다.
|
||||||
|
- 왜: 실제 MySQL DDL에서 생성/수정 시간 기본 동작과 컬럼 설명을 명확히 남기기 위해서다.
|
||||||
|
- 어떻게: SQL 블록의 세 테이블 컬럼 정의를 직접 수정했다.
|
||||||
|
|
||||||
|
### 6차 SQL 컬럼 순서 및 Boolean 보완
|
||||||
|
- 무엇을: MySQL 테이블 생성 SQL에서 `created_at`, `updated_at`을 각 테이블 컬럼 목록의 마지막으로 이동하고, `is_active`를 `TINYINT(1) NOT NULL DEFAULT TRUE`로 변경했다.
|
||||||
|
- 왜: 공통 시간 컬럼 위치와 boolean 기본값 표기를 일관되게 맞추기 위해서다.
|
||||||
|
- 어떻게: SQL 블록의 세 테이블 컬럼 순서와 `is_active` 타입/기본값, 활성 방 생성 컬럼 조건식을 수정했다.
|
||||||
|
|
||||||
|
### 7차 이전 메시지 조회 테스트 보강
|
||||||
|
- 무엇을: 유저-크리에이터 채팅방의 cursor 기반 이전 메시지 조회 테스트를 추가했다.
|
||||||
|
- 왜: 방 내부에서 이전 채팅을 조회할 수 있는지 검증하고, 기본 조회 개수 20개가 repository `PageRequest`에 적용되는지 확인하기 위해서다.
|
||||||
|
- 어떻게: `UserCreatorChatServiceTest`에 `cursor`가 있을 때 `findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc`가 `PageRequest.of(0, 20)`으로 호출되고, 응답의 `messages`, `hasMore`, `nextCursor`가 기대대로 반환되는지 검증하는 테스트를 추가했다. `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest'` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
|
||||||
|
### 8차 참여자 모델 단순화
|
||||||
|
- 무엇을: `UserCreatorChatRoom`의 `user`, `creator` 고정 참여자 컬럼과 `UserCreatorChatParticipantRole`을 제거하고, 참여자는 `UserCreatorChatParticipant`만 기준으로 관리하도록 수정했다.
|
||||||
|
- 왜: 현재 1:1 방에서는 별도 참여자 권한이 필요하지 않고, 향후 user-to-user 채팅 확장을 막지 않도록 방 엔티티에서 특정 참여자 역할을 고정하지 않기 위해서다.
|
||||||
|
- 어떻게: 테스트를 먼저 새 구조 기준으로 바꿔 컴파일 실패를 확인한 뒤, 방 중복 조회를 participants 기준 쿼리로 변경하고 엔티티/서비스/테스트를 수정했다. 아직 테이블을 생성하지 않았으므로 MySQL `CREATE TABLE` 문 자체에서 `user_id`, `creator_id`, `active_room_key`, participant `role` 컬럼을 제거했다.
|
||||||
|
|
||||||
|
### 9차 API 의미 명확화
|
||||||
|
- 무엇을: 실제 채팅방 탈퇴로 오해될 수 있는 `enter`, `leave` 표현을 제거하고, 방 화면 열기는 `open`, 실시간 수신 해제는 `events/disconnect`로 변경했다.
|
||||||
|
- 왜: 현재 기능은 DB 참여자 삭제/비활성화가 아니라 최신 메시지 조회와 SSE/presence 해제이므로, 일반적인 채팅방 입장/탈퇴 의미와 혼동되지 않게 하기 위해서다.
|
||||||
|
- 어떻게: 테스트에서 `disconnectRealtime` 메서드가 필요하도록 먼저 변경해 컴파일 실패를 확인한 뒤, 컨트롤러 URL과 서비스/DTO 함수명을 수정했다. 클라이언트 연동 문서도 새 API 의미와 URL로 갱신했다.
|
||||||
162
docs/prd/20260513_유저크리에이터채팅방개편_prd.md
Normal file
162
docs/prd/20260513_유저크리에이터채팅방개편_prd.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# PRD: 유저-크리에이터 채팅방 개편
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
유저와 크리에이터가 주고받는 신규 메시지를 채팅방 형태로 제공하고, 텍스트/음성 메시지, 실시간 수신, 조건부 푸시 알림을 지원한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problem
|
||||||
|
- 기존 `message` 도메인은 유저 간 1:1 텍스트/음성 메시지를 송수신함으로 구분해 조회하는 구조이며, 채팅방 입장 상태나 실시간 수신 상태를 표현하지 않는다.
|
||||||
|
- 기존 `chat/room` 도메인은 AI 캐릭터 채팅방으로, 외부 AI 세션 생성, 캐릭터 응답 저장, 이미지 메시지, 쿼터 차감이 핵심 책임이다.
|
||||||
|
- 유저-크리에이터 채팅방은 사람 간 대화이며, 상대방이 방에 들어와 있으면 푸시가 아닌 실시간 메시지 표시가 필요하다.
|
||||||
|
- 상대방이 현재 채팅방 화면에 있는지 판단하려면 방 단위 presence가 필요하지만, 현재 코드에서 WebSocket/SSE/STOMP 또는 presence 구현은 확인되지 않았다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
- 새 기능부터 유저-크리에이터 메시지를 채팅방 형태로 제공한다.
|
||||||
|
- 기존 `message` 도메인의 과거 메시지는 별도 유지하고, 신규 채팅방 메시지로 자동 마이그레이션하지 않는다.
|
||||||
|
- 신규 채팅방 메시지는 기존 메시지와 동일하게 텍스트 또는 음성 메시지만 허용한다.
|
||||||
|
- 메시지 발송 시 상대방이 해당 방에 입장해 있으면 푸시를 보내지 않고 실시간으로 메시지를 표시한다.
|
||||||
|
- 메시지 발송 시 상대방이 해당 방에 입장해 있지 않으면 푸시 알림을 발송한다.
|
||||||
|
- 기존 AI 채팅 도메인의 엔티티라도 재활용할 수 있는지 코드 근거에 따라 결정한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
- 기존 `message` 데이터의 신규 채팅방 메시지 마이그레이션은 이번 범위에 포함하지 않는다.
|
||||||
|
- 기존 `message` API와 조회 화면을 제거하지 않는다.
|
||||||
|
- AI 캐릭터 채팅의 외부 세션, 캐릭터 응답, 이미지 메시지, 쿼터 정책을 유저-크리에이터 채팅에 적용하지 않는다.
|
||||||
|
- 텍스트/음성 외 이미지, 파일, 이모티콘, 읽음 확인, 메시지 수정 기능은 이번 범위에 포함하지 않는다.
|
||||||
|
- typing indicator는 이번 범위에 포함하지 않는다.
|
||||||
|
- 관리자 화면 개편은 이번 범위에 포함하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Target Users
|
||||||
|
- 유저: 크리에이터에게 텍스트 또는 음성 메시지를 보내고 채팅방에서 대화를 이어가려는 회원
|
||||||
|
- 크리에이터: 유저와의 대화를 채팅방 단위로 확인하고 실시간으로 응답하려는 회원
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. User Stories
|
||||||
|
- 유저는 크리에이터와의 대화방에 들어가 이전 신규 채팅 메시지를 시간순으로 보고 싶다.
|
||||||
|
- 유저는 채팅방에서 텍스트 메시지를 보내고 상대방이 방에 있으면 바로 표시되기를 원한다.
|
||||||
|
- 유저는 채팅방에서 음성 메시지를 보내고 기존 메시지와 동일한 방식으로 재생 가능한 URL을 받기를 원한다.
|
||||||
|
- 크리에이터는 방에 들어와 있지 않을 때 새 메시지가 오면 푸시로 알림을 받고 싶다.
|
||||||
|
- 크리에이터는 방에 들어와 있을 때 새 메시지가 오면 푸시 없이 화면에 바로 표시되기를 원한다.
|
||||||
|
- 양쪽 사용자는 상대방이 방에 들어와 있을 때 새 메시지를 즉시 확인하고 싶다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Core Features
|
||||||
|
|
||||||
|
### 도메인 결정
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 신규 유저-크리에이터 채팅방 도메인과 엔티티를 작성한다.
|
||||||
|
- 기존 `message` 도메인은 과거 1:1 메시지와 기존 API 유지 용도로 둔다.
|
||||||
|
- 기존 `chat/room` 도메인은 AI 캐릭터 채팅 용도로 유지하고, 유저-크리에이터 채팅방의 엔티티로 직접 재활용하지 않는다.
|
||||||
|
- `message` 도메인의 텍스트/음성 저장 방식, 차단 검증, 음성 파일 업로드 방식, `FcmEventType.SEND_MESSAGE` 푸시 발행 패턴은 참고한다.
|
||||||
|
- `chat/room` 도메인의 방/참여자/메시지 분리 구조와 cursor 기반 메시지 조회 방식은 참고할 수 있다.
|
||||||
|
|
||||||
|
#### Rationale
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt`는 외부 `weraser` AI 세션 생성과 `/api/chat` 호출, 캐릭터 응답 저장, 이미지 메시지, 쿼터 차감에 강하게 결합되어 있다.
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatRoom.kt`는 `sessionId`와 `title`이 필수이고, AI 외부 세션 식별자 의미가 들어간다.
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatParticipant.kt`는 `USER/CHARACTER` 참여자와 `ChatCharacter` 참조를 전제로 하므로 유저-크리에이터의 회원 간 참여자 모델로 그대로 쓰기 어렵다.
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessage.kt`는 텍스트/이미지 중심이고 `CharacterImage`, `imagePath`, `price`를 포함하지만 음성 메시지 경로는 없다.
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/message/MessageService.kt`는 텍스트/음성 메시지와 푸시 발행 흐름이 유사하지만 방, 참여자, presence 개념이 없다.
|
||||||
|
- 따라서 AI 채팅 엔티티를 직접 재활용하기보다 신규 엔티티를 만들고, 엔티티 분리 구조와 조회 패턴만 참고하는 것이 책임 경계가 명확하다.
|
||||||
|
|
||||||
|
### 채팅방 생성 및 입장
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 유저와 크리에이터 조합당 활성 채팅방은 하나만 사용한다.
|
||||||
|
- 신규 메시지를 처음 보내거나 채팅방 진입을 요청할 때 활성 방이 없으면 생성한다.
|
||||||
|
- 채팅방 입장 시 최신 메시지 목록을 cursor 기반으로 조회한다.
|
||||||
|
- 입장 상태는 실시간 연결 상태와 연동해 관리한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 상대방이 비활성 회원이면 메시지 발송을 거부한다.
|
||||||
|
- 기존 차단 관계가 있으면 메시지 발송을 거부한다.
|
||||||
|
- 동시에 같은 유저-크리에이터 조합으로 방 생성 요청이 들어오면 하나의 활성 방만 남도록 유니크 제약 또는 트랜잭션 정책을 둔다.
|
||||||
|
|
||||||
|
### 메시지 발송
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 메시지 타입은 `TEXT`, `VOICE`만 허용한다.
|
||||||
|
- 텍스트 메시지는 본문을 저장한다.
|
||||||
|
- 음성 메시지는 기존 `message` 도메인처럼 S3 업로드 후 저장 경로를 메시지에 연결한다.
|
||||||
|
- 메시지 저장 후 상대방의 현재 방 입장 여부를 확인한다.
|
||||||
|
- 상대방이 같은 방에 입장 중이면 실시간 채널로 메시지를 전송하고 푸시는 발송하지 않는다.
|
||||||
|
- 상대방이 같은 방에 입장 중이 아니면 기존 FCM 발송 패턴을 사용해 푸시 알림을 발송한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 음성 파일 업로드 실패 시 메시지 저장과 파일 저장의 정합성을 보장한다.
|
||||||
|
- 실시간 전송 실패 시 메시지는 저장되어야 하지만, 상대방이 방에 있는 것으로 판단된 경우 보완 푸시는 발송하지 않는다.
|
||||||
|
- 같은 사용자의 중복 전송 요청은 클라이언트 재시도 정책과 서버 idempotency 필요 여부를 별도 결정한다.
|
||||||
|
|
||||||
|
### 실시간 수신 및 presence
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 실시간 전송 방식은 SSE(`SseEmitter`)를 우선 사용한다.
|
||||||
|
- 메시지 발송은 기존 HTTP API로 처리하고, 서버에서 수신자에게 전달해야 하는 새 메시지만 SSE로 전송한다.
|
||||||
|
- 방 입장 시 사용자를 해당 방의 온라인 참여자로 등록하고 SSE 연결을 생성한다.
|
||||||
|
- 앱이 백그라운드로 전환되거나, 사용자가 채팅방 화면에서 나가거나, 연결이 종료되거나, 타임아웃되면 방 입장 상태를 해제한다.
|
||||||
|
- 메시지 발송 시 presence 상태를 기준으로 푸시 발송 여부를 결정한다.
|
||||||
|
- 서버 재시작 또는 비정상 연결 종료에도 오래된 presence가 남지 않도록 만료 시간을 둔다.
|
||||||
|
- Redis/Redisson 의존성이 이미 있으므로, 다중 서버 가능성을 고려해 presence는 Redis TTL 기반 저장을 우선 검토한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 한 사용자가 여러 기기에서 같은 방에 입장할 수 있다면, 하나 이상의 활성 연결이 있을 때 입장 중으로 판단한다.
|
||||||
|
- 앱 백그라운드 전환 또는 화면 이탈 이벤트가 서버에 도달하지 못해도 SSE 연결 종료와 Redis TTL 만료로 방 입장 상태가 해제되어야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Technical Constraints
|
||||||
|
- 기존 Spring Boot 2.7.14, Kotlin, Java 17, Gradle Wrapper 구조를 유지한다.
|
||||||
|
- 문서 기준 신규 도메인 후보 패키지는 `user/creator/chat` 또는 기존 패키지 관례에 맞춘 별도 패키지로 둔다.
|
||||||
|
- 실시간 전송 방식은 SSE(`SseEmitter`)를 사용한다. 현재 코드베이스에 `spring-boot-starter-web`은 있으나 `spring-boot-starter-websocket`은 없고, typing indicator 요구사항이 제거되어 양방향 실시간 프로토콜 필요성이 낮기 때문이다.
|
||||||
|
- 서버는 새 메시지 이벤트만 SSE로 전송하고, 클라이언트의 메시지 발송/입장/퇴장 이벤트는 HTTP API로 처리한다.
|
||||||
|
- 신규 채팅방 API의 URL prefix와 응답 DTO 필드명은 구현 직전 추천안을 제시한 뒤 확정한다.
|
||||||
|
- 계획 문서 기준 URL prefix 추천안은 기존 `/api/chat/room`과 충돌하지 않는 `/api/user-creator-chat/rooms`이다.
|
||||||
|
- SSE 연결 인증은 기존 API 인증과 동일하게 `Authorization: Bearer <accessToken>` 헤더 방식을 우선 사용한다.
|
||||||
|
- 모바일 클라이언트에서 `Authorization` 헤더 기반 SSE 연결을 사용할 수 없는 라이브러리를 선택하는 경우에만 단기 수명 SSE 전용 토큰을 발급해 query parameter로 전달한다.
|
||||||
|
- 클라이언트 재연결 간격은 기본 3초, 연속 실패 시 3초, 6초, 12초, 24초, 최대 30초 지수 백오프로 확정한다.
|
||||||
|
- 푸시 발송은 기존 `FcmEvent`, `FcmSendListener`, `PushNotificationService` 패턴을 우선 재사용한다.
|
||||||
|
- 음성 파일 업로드는 기존 `S3Uploader`와 CloudFront URL 조합 방식을 우선 재사용한다.
|
||||||
|
- 공개 API 스키마는 신규 API로 분리하고 기존 `message` 및 AI `chat/room` API를 깨지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Domain Approach Options
|
||||||
|
|
||||||
|
### Option A: 기존 `message` 도메인 확장
|
||||||
|
- 장점: 텍스트/음성 메시지, 차단 검증, FCM 발행 흐름을 가장 많이 재사용할 수 있다.
|
||||||
|
- 단점: 방, 참여자, presence를 기존 송수신함 모델에 추가해야 하므로 기존 API와 데이터 의미가 혼재된다.
|
||||||
|
- 판단: 기존 메시지를 별도 유지하기로 했으므로 권장하지 않는다.
|
||||||
|
|
||||||
|
### Option B: 기존 AI `chat/room` 엔티티 재활용
|
||||||
|
- 장점: `ChatRoom`, `ChatParticipant`, `ChatMessage`의 방/참여자/메시지 분리 구조와 cursor 조회 패턴이 이미 있다.
|
||||||
|
- 단점: `ChatRoom.sessionId`는 AI 외부 세션 의미이고, `ChatParticipant`는 `USER/CHARACTER` 및 `ChatCharacter` 참조를 전제로 하며, `ChatMessage`는 이미지/가격 필드는 있지만 음성 메시지 필드가 없다.
|
||||||
|
- 판단: 엔티티만 재활용하더라도 유저-크리에이터 채팅 의미와 맞지 않는 필드와 제약이 많아 권장하지 않는다. 구조만 참고한다.
|
||||||
|
|
||||||
|
### Option C: 신규 유저-크리에이터 채팅방 도메인 작성
|
||||||
|
- 장점: 사람 간 채팅, presence, 조건부 푸시의 책임을 명확히 분리할 수 있다.
|
||||||
|
- 장점: 기존 `message`와 AI `chat/room`을 깨지 않고 필요한 패턴만 참고할 수 있다.
|
||||||
|
- 단점: 신규 엔티티, 저장소, 실시간 연결 관리, API가 필요해 초기 구현량이 늘어난다.
|
||||||
|
- 판단: 이번 요구사항의 권장안이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Metrics
|
||||||
|
- 신규 채팅방 메시지 발송 성공률
|
||||||
|
- 실시간 수신 성공률
|
||||||
|
- 방 입장 중 메시지에 대한 푸시 미발송 비율
|
||||||
|
- 방 미입장 상태 메시지에 대한 푸시 발송 성공률
|
||||||
|
- 방 입장 상태 해제 지연 시간
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Open Questions
|
||||||
|
- 없음. URL prefix와 DTO 필드명은 구현 직전 추천안을 제시해 확정하고, SSE 인증과 재연결 간격은 본 문서의 Technical Constraints 기준을 따른다.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.usercreatorchat
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
|
import javax.persistence.Column
|
||||||
|
import javax.persistence.Entity
|
||||||
|
import javax.persistence.EnumType
|
||||||
|
import javax.persistence.Enumerated
|
||||||
|
import javax.persistence.FetchType
|
||||||
|
import javax.persistence.JoinColumn
|
||||||
|
import javax.persistence.ManyToOne
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
class UserCreatorChatMessage(
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "chat_room_id", nullable = false)
|
||||||
|
var chatRoom: UserCreatorChatRoom,
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "participant_id", nullable = false)
|
||||||
|
var participant: UserCreatorChatParticipant,
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "message_type", nullable = false)
|
||||||
|
var messageType: UserCreatorChatMessageType,
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
var textMessage: String? = null,
|
||||||
|
|
||||||
|
@Column(length = 1024)
|
||||||
|
var voiceMessage: String? = null,
|
||||||
|
|
||||||
|
var isActive: Boolean = true
|
||||||
|
) : BaseEntity()
|
||||||
|
|
||||||
|
enum class UserCreatorChatMessageType {
|
||||||
|
TEXT, VOICE
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.usercreatorchat
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import javax.persistence.Entity
|
||||||
|
import javax.persistence.FetchType
|
||||||
|
import javax.persistence.JoinColumn
|
||||||
|
import javax.persistence.ManyToOne
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
class UserCreatorChatParticipant(
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "chat_room_id", nullable = false)
|
||||||
|
var chatRoom: UserCreatorChatRoom,
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "member_id", nullable = false)
|
||||||
|
var member: Member,
|
||||||
|
|
||||||
|
var isActive: Boolean = true
|
||||||
|
) : BaseEntity()
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.usercreatorchat
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
|
import javax.persistence.CascadeType
|
||||||
|
import javax.persistence.Entity
|
||||||
|
import javax.persistence.FetchType
|
||||||
|
import javax.persistence.OneToMany
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
class UserCreatorChatRoom(
|
||||||
|
var isActive: Boolean = true
|
||||||
|
) : BaseEntity() {
|
||||||
|
@OneToMany(mappedBy = "chatRoom", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
|
||||||
|
var participants: MutableList<UserCreatorChatParticipant> = mutableListOf()
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "chatRoom", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
|
||||||
|
var messages: MutableList<UserCreatorChatMessage> = mutableListOf()
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.usercreatorchat.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.usercreatorchat.dto.CreateUserCreatorChatRoomRequest
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorTextMessageRequest
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v2/user-creator-chat/rooms")
|
||||||
|
class UserCreatorChatController(
|
||||||
|
private val service: UserCreatorChatService
|
||||||
|
) {
|
||||||
|
@PostMapping("/create")
|
||||||
|
fun createOrGetRoom(
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||||
|
@RequestBody request: CreateUserCreatorChatRoomRequest
|
||||||
|
) = run {
|
||||||
|
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
ApiResponse.ok(service.createOrGetRoom(member, request.creatorId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{roomId}/open")
|
||||||
|
fun openRoom(
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||||
|
@PathVariable roomId: Long,
|
||||||
|
@RequestParam(defaultValue = "20") limit: Int
|
||||||
|
) = run {
|
||||||
|
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
ApiResponse.ok(service.openRoom(member, roomId, limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{roomId}/events/disconnect")
|
||||||
|
fun disconnectRealtime(
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||||
|
@PathVariable roomId: Long
|
||||||
|
) = run {
|
||||||
|
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
service.disconnectRealtime(member, roomId)
|
||||||
|
ApiResponse.ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{roomId}/messages")
|
||||||
|
fun getMessages(
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||||
|
@PathVariable roomId: Long,
|
||||||
|
@RequestParam(required = false) cursor: Long?,
|
||||||
|
@RequestParam(defaultValue = "20") limit: Int
|
||||||
|
) = run {
|
||||||
|
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
ApiResponse.ok(service.getMessages(member, roomId, cursor, limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{roomId}/events", produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
|
||||||
|
fun connectEvents(
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||||
|
@PathVariable roomId: Long
|
||||||
|
) = run {
|
||||||
|
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
service.connect(member, roomId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{roomId}/messages/text")
|
||||||
|
fun sendTextMessage(
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||||
|
@PathVariable roomId: Long,
|
||||||
|
@RequestBody request: SendUserCreatorTextMessageRequest
|
||||||
|
) = run {
|
||||||
|
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
ApiResponse.ok(service.sendTextMessage(member, roomId, request))
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{roomId}/messages/voice", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
|
||||||
|
fun sendVoiceMessage(
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||||
|
@PathVariable roomId: Long,
|
||||||
|
@RequestPart("voiceMessageFile") voiceMessageFile: MultipartFile,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = run {
|
||||||
|
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
ApiResponse.ok(service.sendVoiceMessage(member, roomId, voiceMessageFile, requestString))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.usercreatorchat.dto
|
||||||
|
|
||||||
|
data class CreateUserCreatorChatRoomRequest(
|
||||||
|
val creatorId: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreateUserCreatorChatRoomResponse(
|
||||||
|
val roomId: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SendUserCreatorTextMessageRequest(
|
||||||
|
val textMessage: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SendUserCreatorVoiceMessageRequest(
|
||||||
|
val recipientId: Long? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SendUserCreatorChatMessageResponse(
|
||||||
|
val message: UserCreatorChatMessageItemDto,
|
||||||
|
val deliveredRealtime: Boolean,
|
||||||
|
val pushSent: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UserCreatorChatRoomOpenResponse(
|
||||||
|
val roomId: Long,
|
||||||
|
val messages: List<UserCreatorChatMessageItemDto>,
|
||||||
|
val hasMore: Boolean,
|
||||||
|
val nextCursor: Long?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UserCreatorChatMessagesPageResponse(
|
||||||
|
val messages: List<UserCreatorChatMessageItemDto>,
|
||||||
|
val hasMore: Boolean,
|
||||||
|
val nextCursor: Long?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UserCreatorChatMessageItemDto(
|
||||||
|
val messageId: Long,
|
||||||
|
val messageType: String,
|
||||||
|
val mine: Boolean,
|
||||||
|
val createdAt: Long,
|
||||||
|
val textMessage: String?,
|
||||||
|
val voiceMessageUrl: String?,
|
||||||
|
val senderId: Long,
|
||||||
|
val senderNickname: String,
|
||||||
|
val senderProfileImageUrl: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.usercreatorchat.repository
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessage
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatRoom
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
interface UserCreatorChatMessageRepository : JpaRepository<UserCreatorChatMessage, Long> {
|
||||||
|
fun findByChatRoomAndIsActiveTrueOrderByIdDesc(
|
||||||
|
chatRoom: UserCreatorChatRoom,
|
||||||
|
pageable: Pageable
|
||||||
|
): List<UserCreatorChatMessage>
|
||||||
|
|
||||||
|
fun findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(
|
||||||
|
chatRoom: UserCreatorChatRoom,
|
||||||
|
id: Long,
|
||||||
|
pageable: Pageable
|
||||||
|
): List<UserCreatorChatMessage>
|
||||||
|
|
||||||
|
fun existsByChatRoomAndIsActiveTrueAndIdLessThan(chatRoom: UserCreatorChatRoom, id: Long): Boolean
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.usercreatorchat.repository
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatParticipant
|
||||||
|
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
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
interface UserCreatorChatParticipantRepository : JpaRepository<UserCreatorChatParticipant, Long> {
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
select p from UserCreatorChatParticipant p
|
||||||
|
where p.isActive = true
|
||||||
|
and p.chatRoom.id = :roomId
|
||||||
|
and p.member.id = :memberId
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun findActiveByRoomIdAndMemberId(
|
||||||
|
@Param("roomId") roomId: Long,
|
||||||
|
@Param("memberId") memberId: Long
|
||||||
|
): UserCreatorChatParticipant?
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
select p from UserCreatorChatParticipant p
|
||||||
|
where p.isActive = true
|
||||||
|
and p.chatRoom.id = :roomId
|
||||||
|
and p.member.id <> :memberId
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun findActiveOpponent(
|
||||||
|
@Param("roomId") roomId: Long,
|
||||||
|
@Param("memberId") memberId: Long
|
||||||
|
): UserCreatorChatParticipant?
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.usercreatorchat.repository
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatRoom
|
||||||
|
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
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
interface UserCreatorChatRoomRepository : JpaRepository<UserCreatorChatRoom, Long> {
|
||||||
|
fun findByIdAndIsActiveTrue(id: Long): UserCreatorChatRoom?
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
select r from UserCreatorChatRoom r
|
||||||
|
where r.isActive = true
|
||||||
|
and exists (
|
||||||
|
select 1 from UserCreatorChatParticipant p1
|
||||||
|
where p1.chatRoom = r
|
||||||
|
and p1.isActive = true
|
||||||
|
and p1.member.id = :firstMemberId
|
||||||
|
)
|
||||||
|
and exists (
|
||||||
|
select 1 from UserCreatorChatParticipant p2
|
||||||
|
where p2.chatRoom = r
|
||||||
|
and p2.isActive = true
|
||||||
|
and p2.member.id = :secondMemberId
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun findActiveRoomByParticipantMemberIds(
|
||||||
|
@Param("firstMemberId") firstMemberId: Long,
|
||||||
|
@Param("secondMemberId") secondMemberId: Long
|
||||||
|
): UserCreatorChatRoom?
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.usercreatorchat.service
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
|
||||||
|
import java.io.IOException
|
||||||
|
import java.time.Duration
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class UserCreatorChatRealtimeService(
|
||||||
|
private val stringRedisTemplate: StringRedisTemplate
|
||||||
|
) {
|
||||||
|
private val emitters = ConcurrentHashMap<String, SseEmitter>()
|
||||||
|
|
||||||
|
fun connect(roomId: Long, memberId: Long): SseEmitter {
|
||||||
|
val emitter = SseEmitter(SSE_TIMEOUT_MILLIS)
|
||||||
|
val key = emitterKey(roomId, memberId)
|
||||||
|
emitters[key] = emitter
|
||||||
|
markPresent(roomId, memberId)
|
||||||
|
|
||||||
|
emitter.onCompletion { disconnect(roomId, memberId) }
|
||||||
|
emitter.onTimeout { disconnect(roomId, memberId) }
|
||||||
|
emitter.onError { disconnect(roomId, memberId) }
|
||||||
|
|
||||||
|
sendConnectEvent(emitter)
|
||||||
|
return emitter
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect(roomId: Long, memberId: Long) {
|
||||||
|
emitters.remove(emitterKey(roomId, memberId))
|
||||||
|
stringRedisTemplate.delete(presenceKey(roomId, memberId))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isMemberInRoom(roomId: Long, memberId: Long): Boolean {
|
||||||
|
return stringRedisTemplate.hasKey(presenceKey(roomId, memberId))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage(roomId: Long, memberId: Long, message: UserCreatorChatMessageItemDto): Boolean {
|
||||||
|
val emitter = emitters[emitterKey(roomId, memberId)] ?: return false
|
||||||
|
return try {
|
||||||
|
emitter.send(
|
||||||
|
SseEmitter.event()
|
||||||
|
.id(message.messageId.toString())
|
||||||
|
.name("message")
|
||||||
|
.reconnectTime(SSE_RECONNECT_MILLIS)
|
||||||
|
.data(message)
|
||||||
|
)
|
||||||
|
markPresent(roomId, memberId)
|
||||||
|
true
|
||||||
|
} catch (_: IOException) {
|
||||||
|
disconnect(roomId, memberId)
|
||||||
|
false
|
||||||
|
} catch (_: IllegalStateException) {
|
||||||
|
disconnect(roomId, memberId)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendConnectEvent(emitter: SseEmitter) {
|
||||||
|
try {
|
||||||
|
emitter.send(
|
||||||
|
SseEmitter.event()
|
||||||
|
.name("connected")
|
||||||
|
.reconnectTime(SSE_RECONNECT_MILLIS)
|
||||||
|
.data("connected")
|
||||||
|
)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
emitter.completeWithError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun markPresent(roomId: Long, memberId: Long) {
|
||||||
|
stringRedisTemplate.opsForValue().set(presenceKey(roomId, memberId), "1", Duration.ofSeconds(PRESENCE_TTL_SECONDS))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitterKey(roomId: Long, memberId: Long) = "$roomId:$memberId"
|
||||||
|
|
||||||
|
private fun presenceKey(roomId: Long, memberId: Long) = "v2:user-creator-chat:presence:$roomId:$memberId"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val SSE_TIMEOUT_MILLIS = 30L * 60L * 1000L
|
||||||
|
private const val SSE_RECONNECT_MILLIS = 3000L
|
||||||
|
private const val PRESENCE_TTL_SECONDS = 60L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.usercreatorchat.service
|
||||||
|
|
||||||
|
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||||
|
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||||
|
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessage
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessageType
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatParticipant
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatRoom
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.CreateUserCreatorChatRoomResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorChatMessageResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorTextMessageRequest
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorVoiceMessageRequest
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessagesPageResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatRoomOpenResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatMessageRepository
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatParticipantRepository
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatRoomRepository
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
|
import org.springframework.data.domain.PageRequest
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
class UserCreatorChatService(
|
||||||
|
private val roomRepository: UserCreatorChatRoomRepository,
|
||||||
|
private val participantRepository: UserCreatorChatParticipantRepository,
|
||||||
|
private val messageRepository: UserCreatorChatMessageRepository,
|
||||||
|
private val memberRepository: MemberRepository,
|
||||||
|
private val blockMemberRepository: BlockMemberRepository,
|
||||||
|
private val realtimeService: UserCreatorChatRealtimeService,
|
||||||
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
|
private val objectMapper: ObjectMapper,
|
||||||
|
private val s3Uploader: S3Uploader,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
|
private val bucket: String,
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val cloudFrontHost: String
|
||||||
|
) {
|
||||||
|
@Transactional
|
||||||
|
fun createOrGetRoom(member: Member, creatorId: Long): CreateUserCreatorChatRoomResponse {
|
||||||
|
val creator = memberRepository.findById(creatorId).orElseThrow {
|
||||||
|
SodaException(messageKey = "message.error.recipient_not_found")
|
||||||
|
}
|
||||||
|
validateRecipient(member, creator)
|
||||||
|
|
||||||
|
val existingRoom = roomRepository.findActiveRoomByParticipantMemberIds(member.id!!, creator.id!!)
|
||||||
|
if (existingRoom != null) {
|
||||||
|
return CreateUserCreatorChatRoomResponse(roomId = existingRoom.id!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
val room = roomRepository.save(UserCreatorChatRoom())
|
||||||
|
participantRepository.save(UserCreatorChatParticipant(room, member))
|
||||||
|
participantRepository.save(UserCreatorChatParticipant(room, creator))
|
||||||
|
return CreateUserCreatorChatRoomResponse(roomId = room.id!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun openRoom(member: Member, roomId: Long, limit: Int = 20): UserCreatorChatRoomOpenResponse {
|
||||||
|
val room = findRoom(roomId)
|
||||||
|
requireParticipant(roomId, member.id!!)
|
||||||
|
val page = getMessages(member, roomId, cursor = null, limit = limit)
|
||||||
|
return UserCreatorChatRoomOpenResponse(
|
||||||
|
roomId = room.id!!,
|
||||||
|
messages = page.messages,
|
||||||
|
hasMore = page.hasMore,
|
||||||
|
nextCursor = page.nextCursor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMessages(member: Member, roomId: Long, cursor: Long?, limit: Int = 20): UserCreatorChatMessagesPageResponse {
|
||||||
|
val room = findRoom(roomId)
|
||||||
|
requireParticipant(roomId, member.id!!)
|
||||||
|
val pageable = PageRequest.of(0, limit.coerceIn(1, 100))
|
||||||
|
val fetched = if (cursor != null) {
|
||||||
|
messageRepository.findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(room, cursor, pageable)
|
||||||
|
} else {
|
||||||
|
messageRepository.findByChatRoomAndIsActiveTrueOrderByIdDesc(room, pageable)
|
||||||
|
}
|
||||||
|
val nextCursor = fetched.minByOrNull { it.id ?: Long.MAX_VALUE }?.id
|
||||||
|
val hasMore = nextCursor?.let { messageRepository.existsByChatRoomAndIsActiveTrueAndIdLessThan(room, it) } ?: false
|
||||||
|
return UserCreatorChatMessagesPageResponse(
|
||||||
|
messages = fetched.sortedBy { it.createdAt }.map { toMessageItemDto(it, member) },
|
||||||
|
hasMore = hasMore,
|
||||||
|
nextCursor = nextCursor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun sendTextMessage(
|
||||||
|
member: Member,
|
||||||
|
roomId: Long,
|
||||||
|
request: SendUserCreatorTextMessageRequest
|
||||||
|
): SendUserCreatorChatMessageResponse {
|
||||||
|
if (request.textMessage.isBlank()) throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
val context = resolveSendContext(member, roomId)
|
||||||
|
val message = messageRepository.save(
|
||||||
|
UserCreatorChatMessage(
|
||||||
|
chatRoom = context.room,
|
||||||
|
participant = context.senderParticipant,
|
||||||
|
messageType = UserCreatorChatMessageType.TEXT,
|
||||||
|
textMessage = request.textMessage
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return deliverMessage(message, member, context.opponentParticipant)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun sendVoiceMessage(
|
||||||
|
member: Member,
|
||||||
|
roomId: Long,
|
||||||
|
voiceMessageFile: MultipartFile,
|
||||||
|
requestString: String
|
||||||
|
): SendUserCreatorChatMessageResponse {
|
||||||
|
objectMapper.readValue(requestString, SendUserCreatorVoiceMessageRequest::class.java)
|
||||||
|
val context = resolveSendContext(member, roomId)
|
||||||
|
val message = messageRepository.save(
|
||||||
|
UserCreatorChatMessage(
|
||||||
|
chatRoom = context.room,
|
||||||
|
participant = context.senderParticipant,
|
||||||
|
messageType = UserCreatorChatMessageType.VOICE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val metadata = ObjectMetadata()
|
||||||
|
metadata.contentLength = voiceMessageFile.size
|
||||||
|
message.voiceMessage = s3Uploader.upload(
|
||||||
|
inputStream = voiceMessageFile.inputStream,
|
||||||
|
bucket = bucket,
|
||||||
|
filePath = "user_creator_chat_voice/${message.id}/${generateFileName(prefix = "${message.id}-message-")}",
|
||||||
|
metadata = metadata
|
||||||
|
)
|
||||||
|
return deliverMessage(message, member, context.opponentParticipant)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connect(member: Member, roomId: Long) = run {
|
||||||
|
requireParticipant(roomId, member.id!!)
|
||||||
|
realtimeService.connect(roomId, member.id!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnectRealtime(member: Member, roomId: Long) {
|
||||||
|
requireParticipant(roomId, member.id!!)
|
||||||
|
realtimeService.disconnect(roomId, member.id!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveSendContext(member: Member, roomId: Long): SendContext {
|
||||||
|
val room = findRoom(roomId)
|
||||||
|
val senderParticipant = requireParticipant(roomId, member.id!!)
|
||||||
|
val opponentParticipant = participantRepository.findActiveOpponent(roomId, member.id!!)
|
||||||
|
?: throw SodaException(messageKey = "chat.room.invalid_access")
|
||||||
|
validateRecipient(member, opponentParticipant.member)
|
||||||
|
return SendContext(room, senderParticipant, opponentParticipant)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deliverMessage(
|
||||||
|
message: UserCreatorChatMessage,
|
||||||
|
member: Member,
|
||||||
|
opponentParticipant: UserCreatorChatParticipant
|
||||||
|
): SendUserCreatorChatMessageResponse {
|
||||||
|
val opponent = opponentParticipant.member
|
||||||
|
val item = toMessageItemDto(message, member)
|
||||||
|
val opponentPresent = realtimeService.isMemberInRoom(message.chatRoom.id!!, opponent.id!!)
|
||||||
|
if (opponentPresent) {
|
||||||
|
val delivered = realtimeService.sendMessage(message.chatRoom.id!!, opponent.id!!, item)
|
||||||
|
return SendUserCreatorChatMessageResponse(message = item, deliveredRealtime = delivered, pushSent = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
publishMessagePush(message, member, opponent)
|
||||||
|
return SendUserCreatorChatMessageResponse(message = item, deliveredRealtime = false, pushSent = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publishMessagePush(message: UserCreatorChatMessage, sender: Member, opponent: Member) {
|
||||||
|
val messageKey = if (message.messageType == UserCreatorChatMessageType.VOICE) {
|
||||||
|
"message.fcm.voice_received"
|
||||||
|
} else {
|
||||||
|
"message.fcm.text_received"
|
||||||
|
}
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
FcmEvent(
|
||||||
|
type = FcmEventType.INDIVIDUAL,
|
||||||
|
category = PushNotificationCategory.MESSAGE,
|
||||||
|
titleKey = "message.fcm.title",
|
||||||
|
messageKey = messageKey,
|
||||||
|
senderMemberId = sender.id,
|
||||||
|
args = listOf(sender.nickname),
|
||||||
|
recipients = listOf(opponent.id!!),
|
||||||
|
messageId = message.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateRecipient(sender: Member, recipient: Member) {
|
||||||
|
if (!recipient.isActive) throw SodaException(messageKey = "message.error.recipient_inactive")
|
||||||
|
if (sender.id == recipient.id) throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
if (blockMemberRepository.isBlocked(blockedMemberId = sender.id!!, memberId = recipient.id!!)) {
|
||||||
|
throw SodaException(messageKey = "message.error.blocked_by_recipient")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findRoom(roomId: Long): UserCreatorChatRoom {
|
||||||
|
return roomRepository.findByIdAndIsActiveTrue(roomId)
|
||||||
|
?: throw SodaException(messageKey = "chat.error.room_not_found")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireParticipant(roomId: Long, memberId: Long): UserCreatorChatParticipant {
|
||||||
|
return participantRepository.findActiveByRoomIdAndMemberId(roomId, memberId)
|
||||||
|
?: throw SodaException(messageKey = "chat.room.invalid_access")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toMessageItemDto(message: UserCreatorChatMessage, member: Member): UserCreatorChatMessageItemDto {
|
||||||
|
val sender = message.participant.member
|
||||||
|
val profilePath = sender.profileImage ?: "profile/default-profile.png"
|
||||||
|
return UserCreatorChatMessageItemDto(
|
||||||
|
messageId = message.id!!,
|
||||||
|
messageType = message.messageType.name,
|
||||||
|
mine = sender.id == member.id,
|
||||||
|
createdAt = message.createdAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() ?: 0L,
|
||||||
|
textMessage = message.textMessage,
|
||||||
|
voiceMessageUrl = message.voiceMessage?.let { "$cloudFrontHost/$it" },
|
||||||
|
senderId = sender.id!!,
|
||||||
|
senderNickname = sender.nickname,
|
||||||
|
senderProfileImageUrl = "$cloudFrontHost/$profilePath"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class SendContext(
|
||||||
|
val room: UserCreatorChatRoom,
|
||||||
|
val senderParticipant: UserCreatorChatParticipant,
|
||||||
|
val opponentParticipant: UserCreatorChatParticipant
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.usercreatorchat
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
|
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||||
|
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorTextMessageRequest
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatMessageRepository
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatParticipantRepository
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatRoomRepository
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatRealtimeService
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService
|
||||||
|
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.ArgumentCaptor
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
|
import org.springframework.data.domain.PageRequest
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.Optional
|
||||||
|
|
||||||
|
class UserCreatorChatServiceTest {
|
||||||
|
private lateinit var roomRepository: UserCreatorChatRoomRepository
|
||||||
|
private lateinit var participantRepository: UserCreatorChatParticipantRepository
|
||||||
|
private lateinit var messageRepository: UserCreatorChatMessageRepository
|
||||||
|
private lateinit var memberRepository: MemberRepository
|
||||||
|
private lateinit var blockMemberRepository: BlockMemberRepository
|
||||||
|
private lateinit var realtimeService: UserCreatorChatRealtimeService
|
||||||
|
private lateinit var eventPublisher: ApplicationEventPublisher
|
||||||
|
private lateinit var service: UserCreatorChatService
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
roomRepository = Mockito.mock(UserCreatorChatRoomRepository::class.java)
|
||||||
|
participantRepository = Mockito.mock(UserCreatorChatParticipantRepository::class.java)
|
||||||
|
messageRepository = Mockito.mock(UserCreatorChatMessageRepository::class.java)
|
||||||
|
memberRepository = Mockito.mock(MemberRepository::class.java)
|
||||||
|
blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java)
|
||||||
|
realtimeService = Mockito.mock(UserCreatorChatRealtimeService::class.java)
|
||||||
|
eventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||||
|
|
||||||
|
service = UserCreatorChatService(
|
||||||
|
roomRepository = roomRepository,
|
||||||
|
participantRepository = participantRepository,
|
||||||
|
messageRepository = messageRepository,
|
||||||
|
memberRepository = memberRepository,
|
||||||
|
blockMemberRepository = blockMemberRepository,
|
||||||
|
realtimeService = realtimeService,
|
||||||
|
applicationEventPublisher = eventPublisher,
|
||||||
|
objectMapper = ObjectMapper(),
|
||||||
|
s3Uploader = Mockito.mock(S3Uploader::class.java),
|
||||||
|
bucket = "test-bucket",
|
||||||
|
cloudFrontHost = "https://cdn.test"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("활성 유저-크리에이터 방이 없으면 새 방과 참여자를 생성한다")
|
||||||
|
fun shouldCreateRoomAndParticipantsWhenActiveRoomDoesNotExist() {
|
||||||
|
val user = member(1L, "user")
|
||||||
|
val creator = member(2L, "creator")
|
||||||
|
Mockito.`when`(memberRepository.findById(2L)).thenReturn(Optional.of(creator))
|
||||||
|
Mockito.`when`(roomRepository.findActiveRoomByParticipantMemberIds(1L, 2L)).thenReturn(null)
|
||||||
|
Mockito.`when`(roomRepository.save(Mockito.any(UserCreatorChatRoom::class.java))).thenAnswer { invocation ->
|
||||||
|
(invocation.arguments[0] as UserCreatorChatRoom).apply { id = 10L }
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = service.createOrGetRoom(user, 2L)
|
||||||
|
|
||||||
|
assertEquals(10L, response.roomId)
|
||||||
|
val roomCaptor = ArgumentCaptor.forClass(UserCreatorChatRoom::class.java)
|
||||||
|
Mockito.verify(roomRepository).save(roomCaptor.capture())
|
||||||
|
Mockito.verify(participantRepository, Mockito.times(2)).save(Mockito.any(UserCreatorChatParticipant::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("상대방이 같은 방에 입장 중이면 텍스트 메시지를 실시간 전송하고 푸시를 보내지 않는다")
|
||||||
|
fun shouldSendRealtimeMessageWithoutPushWhenOpponentIsPresent() {
|
||||||
|
val user = member(1L, "user")
|
||||||
|
val creator = member(2L, "creator")
|
||||||
|
val room = room(10L)
|
||||||
|
val senderParticipant = participant(100L, room, user)
|
||||||
|
val recipientParticipant = participant(101L, room, creator)
|
||||||
|
Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room)
|
||||||
|
Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant)
|
||||||
|
Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant)
|
||||||
|
Mockito.`when`(realtimeService.isMemberInRoom(10L, 2L)).thenReturn(true)
|
||||||
|
Mockito.`when`(realtimeService.sendMessage(Mockito.eq(10L), Mockito.eq(2L), anyMessageItem())).thenReturn(true)
|
||||||
|
Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation ->
|
||||||
|
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 200L }
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = service.sendTextMessage(user, 10L, SendUserCreatorTextMessageRequest("hello"))
|
||||||
|
|
||||||
|
assertEquals(200L, response.message.messageId)
|
||||||
|
assertEquals("hello", response.message.textMessage)
|
||||||
|
assertTrue(response.deliveredRealtime)
|
||||||
|
assertFalse(response.pushSent)
|
||||||
|
Mockito.verify(realtimeService).sendMessage(Mockito.eq(10L), Mockito.eq(2L), anyMessageItem())
|
||||||
|
Mockito.verifyNoInteractions(eventPublisher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("상대방이 같은 방에 없으면 텍스트 메시지를 저장하고 푸시 이벤트를 발행한다")
|
||||||
|
fun shouldPublishPushEventWhenOpponentIsNotPresent() {
|
||||||
|
val user = member(1L, "user")
|
||||||
|
val creator = member(2L, "creator")
|
||||||
|
val room = room(10L)
|
||||||
|
val senderParticipant = participant(100L, room, user)
|
||||||
|
val recipientParticipant = participant(101L, room, creator)
|
||||||
|
Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room)
|
||||||
|
Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant)
|
||||||
|
Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant)
|
||||||
|
Mockito.`when`(realtimeService.isMemberInRoom(10L, 2L)).thenReturn(false)
|
||||||
|
Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation ->
|
||||||
|
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 201L }
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = service.sendTextMessage(user, 10L, SendUserCreatorTextMessageRequest("hello"))
|
||||||
|
|
||||||
|
assertFalse(response.deliveredRealtime)
|
||||||
|
assertTrue(response.pushSent)
|
||||||
|
val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java)
|
||||||
|
Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture())
|
||||||
|
assertEquals(FcmEventType.INDIVIDUAL, eventCaptor.value.type)
|
||||||
|
assertEquals(listOf(2L), eventCaptor.value.recipients)
|
||||||
|
assertEquals(201L, eventCaptor.value.messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("상대방이 입장 중이면 실시간 전송 실패 시에도 보완 푸시를 보내지 않는다")
|
||||||
|
fun shouldNotFallbackToPushWhenRealtimeDeliveryFailsForPresentOpponent() {
|
||||||
|
val user = member(1L, "user")
|
||||||
|
val creator = member(2L, "creator")
|
||||||
|
val room = room(10L)
|
||||||
|
val senderParticipant = participant(100L, room, user)
|
||||||
|
val recipientParticipant = participant(101L, room, creator)
|
||||||
|
Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room)
|
||||||
|
Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant)
|
||||||
|
Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant)
|
||||||
|
Mockito.`when`(realtimeService.isMemberInRoom(10L, 2L)).thenReturn(true)
|
||||||
|
Mockito.`when`(realtimeService.sendMessage(Mockito.eq(10L), Mockito.eq(2L), anyMessageItem()))
|
||||||
|
.thenReturn(false)
|
||||||
|
Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation ->
|
||||||
|
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 202L }
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = service.sendTextMessage(user, 10L, SendUserCreatorTextMessageRequest("hello"))
|
||||||
|
|
||||||
|
assertFalse(response.deliveredRealtime)
|
||||||
|
assertFalse(response.pushSent)
|
||||||
|
Mockito.verifyNoInteractions(eventPublisher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("커서가 있으면 기본 20개 기준으로 이전 메시지를 조회한다")
|
||||||
|
fun shouldGetPreviousMessagesWithDefaultLimitWhenCursorExists() {
|
||||||
|
val user = member(1L, "user")
|
||||||
|
val creator = member(2L, "creator")
|
||||||
|
val room = room(10L)
|
||||||
|
val userParticipant = participant(100L, room, user)
|
||||||
|
val creatorParticipant = participant(101L, room, creator)
|
||||||
|
val olderMessage = textMessage(
|
||||||
|
id = 298L,
|
||||||
|
room = room,
|
||||||
|
participant = userParticipant,
|
||||||
|
text = "older",
|
||||||
|
createdAt = LocalDateTime.of(2026, 5, 13, 10, 0)
|
||||||
|
)
|
||||||
|
val newerMessage = textMessage(
|
||||||
|
id = 299L,
|
||||||
|
room = room,
|
||||||
|
participant = creatorParticipant,
|
||||||
|
text = "newer",
|
||||||
|
createdAt = LocalDateTime.of(2026, 5, 13, 10, 1)
|
||||||
|
)
|
||||||
|
Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room)
|
||||||
|
Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(userParticipant)
|
||||||
|
Mockito.`when`(
|
||||||
|
messageRepository.findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(
|
||||||
|
room,
|
||||||
|
300L,
|
||||||
|
PageRequest.of(0, 20)
|
||||||
|
)
|
||||||
|
).thenReturn(listOf(newerMessage, olderMessage))
|
||||||
|
Mockito.`when`(messageRepository.existsByChatRoomAndIsActiveTrueAndIdLessThan(room, 298L)).thenReturn(true)
|
||||||
|
|
||||||
|
val response = service.getMessages(user, roomId = 10L, cursor = 300L)
|
||||||
|
|
||||||
|
assertEquals(listOf(298L, 299L), response.messages.map { it.messageId })
|
||||||
|
assertEquals(listOf("older", "newer"), response.messages.map { it.textMessage })
|
||||||
|
assertTrue(response.hasMore)
|
||||||
|
assertEquals(298L, response.nextCursor)
|
||||||
|
Mockito.verify(messageRepository).findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(
|
||||||
|
room,
|
||||||
|
300L,
|
||||||
|
PageRequest.of(0, 20)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("실시간 연결 해제는 참여자를 제거하지 않고 presence만 해제한다")
|
||||||
|
fun shouldDisconnectRealtimeWithoutLeavingRoom() {
|
||||||
|
val user = member(1L, "user")
|
||||||
|
val room = room(10L)
|
||||||
|
val participant = participant(100L, room, user)
|
||||||
|
Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(participant)
|
||||||
|
|
||||||
|
service.disconnectRealtime(user, 10L)
|
||||||
|
|
||||||
|
Mockito.verify(realtimeService).disconnect(10L, 1L)
|
||||||
|
Mockito.verify(participantRepository, Mockito.never()).save(Mockito.any(UserCreatorChatParticipant::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun member(id: Long, nickname: String) = Member(password = "pw", nickname = nickname).apply { this.id = id }
|
||||||
|
|
||||||
|
private fun anyMessageItem(): UserCreatorChatMessageItemDto {
|
||||||
|
return Mockito.any(UserCreatorChatMessageItemDto::class.java) ?: UserCreatorChatMessageItemDto(
|
||||||
|
messageId = 0L,
|
||||||
|
messageType = "TEXT",
|
||||||
|
mine = false,
|
||||||
|
createdAt = 0L,
|
||||||
|
textMessage = null,
|
||||||
|
voiceMessageUrl = null,
|
||||||
|
senderId = 0L,
|
||||||
|
senderNickname = "",
|
||||||
|
senderProfileImageUrl = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun room(id: Long) = UserCreatorChatRoom().apply {
|
||||||
|
this.id = id
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun participant(
|
||||||
|
id: Long,
|
||||||
|
room: UserCreatorChatRoom,
|
||||||
|
member: Member
|
||||||
|
) = UserCreatorChatParticipant(chatRoom = room, member = member).apply { this.id = id }
|
||||||
|
|
||||||
|
private fun textMessage(
|
||||||
|
id: Long,
|
||||||
|
room: UserCreatorChatRoom,
|
||||||
|
participant: UserCreatorChatParticipant,
|
||||||
|
text: String,
|
||||||
|
createdAt: LocalDateTime
|
||||||
|
) = UserCreatorChatMessage(
|
||||||
|
chatRoom = room,
|
||||||
|
participant = participant,
|
||||||
|
messageType = UserCreatorChatMessageType.TEXT,
|
||||||
|
textMessage = text
|
||||||
|
).apply {
|
||||||
|
this.id = id
|
||||||
|
this.createdAt = createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user