Compare commits
32 Commits
36bd5365e0
...
74c112f128
| Author | SHA1 | Date | |
|---|---|---|---|
| 74c112f128 | |||
| 6c252ee008 | |||
| 07b93f3219 | |||
| be6f324fb1 | |||
| 341020788b | |||
| fe8bf73e6e | |||
| 5d18f478ab | |||
| 8b80ca6344 | |||
| 7f13cccde0 | |||
| 0811f92bf5 | |||
| 84e9c18ae1 | |||
| 8fa8d12667 | |||
| 6949d3e482 | |||
| 9e58131167 | |||
| 54c9a7d5a5 | |||
| b7c1bb8c20 | |||
| 743020d6bf | |||
| 562a4b2077 | |||
| 7080a03166 | |||
| 2d13f8dee7 | |||
| 282bc078e5 | |||
| f44ea58ca2 | |||
| 216850c07a | |||
| afa57b70de | |||
| af1e9b565a | |||
| fefd62c63a | |||
| d506ad9c39 | |||
| a170c82a92 | |||
| 5cab3558c0 | |||
| a81987c3f7 | |||
| 3af958fdcb | |||
| 245bae8600 |
@@ -32,6 +32,7 @@ dependencies {
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-redis")
|
||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||
implementation("org.springframework.boot:spring-boot-starter-websocket")
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||
implementation("org.springframework.retry:spring-retry")
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||
|
||||
864
docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md
Normal file
864
docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md
Normal file
@@ -0,0 +1,864 @@
|
||||
# 유저-크리에이터 채팅 WebSocket 전환 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
||||
|
||||
**Goal:** 유저-크리에이터 1:1 채팅의 SSE 실시간 연결을 제거하고, 채팅방 화면 진입 중에만 유지되는 raw WebSocket + Redis presence/pub-sub 구조로 전환한다.
|
||||
|
||||
**Architecture:** REST는 방 생성, 방 열기, 과거 메시지 조회, 음성 업로드에 유지하고 텍스트 실시간 송수신과 방 presence는 WebSocket으로 처리한다. 서버는 다중 인스턴스를 전제로 local WebSocket session registry와 Redis presence/pub-sub을 함께 사용한다. 상대방이 해당 `roomId`에 접속 중이면 WebSocket으로만 전달하고, 접속 중이 아니면 기존 FCM/APNs 푸시 흐름에 `deep_link` 이동 정보만 포함해 발송한다.
|
||||
|
||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring WebSocket, Spring Data Redis, Redis pub/sub, Spring Data JPA, JUnit 5, Mockito, Gradle Wrapper
|
||||
|
||||
---
|
||||
|
||||
## 0. 구현 전 확정 사항
|
||||
|
||||
- 대상 PRD: `docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md`
|
||||
- WebSocket endpoint: `/ws/v2/user-creator-chat`
|
||||
- WebSocket protocol: STOMP 없는 raw JSON envelope
|
||||
- WebSocket 연결 수명: 채팅방 화면 진입 중에만 유지
|
||||
- 서버 인스턴스 전제: 여러 대
|
||||
- presence 저장소: Redis
|
||||
- 서버 간 메시지 전달: Redis pub/sub
|
||||
- local memory에는 현재 서버에 붙은 WebSocket session만 저장
|
||||
- 기존 SSE는 완전히 제거
|
||||
- 기존 REST 유지:
|
||||
- `POST /api/v2/user-creator-chat/rooms/create`
|
||||
- `GET /api/v2/user-creator-chat/rooms/{roomId}/open`
|
||||
- `GET /api/v2/user-creator-chat/rooms/{roomId}/messages`
|
||||
- `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voice`
|
||||
- 텍스트 메시지 전송은 WebSocket `SEND_TEXT`로 전환
|
||||
- 푸시 발송 기준:
|
||||
- 상대방이 같은 `roomId`에 WebSocket presence 있음: 푸시 미발송
|
||||
- 상대방이 같은 `roomId`에 WebSocket presence 없음: 푸시 발송
|
||||
- 푸시 payload 필수값:
|
||||
- `deep_link`: 운영 `voiceon://chat/{roomId}`, 개발/테스트 `voiceon-test://chat/{roomId}`
|
||||
- v2 채팅 푸시에서는 `room_id`, `message_id`, `chat_type` data payload를 사용하지 않는다.
|
||||
- Redis key 기본안:
|
||||
- `v2:user-creator-chat:ws:presence:{roomId}:{memberId}:{sessionId}`
|
||||
- `v2:user-creator-chat:ws:room:{roomId}:member:{memberId}:sessions`
|
||||
- `v2:user-creator-chat:ws:room:{roomId}`
|
||||
- presence TTL 기본값: 90초
|
||||
|
||||
---
|
||||
|
||||
## 1. 파일 구조 계획
|
||||
|
||||
### 의존성/설정
|
||||
- Modify: `build.gradle.kts`
|
||||
- `spring-boot-starter-websocket` 의존성을 추가한다.
|
||||
- Modify: `src/main/resources/application.yml`
|
||||
- lazy loading 의존 API 점검과 수정 후 `spring.jpa.open-in-view=false`를 명시한다.
|
||||
- Modify: `src/test/resources/application.yml`
|
||||
- 테스트에서 OSIV off 회귀를 확인할 수 있도록 동일 설정을 검토한다.
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketConfig.kt`
|
||||
- WebSocket handler endpoint를 등록한다.
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptor.kt`
|
||||
- handshake에서 JWT 인증 정보를 추출한다.
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptorTest.kt`
|
||||
|
||||
### WebSocket protocol
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketMessage.kt`
|
||||
- request/response envelope와 message type enum을 둔다.
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketMessageTest.kt`
|
||||
|
||||
### WebSocket session/presence
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketSessionRegistry.kt`
|
||||
- local WebSocket session을 sessionId 기준으로 관리한다.
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatPresenceService.kt`
|
||||
- Redis presence 등록, 갱신, 제거, 조회를 담당한다.
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBroker.kt`
|
||||
- Redis pub/sub publish/subscribe와 local session 전송을 담당한다.
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketSessionRegistryTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatPresenceServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBrokerTest.kt`
|
||||
|
||||
### WebSocket handler/application
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt`
|
||||
- WebSocket lifecycle과 JSON envelope dispatch를 담당한다.
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`
|
||||
- WebSocket 텍스트 메시지 저장/전달용 application method를 추가하고 SSE 의존성을 제거한다.
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandlerTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandshakeIntegrationTest.kt`
|
||||
|
||||
### 푸시 payload
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt`
|
||||
- 필요 시 v2 채팅용 deep link 값을 추가한다.
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt`
|
||||
- v2 채팅 푸시는 `deep_link`만 data payload에 포함하고 `room_id`, `message_id`, `chat_type`은 제외한다.
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/fcm/FcmServiceTest.kt`
|
||||
|
||||
### SSE 제거
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt`
|
||||
- `events`, `events/disconnect`, `messages/text` endpoint 제거 또는 WebSocket 전환에 맞춰 제거한다.
|
||||
- Delete: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatRealtimeService.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt`
|
||||
- Modify: `docs/plan-task/20260513_유저크리에이터채팅방개편.md`
|
||||
- 클라이언트 연동 문서의 SSE 안내를 WebSocket 기준으로 갱신한다.
|
||||
|
||||
### 클라이언트 반영 문서
|
||||
- Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md`
|
||||
- iOS/Android 앱 변경 사항을 PRD에 유지한다.
|
||||
- Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md`
|
||||
- 서버 구현 task와 별도로 앱 반영 체크리스트를 유지한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. WebSocket 메시지 계약 초안
|
||||
|
||||
구현 전 클라이언트와 공유할 JSON envelope 기준이다. 필드명 변경이 필요하면 PRD와 이 계획 문서를 먼저 갱신한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "SEND_TEXT",
|
||||
"requestId": "c7f1a263-0e4f-4f98-9bd4-70988575e9d7",
|
||||
"roomId": 10,
|
||||
"payload": {
|
||||
"textMessage": "hello"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
서버 응답 예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "MESSAGE",
|
||||
"requestId": null,
|
||||
"roomId": 10,
|
||||
"payload": {
|
||||
"messageId": 200,
|
||||
"messageType": "TEXT",
|
||||
"mine": false,
|
||||
"createdAt": 1781690400000,
|
||||
"textMessage": "hello",
|
||||
"voiceMessageUrl": null,
|
||||
"senderId": 2,
|
||||
"senderNickname": "creator",
|
||||
"senderProfileImageUrl": "https://cdn.test/profile/creator.png"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
서버 ack 예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "SEND_ACK",
|
||||
"requestId": "c7f1a263-0e4f-4f98-9bd4-70988575e9d7",
|
||||
"roomId": 10,
|
||||
"payload": {
|
||||
"messageId": 201,
|
||||
"messageType": "TEXT",
|
||||
"mine": true,
|
||||
"createdAt": 1781690401000,
|
||||
"textMessage": "hello",
|
||||
"voiceMessageUrl": null,
|
||||
"senderId": 1,
|
||||
"senderNickname": "user",
|
||||
"senderProfileImageUrl": "https://cdn.test/profile/user.png"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. iOS/Android 클라이언트 변경 사항
|
||||
|
||||
앱은 서버 배포와 같은 릴리스 범위에서 아래 변경을 반영해야 한다. 서버 구현자가 직접 앱 코드를 수정하지 않더라도, API 계약과 검증 기준은 이 문서에 유지한다.
|
||||
|
||||
### 채팅방 진입
|
||||
- 기존 SSE 연결 생성 코드를 제거한다.
|
||||
- 채팅방 화면 진입 시 `GET /api/v2/user-creator-chat/rooms/{roomId}/open`을 먼저 호출한다.
|
||||
- `openRoom` 응답으로 기존 메시지 목록과 상대방 프로필/닉네임을 렌더링한다.
|
||||
- 이후 WebSocket `/ws/v2/user-creator-chat`에 연결한다.
|
||||
- handshake에는 `Authorization: Bearer <accessToken>` 헤더를 포함한다.
|
||||
- 연결 직후 아래 메시지를 보낸다.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "JOIN_ROOM",
|
||||
"requestId": "client-request-id",
|
||||
"roomId": 10,
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
- `JOINED` 수신 전에는 텍스트 전송 버튼을 비활성화하거나 전송 대기 상태로 처리한다.
|
||||
|
||||
### 텍스트 메시지 전송
|
||||
- 기존 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 호출을 제거한다.
|
||||
- 텍스트 메시지는 아래 WebSocket 메시지로 전송한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "SEND_TEXT",
|
||||
"requestId": "client-request-id",
|
||||
"roomId": 10,
|
||||
"payload": {
|
||||
"textMessage": "hello"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 앱은 `requestId`를 pending 메시지와 매칭한다.
|
||||
- `SEND_ACK`를 수신하면 pending 메시지를 서버 응답의 `messageId`, `createdAt`, `senderProfileImageUrl` 기준으로 확정한다.
|
||||
- `ERROR` 또는 timeout이 발생하면 메시지를 실패 상태로 표시하고 재시도 UI를 제공한다.
|
||||
|
||||
### 메시지 수신
|
||||
- `MESSAGE` 이벤트 수신 시 현재 열려 있는 `roomId`와 일치하는지 확인한 뒤 메시지 목록에 append한다.
|
||||
- 현재 채팅방과 다른 `roomId`의 `MESSAGE`를 받으면 버리거나 로그로 남긴다. 이번 서버 설계에서는 같은 WebSocket session이 하나의 `roomId`만 활성 방으로 가지므로 정상 상황에서는 발생하지 않아야 한다.
|
||||
|
||||
### 화면 이탈과 재연결
|
||||
- 채팅방 화면 이탈, 앱 백그라운드 진입, 로그아웃 시 아래 메시지를 보낸 뒤 WebSocket을 close한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "LEAVE_ROOM",
|
||||
"requestId": "client-request-id",
|
||||
"roomId": 10,
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
- 현재 채팅방 화면에 남아 있는 동안 WebSocket이 끊기면 지수 백오프로 재연결한다.
|
||||
- 재연결 성공 후 `JOIN_ROOM`을 다시 보내고, 필요하면 `GET /api/v2/user-creator-chat/rooms/{roomId}/messages`로 누락 메시지를 동기화한다.
|
||||
- access token이 refresh되면 기존 WebSocket을 닫고 새 token으로 다시 연결한다.
|
||||
|
||||
### 푸시 이동
|
||||
- 푸시 payload의 `deep_link`를 확인한다.
|
||||
- 사용자가 푸시를 터치하면 `voiceon://chat/{roomId}` 또는 `voiceon-test://chat/{roomId}`의 `{roomId}`에 해당하는 채팅방 화면으로 이동한다.
|
||||
- 푸시 진입 후에도 일반 진입과 동일하게 `openRoom` 호출 후 WebSocket 연결과 `JOIN_ROOM`을 수행한다.
|
||||
- `deep_link`가 없거나 채팅방 room id를 해석할 수 없으면 채팅방 직접 이동을 하지 않고 앱 기본 알림 처리로 fallback 한다.
|
||||
|
||||
### 클라이언트 제거 대상
|
||||
- `GET /api/v2/user-creator-chat/rooms/{roomId}/events` 호출
|
||||
- `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect` 호출
|
||||
- `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 호출
|
||||
- SSE reconnect/retry 처리 코드
|
||||
|
||||
---
|
||||
|
||||
### Phase 0: OSIV 비활성화 사전 점검
|
||||
|
||||
- [x] **Task 0.1: 현재 OSIV 설정과 위험 패턴 조사**
|
||||
- Files:
|
||||
- Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md`
|
||||
- TDD 예외 사유: 코드 변경 전 사전 조사 task로, 자동화 테스트보다 정적 검색과 결과 문서화가 목적이다.
|
||||
- 대체 검증 방법:
|
||||
- Run: `rg -n "open-in-view|spring.jpa" src/main/resources src/test/resources`
|
||||
- Expected: 현재 OSIV 명시 여부를 확인한다.
|
||||
- Run: `rg -n "member\\?\\.auth|member\\.auth|member\\?\\.notification|member\\.notification|ApiResponse\\.ok\\([^\\n]*(repository|findBy|findAll)|ResponseEntity\\.ok\\([^\\n]*(repository|findBy|findAll)" src/main/kotlin/kr/co/vividnext/sodalive`
|
||||
- Expected: controller 또는 응답 직렬화 단계에서 lazy loading이 일어날 수 있는 후보를 찾는다.
|
||||
- Run: `rg -n "@Service|@Transactional|findByIdOrNull|findById\\(|findAll\\(|\\.member|\\.sender|\\.recipient|\\.auth|\\.chatRoom|\\.series|\\.audioContent" src/main/kotlin/kr/co/vividnext/sodalive`
|
||||
- Expected: 트랜잭션 없는 service/facade/query method에서 LAZY 연관을 접근하는 후보를 찾는다.
|
||||
- GREEN: 발견 항목을 이 문서의 `OSIV 점검 기록` 섹션에 API/파일/위험/수정 방향 형식으로 기록한다.
|
||||
- 통과 확인:
|
||||
- Run: `rg -n "OSIV 점검 기록|lazy loading|open-in-view" docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md`
|
||||
- Expected: 조사 결과와 후속 조치가 문서에 기록되어야 한다.
|
||||
- 검증 기록:
|
||||
- 무엇: 현재 OSIV 명시 여부와 lazy loading 위험 후보를 정적 검색으로 조사했다.
|
||||
- 왜: `spring.jpa.open-in-view=false` 전환 전에 controller 응답 직렬화나 인증 principal 접근에서 트랜잭션 밖 lazy 접근 가능성을 확인하기 위해서다.
|
||||
- 어떻게: `rg -n "open-in-view|spring.jpa" src/main/resources src/test/resources`, `rg -n "member\\?\\.auth|member\\.auth|member\\?\\.notification|member\\.notification|ApiResponse\\.ok\\([^\\n]*(repository|findBy|findAll)|ResponseEntity\\.ok\\([^\\n]*(repository|findBy|findAll)" src/main/kotlin/kr/co/vividnext/sodalive`, `rg -n "@Service|@Transactional|findByIdOrNull|findById\\(|findAll\\(|\\.member|\\.sender|\\.recipient|\\.auth|\\.chatRoom|\\.series|\\.audioContent" src/main/kotlin/kr/co/vividnext/sodalive`를 실행했다.
|
||||
- 결과: main/test `application.yml` 모두 `spring.jpa`는 있으나 `open-in-view`는 명시되어 있지 않았다. `EventController`, `UserActionController`, `AuditionController`, `CreatorAdminContentSeriesGenreController`, `AudioContentCommentController` 등에서 `MemberAdapter.member.auth` 직접 접근 후보가 확인되었다. QueryDSL repository의 `member.auth`, `series.member`, `audioContent.member` 접근은 쿼리 식 내부 경로가 대부분이라 즉시 lazy 직렬화 위험으로 분류하지 않았다.
|
||||
|
||||
- [x] **Task 0.2: OSIV off 테스트 실행 범위 선정**
|
||||
- Files:
|
||||
- Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md`
|
||||
- TDD 예외 사유: 구현 전 테스트 전략 정의 task다.
|
||||
- 대체 검증 방법:
|
||||
- Run: `find src/test/kotlin/kr/co/vividnext/sodalive -name '*ControllerTest.kt' | sort`
|
||||
- Expected: OSIV off 영향이 드러날 가능성이 높은 controller 테스트 목록을 선별한다.
|
||||
- GREEN: 인증 principal의 lazy 접근, entity 직접 반환, controller DTO 변환이 있는 API를 우선순위로 선정한다.
|
||||
- 통과 확인:
|
||||
- Run: `rg -n "OSIV off 우선 테스트|ControllerTest|MockMvc" docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md`
|
||||
- Expected: 우선 실행할 테스트 목록 또는 기준이 문서에 기록되어야 한다.
|
||||
- OSIV off 우선 테스트:
|
||||
- `kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`: `@SpringBootTest`, `@AutoConfigureMockMvc`, `@Transactional`, `MemberAdapter`를 함께 사용해 실제 JPA/MockMvc 경계와 인증 principal 전달 표면을 확인할 수 있다.
|
||||
- `kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest`: `@SpringBootTest`, `@AutoConfigureMockMvc`, `@Transactional`, `MemberAdapter`를 함께 사용해 인증 회원 id 기반 조회 표면을 확인할 수 있다.
|
||||
- `kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest`: `@WebMvcTest`라 JPA lazy loading 자체 검증은 제한적이지만 controller가 `MemberAdapter.member`를 facade로 전달하는 표면 회귀를 확인할 수 있다.
|
||||
- `kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest`: `@WebMvcTest`라 JPA lazy loading 자체 검증은 제한적이지만 인증 principal 전달 표면 회귀를 확인할 수 있다.
|
||||
- `kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest`, `kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`: 현재 유저-크리에이터 채팅 전용 `ControllerTest`가 없어, service 트랜잭션 안 DTO 변환과 lazy 접근 안전성을 보조 확인한다.
|
||||
- 검증 기록:
|
||||
- 무엇: OSIV off 영향이 드러날 가능성이 높은 controller/service 테스트 범위를 선정했다.
|
||||
- 왜: Phase 0.3에서 무작위 전체 테스트가 아니라 인증 principal lazy 접근, entity 직접 반환, DTO 변환 경계가 있는 테스트를 우선 실행하기 위해서다.
|
||||
- 어떻게: `rg --files src/test/kotlin/kr/co/vividnext/sodalive | rg 'ControllerTest\\.kt$'`, `rg -n "@SpringBootTest|@AutoConfigureMockMvc|@Transactional|MockMvc|MemberAdapter" src/test/kotlin/kr/co/vividnext/sodalive -g "*ControllerTest.kt"`, `rg --files src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat`를 실행했다.
|
||||
- 결과: `HomeRecommendationControllerTest`, `CreatorRankingControllerTest`를 우선 통합 테스트로 선정하고, `CreatorChannelHomeControllerTest`, `CreatorChannelLiveControllerTest`, user-creator-chat service 테스트를 보조 범위로 선정했다.
|
||||
|
||||
- [x] **Task 0.3: OSIV off로 후보 테스트를 실행하고 실패를 분류**
|
||||
- Files:
|
||||
- Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md`
|
||||
- TDD 예외 사유: 설정 전환 영향 조사 task다.
|
||||
- 대체 검증 방법:
|
||||
- Run: `./gradlew test -Dspring.jpa.open-in-view=false --tests '<선정한 ControllerTest 클래스명>'`
|
||||
- Expected: 성공하면 해당 API는 우선 위험 낮음으로 기록한다. `LazyInitializationException`이 발생하면 파일/필드/API를 기록한다.
|
||||
- GREEN: 실패를 다음 유형으로 분류한다.
|
||||
- controller에서 인증 principal lazy 연관 접근
|
||||
- 응답 직렬화 중 entity lazy 연관 접근
|
||||
- service/facade 트랜잭션 밖 DTO 변환 중 lazy 연관 접근
|
||||
- 테스트 fixture가 `@Transactional`로 문제를 숨기는 경우
|
||||
- 통과 확인:
|
||||
- Run: `rg -n "LazyInitializationException|OSIV off 실패|OSIV off 성공" docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md`
|
||||
- Expected: 테스트 결과와 분류가 문서에 기록되어야 한다.
|
||||
- OSIV off 성공:
|
||||
- Run: `./gradlew --no-daemon cleanTest test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests '*CreatorChannelHomeControllerTest' --tests '*CreatorChannelLiveControllerTest' --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest`
|
||||
- Result: `BUILD SUCCESSFUL in 1m 24s`
|
||||
- XML 확인: `UserCreatorChatServiceIntegrationTest` 2개, `UserCreatorChatServiceTest` 8개, `CreatorChannelHomeControllerTest` 2개, `CreatorChannelLiveControllerTest` 5개, `HomeRecommendationControllerTest` 21개, `CreatorRankingControllerTest` 3개 모두 `failures=0`, `errors=0`.
|
||||
- 분류: 선정한 user-creator-chat service/integration, 홈/랭킹 controller 통합 테스트, 보조 WebMvc controller 테스트 범위에서는 확인된 `LazyInitializationException` 없음. user-creator-chat DTO 변환은 현재 service 트랜잭션 안에서 수행되는 것으로 판단했다.
|
||||
|
||||
- [x] **Task 0.4: lazy loading 의존 제거 후 OSIV off 명시**
|
||||
- Files:
|
||||
- Modify: `src/main/resources/application.yml`
|
||||
- Modify: `src/test/resources/application.yml`
|
||||
- Modify: lazy loading 의존 API별 service/repository/controller/test 파일
|
||||
- Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md`
|
||||
- RED: Task 0.3에서 확인한 `LazyInitializationException` 재현 테스트를 먼저 고정한다.
|
||||
- 실패 확인:
|
||||
- Run: `./gradlew test -Dspring.jpa.open-in-view=false --tests '<실패 재현 테스트>'`
|
||||
- Expected: 기존 코드에서는 `LazyInitializationException` 또는 동등한 실패가 발생한다.
|
||||
- GREEN: controller lazy 접근을 service/query 계층의 트랜잭션 안 DTO projection, fetch join, 명시 조회로 이동한다.
|
||||
- GREEN: `application.yml`과 필요한 경우 `test application.yml`에 아래 설정을 명시한다.
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
jpa:
|
||||
open-in-view: false
|
||||
```
|
||||
|
||||
- 통과 확인:
|
||||
- Run: `./gradlew test -Dspring.jpa.open-in-view=false --tests '<수정한 테스트>'`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- REFACTOR: WebSocket 전환 작업과 관계없는 API 스키마 변경은 하지 않는다.
|
||||
- 검증 기록:
|
||||
- 무엇: main/test 설정에 `spring.jpa.open-in-view=false`를 명시했다.
|
||||
- 왜: Phase 0.3에서 user-creator-chat service/integration, 홈/랭킹 controller 통합 테스트, 보조 WebMvc controller 테스트 범위에 `LazyInitializationException`이 없었고, OSIV 정책을 명시해야 이후 WebSocket 전환 작업의 트랜잭션 경계를 안전하게 유지할 수 있기 때문이다.
|
||||
- 어떻게: `src/main/resources/application.yml`, `src/test/resources/application.yml`의 `spring.jpa` 아래에 `open-in-view: false`를 추가했다.
|
||||
- 결과: 확인된 lazy loading 재현 실패가 없어 production code의 service/repository/controller 수정은 하지 않았다. 공개 API 스키마 변경도 없다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: WebSocket 의존성과 인증 handshake 기반 추가
|
||||
|
||||
- [x] **Task 1.1: WebSocket 의존성 추가**
|
||||
- Files:
|
||||
- Modify: `build.gradle.kts`
|
||||
- RED: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketConfigTest.kt`를 추가해 `/ws/v2/user-creator-chat` handler bean 등록을 기대한다.
|
||||
- 실패 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketConfigTest`
|
||||
- Expected: WebSocket 관련 타입 또는 config bean 부재로 실패한다.
|
||||
- GREEN: `implementation("org.springframework.boot:spring-boot-starter-websocket")`를 추가한다.
|
||||
- 통과 확인:
|
||||
- Run: `./gradlew tasks --all`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- REFACTOR: 의존성 추가 외 다른 dependency 정렬/버전 변경은 하지 않는다.
|
||||
- 검증 기록:
|
||||
- 무엇: `spring-boot-starter-websocket` 의존성을 추가했다.
|
||||
- 왜: raw WebSocket endpoint와 handshake interceptor 타입을 사용하기 위해서다.
|
||||
- 어떻게: WebSocket 타입 부재로 `compileTestKotlin` RED를 확인한 뒤 의존성을 추가하고 Phase 1 focused 테스트를 재실행했다.
|
||||
- 결과: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketConfigTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketAuthInterceptorTest`가 `BUILD SUCCESSFUL in 1m 11s`로 통과했다.
|
||||
|
||||
- [x] **Task 1.2: WebSocket config와 handler 등록**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketConfig.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketConfigTest.kt`
|
||||
- RED: config 테스트에서 handler가 `/ws/v2/user-creator-chat` 경로로 등록되는지 검증한다.
|
||||
- 실패 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketConfigTest`
|
||||
- Expected: config class 부재로 실패한다.
|
||||
- GREEN: `WebSocketConfigurer`를 구현하고 `TextWebSocketHandler` 기반 handler를 등록한다.
|
||||
- 통과 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketConfigTest`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- REFACTOR: CORS origin은 기존 `WebConfig`의 허용 origin 정책과 어긋나지 않게 제한한다.
|
||||
- 검증 기록:
|
||||
- 무엇: `/ws/v2/user-creator-chat` endpoint를 `WebSocketConfigurer`로 등록하고 `TextWebSocketHandler` 기반 handler를 추가했다.
|
||||
- 왜: 채팅방 화면 진입 중 유지되는 raw WebSocket 연결 기반을 만들기 위해서다.
|
||||
- 어떻게: `UserCreatorChatWebSocketConfigTest`에서 endpoint path 등록을 검증했다.
|
||||
- 결과: Phase 1 focused 테스트가 `BUILD SUCCESSFUL`로 통과했다. allowed origin은 기존 `WebConfig` origin 목록과 동일하게 제한했다.
|
||||
|
||||
- [x] **Task 1.3: WebSocket handshake JWT 인증 추가**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptor.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptorTest.kt`
|
||||
- RED: `Authorization: Bearer <token>` 헤더가 없거나 유효하지 않으면 handshake가 실패하는 테스트를 작성한다.
|
||||
- RED: 유효한 토큰이면 attributes에 `memberId`와 인증 principal이 저장되는 테스트를 작성한다.
|
||||
- 실패 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketAuthInterceptorTest`
|
||||
- Expected: interceptor class 부재로 실패한다.
|
||||
- GREEN: 기존 `TokenProvider.getAuthentication(token)`을 사용해 인증하고, `MemberAdapter.member.id`를 session attributes에 저장한다.
|
||||
- 통과 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketAuthInterceptorTest`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- REFACTOR: JWT parsing 로직을 새로 만들지 않고 기존 `TokenProvider`를 재사용한다.
|
||||
- 검증 기록:
|
||||
- 무엇: `Authorization: Bearer <token>` handshake 인증 interceptor를 추가했다.
|
||||
- 왜: WebSocket session attributes에 인증 member id와 authentication을 저장해 이후 room join/message 처리에서 사용할 수 있게 하기 위해서다.
|
||||
- 어떻게: `TokenProvider.validateToken(token)`과 `TokenProvider.getAuthentication(token)`을 재사용하고, `MemberAdapter.member.id`를 `memberId` attribute로 저장했다.
|
||||
- 결과: 유효 token 성공, Authorization header 누락 실패, invalid token 실패 테스트가 Phase 1 focused 테스트에서 통과했다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 메시지 프로토콜과 local session registry 추가
|
||||
|
||||
- [x] **Task 2.1: WebSocket message envelope 정의**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketMessage.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketMessageTest.kt`
|
||||
- RED: `JOIN_ROOM`, `SEND_TEXT`, `LEAVE_ROOM`, `PING`, `JOINED`, `MESSAGE`, `SEND_ACK`, `ERROR`, `PONG` enum 값과 JSON deserialize 테스트를 작성한다.
|
||||
- 실패 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageTest`
|
||||
- Expected: message class 부재로 실패한다.
|
||||
- GREEN: request/response envelope와 type enum을 추가한다.
|
||||
- 통과 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageTest`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- REFACTOR: payload는 초기 구현에서 `JsonNode`로 받고, 타입별 request DTO 변환은 handler dispatch에서 수행한다.
|
||||
- 검증 기록:
|
||||
- 무엇: WebSocket request/response envelope와 message type enum을 추가했다.
|
||||
- 왜: Phase 4 handler dispatch에서 raw JSON 메시지를 공통 계약으로 역직렬화하기 위해서다.
|
||||
- 어떻게: `UserCreatorChatWebSocketMessage`는 `type`, `requestId`, `roomId`, `payload: JsonNode`를 갖는 data class로 두고, `UserCreatorChatWebSocketMessageType`에 `JOIN_ROOM`, `SEND_TEXT`, `LEAVE_ROOM`, `PING`, `JOINED`, `MESSAGE`, `SEND_ACK`, `ERROR`, `PONG`을 정의했다.
|
||||
- RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageTest` 실행 시 `UserCreatorChatWebSocketMessageType`, `UserCreatorChatWebSocketMessage` unresolved reference로 `compileTestKotlin` 실패를 확인했다.
|
||||
- 결과: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketSessionRegistryTest`가 `BUILD SUCCESSFUL in 4m 30s`로 통과했다.
|
||||
|
||||
- [x] **Task 2.2: local WebSocket session registry 추가**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketSessionRegistry.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketSessionRegistryTest.kt`
|
||||
- RED: `roomId/memberId/sessionId` 등록, 조회, 제거, 같은 session의 room 전환 시 기존 room 제거 테스트를 작성한다.
|
||||
- 실패 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketSessionRegistryTest`
|
||||
- Expected: registry class 부재로 실패한다.
|
||||
- GREEN: `ConcurrentHashMap` 기반 registry를 추가한다.
|
||||
- 통과 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketSessionRegistryTest`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- REFACTOR: registry는 local session만 관리하고 Redis를 직접 호출하지 않는다.
|
||||
- 검증 기록:
|
||||
- 무엇: local WebSocket session registry를 추가했다.
|
||||
- 왜: 다중 인스턴스 구조에서 현재 서버에 붙은 session만 roomId/memberId/sessionId 기준으로 관리하기 위해서다.
|
||||
- 어떻게: `ConcurrentHashMap`으로 room/member별 session map과 sessionId index를 관리하고, 같은 session이 다른 room으로 등록되면 기존 room mapping을 제거하도록 했다. 동시 같은 session room 전환은 sessionId hash 기반 고정 striped lock으로 `register`/`remove`를 직렬화했다. Redis 호출은 포함하지 않았다.
|
||||
- RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketSessionRegistryTest` 실행 시 `UserCreatorChatWebSocketSessionRegistry` unresolved reference로 `compileTestKotlin` 실패를 확인했다.
|
||||
- RED: reviewer 지적 후 추가한 동시 같은 session room 전환 테스트가 기존 구현에서 `Expected concurrent same-session room switch to leave exactly one active room mapping` assertion으로 실패함을 확인했다.
|
||||
- RED: 운영 트래픽에서 sessionId별 lock map이 누적될 수 있다는 리뷰를 반영해 추가한 `sessionId별 lock map을 유지하지 않는다` 테스트가 기존 구현에서 실패함을 확인했다.
|
||||
- 결과: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketSessionRegistryTest`가 `BUILD SUCCESSFUL in 3m 41s`로 통과했다.
|
||||
- 결과: 실제 실행한 Phase 2 focused 명령 `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketSessionRegistryTest`가 `BUILD SUCCESSFUL in 1m 46s`로 통과했다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Redis presence와 Redis pub/sub 추가
|
||||
|
||||
- [x] **Task 3.1: Redis presence service 추가**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatPresenceService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatPresenceServiceTest.kt`
|
||||
- RED: `markJoined`, `refresh`, `markLeft`, `hasPresence(roomId, memberId)` 동작을 embedded Redis 또는 mock RedisTemplate으로 검증한다.
|
||||
- RED: TTL이 설정되는지 검증한다.
|
||||
- 실패 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest`
|
||||
- Expected: presence service 부재로 실패한다.
|
||||
- GREEN: Redis key/value와 session set index를 저장하고 TTL 90초를 적용한다.
|
||||
- 통과 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- REFACTOR: key prefix는 companion object 상수로 모으고 기존 SSE presence key와 섞이지 않게 `ws` segment를 포함한다.
|
||||
- 검증 기록:
|
||||
- 무엇: WebSocket session 단위 Redis presence service를 추가했다.
|
||||
- 왜: 다중 서버에서 상대방이 같은 `roomId`에 접속 중인지 판단하고, session별 join/refresh/leave 상태를 TTL 기반으로 관리하기 위해서다.
|
||||
- 어떻게: `StringRedisTemplate`으로 `v2:user-creator-chat:ws:presence:{roomId}:{memberId}:{sessionId}`, `v2:user-creator-chat:ws:room:{roomId}:member:{memberId}:sessions`, `v2:user-creator-chat:ws:room:{roomId}` 키를 저장하고 90초 TTL을 적용했다.
|
||||
- RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest` 실행 시 `UserCreatorChatPresenceService` unresolved reference로 `compileTestKotlin` 실패를 확인했다.
|
||||
- Reviewer RED: 남은 session id가 TTL 만료로 stale 상태일 때 마지막 live session이 leave 해도 member presence가 즉시 제거되지 않는다는 지적을 받고, stale session 테스트가 `ArgumentsAreDifferent`로 실패함을 확인했다.
|
||||
- 결과: stale session id를 presence key 기준으로 정리하고 live session이 없을 때 member/room presence를 제거하도록 수정했다. `UserCreatorChatPresenceServiceTest`가 `BUILD SUCCESSFUL in 1m 22s`로 통과했고, Phase 3 WebSocket focused 테스트가 `BUILD SUCCESSFUL in 31s`로 통과했다.
|
||||
- Reviewer 보강: PRD의 Redis presence value 계약에 맞춰 value를 문자열 `1`에서 `serverId`, `memberId`, `roomId`, `sessionId`, `lastSeenAt` JSON으로 변경했다. `user-creator-chat.websocket.server-id`가 비어 있으면 애플리케이션 시작 시 UUID를 serverId로 사용한다.
|
||||
|
||||
- [x] **Task 3.2: Redis pub/sub room broker 추가**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBroker.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBrokerTest.kt`
|
||||
- RED: publish 시 `v2:user-creator-chat:ws:room:{roomId}` channel로 메시지를 발행하는 테스트를 작성한다.
|
||||
- RED: subscribe callback이 local registry에서 대상 member session만 찾아 전송하는 테스트를 작성한다.
|
||||
- 실패 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest`
|
||||
- Expected: broker class 부재로 실패한다.
|
||||
- GREEN: Redis pub/sub publisher와 listener를 추가한다.
|
||||
- 통과 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- REFACTOR: broker는 DB 저장을 하지 않고 이미 만들어진 message DTO만 전달한다.
|
||||
- 검증 기록:
|
||||
- 무엇: Redis pub/sub room broker와 `RedisMessageListenerContainer` bean을 추가했다.
|
||||
- 왜: 서버 인스턴스 간 room 메시지를 Redis channel로 전달하고, 수신 인스턴스가 local registry에서 대상 member session만 찾아 WebSocket으로 전송하기 위해서다.
|
||||
- 어떻게: publish는 `v2:user-creator-chat:ws:room:{roomId}` channel에 `roomId`, `memberId`, `payload` JSON을 발행하고, listener는 `v2:user-creator-chat:ws:room:*` pattern topic을 구독해 대상 local session에 `TextMessage(payload)`를 전송한다. broker는 DB 저장을 하지 않는다.
|
||||
- RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest` 실행 시 `UserCreatorChatRoomMessageBroker`, `UserCreatorChatRoomPublishedMessage` unresolved reference로 `compileTestKotlin` 실패를 확인했다.
|
||||
- Reviewer RED: 같은 member의 local session 중 하나가 `IOException`으로 실패하면 이후 정상 session 전송이 중단되는 테스트가 기존 구현에서 실패함을 확인했다.
|
||||
- 결과: session별 전송 실패를 격리하고 실패 session만 local registry에서 제거하도록 수정했다. `UserCreatorChatRoomMessageBrokerTest`가 `BUILD SUCCESSFUL in 21s`로 통과했고, Phase 3 WebSocket focused 테스트가 `BUILD SUCCESSFUL in 21s`로 통과했다.
|
||||
|
||||
- [x] **Task 3.3: Redis presence/pub-sub embedded Redis 통합 테스트 추가**
|
||||
- Files:
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRedisIntegrationTest.kt`
|
||||
- RED: `@SpringBootTest`와 `@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])`로 실제 Redis에 `markJoined`를 호출하면 presence key, member session set, room set이 저장되고 presence key TTL이 0보다 큰지 검증한다.
|
||||
- RED: `markLeft` 호출 시 실제 Redis에서 session presence key와 마지막 member session set, room member entry가 정리되는지 검증한다.
|
||||
- RED: stale session id가 member session set에 남아 있고 presence key가 없으면 `hasPresence(roomId, memberId)`가 false를 반환하고 stale session id를 set에서 제거하는지 검증한다.
|
||||
- RED: `UserCreatorChatRoomMessageBroker.publish`가 실제 Redis pub/sub을 통해 listener까지 도달하고, local registry의 대상 member session에 payload를 전달하는지 검증한다.
|
||||
- 실패 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest`
|
||||
- Expected: 통합 테스트 파일 부재 또는 실제 Redis 경계 미검증으로 실패한다.
|
||||
- GREEN: production code 변경 없이 기존 `UserCreatorChatPresenceService`, `UserCreatorChatRoomMessageBroker`, `RedisMessageListenerContainer`가 embedded Redis에서 동작하도록 테스트를 추가한다. 실패가 있으면 Redis serialization/listener wiring에 필요한 최소 수정만 적용한다.
|
||||
- 통과 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- REFACTOR: Redis 통합 테스트는 `src/test/resources/META-INF/spring.factories` 전역 등록 없이 `EmbeddedRedisInitializer`를 명시적으로 opt-in 한다.
|
||||
- 범위 한계:
|
||||
- Phase 3에서는 Redis presence/pub-sub 인프라를 실제 Redis 기준으로 검증한다.
|
||||
- 실제 WebSocket `JOIN_ROOM`, `SEND_TEXT`, `LEAVE_ROOM`, `PING`, 메시지 저장/ack/푸시 분기까지 포함한 end-to-end 흐름은 Phase 4에서 검증한다.
|
||||
- 검증 기록:
|
||||
- 무엇: embedded Redis 기반 `UserCreatorChatRedisIntegrationTest`를 추가했다.
|
||||
- 왜: mock 단위 테스트가 아니라 실제 Redis key/TTL/set 저장, stale session pruning, Redis pub/sub listener 전달 경계를 확인하기 위해서다.
|
||||
- 어떻게: `@SpringBootTest`와 `@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])`로 opt-in embedded Redis를 사용하고, `UserCreatorChatPresenceService`, `UserCreatorChatRoomMessageBroker`, `UserCreatorChatWebSocketSessionRegistry`, `StringRedisTemplate`을 실제 Spring context에서 주입받아 검증했다.
|
||||
- RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest` 실행 시 테스트 파일 부재로 `No tests found for given includes` 실패를 확인했다.
|
||||
- GREEN: 같은 focused 명령을 `cleanTest`와 함께 순차 재실행해 `BUILD SUCCESSFUL in 33s`로 통과했다. join presence key/member session set/room set/TTL, last session leave 정리, stale session pruning, Redis pub/sub listener를 통한 target local session payload 전달을 확인했다.
|
||||
- Reviewer 보강 GREEN: embedded Redis 테스트에서 `user-creator-chat.websocket.server-id=redis-test-server`를 주입하고 실제 Redis presence value JSON의 `serverId`, `memberId`, `roomId`, `sessionId`, `lastSeenAt`과 TTL을 함께 검증하도록 갱신했다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: WebSocket handler와 메시지 저장/전달
|
||||
|
||||
- [x] **Task 4.1: JOIN_ROOM 처리**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandlerTest.kt`
|
||||
- RED: 인증 member가 참여 중인 room에 `JOIN_ROOM`을 보내면 `JOINED` 응답과 local/Redis presence 등록이 수행되는 테스트를 작성한다.
|
||||
- RED: 참여자가 아닌 room이면 `ERROR` 후 close 되는 테스트를 작성한다.
|
||||
- 실패 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest`
|
||||
- Expected: JOIN_ROOM dispatch 부재로 실패한다.
|
||||
- GREEN: handler에서 `JOIN_ROOM`을 처리하고 service의 참여자 검증 method를 호출한다.
|
||||
- 통과 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- REFACTOR: 기존 private `requireParticipant` 재사용이 필요하면 public/internal 검증 method로 최소 노출한다.
|
||||
- 검증 기록:
|
||||
- 무엇: WebSocket handler의 `JOIN_ROOM` dispatch, service 참여자 검증 method, local session registry 등록, Redis presence 등록, `JOINED`/`ERROR` envelope 응답을 추가했다.
|
||||
- 왜: 채팅방 화면 진입 시 인증 member가 해당 room 참여자인지 확인한 뒤 현재 서버 session과 Redis presence를 등록해야 하기 때문이다.
|
||||
- RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest` 실행 시 `validateParticipant`, handler constructor dependency, dispatch 부재로 `compileTestKotlin` 실패를 확인했다.
|
||||
- GREEN: 같은 focused 명령 재실행 결과 `BUILD SUCCESSFUL in 1m 1s`로 통과했다.
|
||||
- 인접 검증: `UserCreatorChatWebSocketConfigTest`, `UserCreatorChatPresenceServiceTest`, `UserCreatorChatRoomMessageBrokerTest` 포함 명령이 `BUILD SUCCESSFUL in 1m`로 통과했고, 이후 focused+인접 통합 명령이 `BUILD SUCCESSFUL in 3m 28s`로 통과했다.
|
||||
- Reviewer 보강 RED: 같은 session이 다른 room으로 다시 `JOIN_ROOM`할 때 기존 Redis presence가 제거되지 않고, WebSocket close 시 local session/Redis presence가 정리되지 않는 테스트가 기존 구현에서 실패함을 확인했다.
|
||||
- Reviewer 보강 GREEN: session attribute에 joined room을 저장하고, 재JOIN/close 시 `presenceService.markLeft`와 `sessionRegistry.remove`를 호출하도록 수정했다. `UserCreatorChatWebSocketHandlerTest`가 `BUILD SUCCESSFUL in 1m`로 통과했고, focused+인접 WebSocket 테스트 묶음이 `BUILD SUCCESSFUL in 15s`로 통과했다.
|
||||
|
||||
- [x] **Task 4.2: SEND_TEXT 저장, sender ack, 수신자 WebSocket 전달**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandlerTest.kt`
|
||||
- RED: 상대방 presence가 있으면 메시지를 저장하고 `broker.publish`를 호출하며 푸시 이벤트를 발행하지 않는 테스트를 작성한다.
|
||||
- RED: sender에게 `SEND_ACK`가 전송되는 handler 테스트를 작성한다.
|
||||
- 실패 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest`
|
||||
- Expected: WebSocket send method 부재로 실패한다.
|
||||
- GREEN: `sendTextMessage`의 저장 로직을 재사용하되 `UserCreatorChatPresenceService`와 `UserCreatorChatRoomMessageBroker` 기준으로 전달 여부를 판단한다.
|
||||
- 통과 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- REFACTOR: 기존 REST text endpoint 제거 전까지 중복 저장 로직이 생기지 않도록 private save method로만 분리한다.
|
||||
- 검증 기록:
|
||||
- 무엇: WebSocket 전용 `sendTextMessageByWebSocket`을 추가해 기존 텍스트 저장 로직을 private `saveTextMessage`로 재사용하고, 상대방 presence가 있으면 `UserCreatorChatRoomMessageBroker.publish`로 `MESSAGE` envelope를 발행하며 sender에게는 handler가 `SEND_ACK`를 응답하도록 했다.
|
||||
- 왜: REST text endpoint 제거 전까지 중복 저장 로직을 만들지 않고, WebSocket 송신 경로에서 저장/ack/수신자 전달을 처리하기 위해서다.
|
||||
- 범위: 상대방 미접속 시 push payload 보강은 Task 4.3 범위라 이번 task에서는 변경하지 않았다. 현재 계약은 v2 채팅 푸시 payload에 `deep_link`만 포함하는 것이다.
|
||||
- RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest` 실행 시 `sendTextMessageByWebSocket`, service WebSocket dependency, handler `SEND_TEXT` dispatch 부재로 `compileTestKotlin` 실패를 확인했다.
|
||||
- GREEN: focused 명령 재실행 결과 `BUILD SUCCESSFUL in 1m 1s`로 통과했다. 이후 `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketConfigTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest`가 `BUILD SUCCESSFUL in 3m 28s`로 통과했다.
|
||||
- Reviewer 보강 RED: `JOIN_ROOM` 완료 전 `SEND_TEXT`를 보내도 메시지 저장 경로로 들어갈 수 있다는 테스트가 기존 구현에서 실패함을 확인했다.
|
||||
- Reviewer 보강 GREEN: `SEND_TEXT` 처리 전 session의 joined room id가 요청 `roomId`와 일치하는지 검증하고, 미JOIN/다른 room이면 `chat.room.join_required` `ERROR`를 응답하도록 수정했다. `UserCreatorChatWebSocketHandlerTest`가 `BUILD SUCCESSFUL in 1m`로 통과했고, focused+인접 WebSocket 테스트 묶음이 `BUILD SUCCESSFUL in 15s`로 통과했다.
|
||||
|
||||
- [x] **Task 4.3: 상대방 미접속 시 푸시 발송**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/fcm/FcmServiceTest.kt`
|
||||
- RED: 상대방 presence가 없으면 `FcmEvent`가 v2 채팅용 `deep_link` 생성 정보를 포함하고 `roomId`, `messageId`, `chatType=USER_CREATOR` data payload를 포함하지 않는 테스트를 작성한다.
|
||||
- RED: `FcmService`가 FCM data payload에 `deep_link=voiceon[-test]://chat/{roomId}`만 넣는 테스트를 작성한다.
|
||||
- 실패 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest`
|
||||
- Expected: v2 채팅 deep_link payload 부재 또는 기존 `room_id`/`message_id`/`chat_type` 잔존으로 실패한다.
|
||||
- GREEN: v2 채팅 push 발행 시 `deep_link`만 채우고 기존 `room_id`, `message_id`, `chat_type` data payload는 제거한다.
|
||||
- 통과 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- REFACTOR: 기존 live/content/community deep link payload와 충돌하지 않게 v2 채팅 deep link는 `voiceon[-test]://chat/{roomId}` 규칙으로만 생성한다.
|
||||
- 검증 기록:
|
||||
- 현재 요구사항 변경: v2 채팅 푸시 payload는 `deep_link`만 사용하고 `room_id`, `message_id`, `chat_type`은 제거한다. 기존 완료 기록은 이전 계약 기준 이력이며, 후속 구현에서 새 계약에 맞춰 수정한다.
|
||||
- 무엇: WebSocket 텍스트/REST 음성 전송에서 상대방 presence가 없으면 `FcmEvent.deepLinkValue=CHAT`, `deepLinkId=roomId`만 포함한 FCM 이벤트를 발행하도록 수정했다. FCM data payload 생성은 `deep_link=voiceon[-test]://chat/{roomId}`만 포함하고 `room_id`, `message_id`, `chat_type`을 제외한다.
|
||||
- 왜: 상대방이 같은 `roomId`에 접속 중이 아닐 때 푸시 터치로 유저-크리에이터 채팅방에 이동해야 하기 때문이다.
|
||||
- 이전 계약 기준 RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest` 실행 시 `FcmEvent.chatType`, `FcmService.buildDataPayload` unresolved reference로 `compileTestKotlin` 실패를 확인했다.
|
||||
- 이전 계약 기준 GREEN: 같은 focused 명령 재실행 결과 `BUILD SUCCESSFUL in 4m 30s`로 통과했다. 현재 요구사항은 위 RED/GREEN 항목처럼 `deep_link` 단일 payload로 후속 수정한다.
|
||||
- Fresh 검증 Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --rerun-tasks --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest`
|
||||
- Fresh 검증 Result: `BUILD SUCCESSFUL in 3m 45s`; focused 테스트 10 actionable tasks가 실제 실행됐다.
|
||||
- Fresh 정적 확인 Run: `rg -n "room_id|message_id|chat_type|deep_link|voiceon://chat|voiceon-test://chat" docs/20260618_유저크리에이터채팅_WebSocket전환 src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat src/test/kotlin/kr/co/vividnext/sodalive/fcm src/main/kotlin/kr/co/vividnext/sodalive/fcm`
|
||||
- Fresh 정적 확인 Result: v2 채팅 service/test 경로는 `deepLinkValue=CHAT`, `deepLinkId=roomId`, `deep_link` 테스트로 확인됐고, `room_id`/`message_id`/`chat_type`은 공통 FCM payload helper와 문서상 제외 조건에만 남아 있음을 확인했다.
|
||||
- 코드 리뷰 메모: `UserCreatorChatService.publishMessagePush`, `FcmSendListener.sendPush`, `FcmService.buildDeepLink/buildDataPayload`, 관련 단위 테스트를 대조했다. Task 4.3 범위에서 즉시 수정이 필요한 production 코드 결함은 발견하지 못했다.
|
||||
|
||||
- [x] **Task 4.4: LEAVE_ROOM, close, heartbeat 처리**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatPresenceService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandlerTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatPresenceServiceTest.kt`
|
||||
- RED: `LEAVE_ROOM`과 WebSocket close 시 local registry와 Redis presence가 제거되는 테스트를 작성한다.
|
||||
- RED: `PING` 수신 시 presence TTL 갱신과 `PONG` 응답 테스트를 작성한다.
|
||||
- 실패 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest`
|
||||
- Expected: LEAVE_ROOM/PING 처리 부재로 실패한다.
|
||||
- GREEN: handler lifecycle callback과 message dispatch에 정리/heartbeat 처리를 추가한다.
|
||||
- 통과 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- REFACTOR: close와 LEAVE_ROOM 정리 로직은 같은 private method를 사용한다.
|
||||
- 검증 기록:
|
||||
- 무엇: handler dispatch에 `LEAVE_ROOM`과 `PING`을 추가했다. `LEAVE_ROOM`은 기존 close cleanup 경로인 `clearJoinedRoom`을 재사용하고, `PING`은 joined room 검증 후 Redis presence TTL을 갱신하고 `PONG`을 응답한다.
|
||||
- 왜: 채팅방 화면 이탈 시 presence를 즉시 제거하고, 화면 유지 중 heartbeat로 90초 TTL presence를 연장하기 위해서다.
|
||||
- RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest` 실행 시 `LEAVE_ROOM`의 `markLeft`, `PING`의 `refresh`가 호출되지 않아 handler 테스트 2개가 실패했다.
|
||||
- GREEN: 같은 focused 명령 재실행 결과 `BUILD SUCCESSFUL in 4m 9s`로 통과했다.
|
||||
|
||||
- [x] **Task 4.5: WebSocket handshake slice 테스트 추가**
|
||||
- Files:
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandshakeIntegrationTest.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt` (필요한 경우에만)
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptor.kt` (필요한 경우에만)
|
||||
- RED: WebSocket config slice에서 `/ws/v2/user-creator-chat` handler에 `UserCreatorChatWebSocketAuthInterceptor`가 등록되어 있고, 등록된 interceptor가 handshake 인증 성공/실패를 판정하는 테스트를 작성한다.
|
||||
- RED: 유효한 `Authorization: Bearer <accessToken>` header가 있으면 handshake가 성공하고, header가 없거나 유효하지 않으면 handshake가 실패하는 테스트를 작성한다.
|
||||
- RED: 전체 Spring Boot server/DB/Redis context를 띄우지 않고 `TokenProvider`는 mock으로 고정해 token parsing 세부 로직은 기존 interceptor 단위 테스트에 위임한다.
|
||||
- 실패 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandshakeIntegrationTest`
|
||||
- Expected: 통합 테스트 파일 부재 또는 실제 client handshake 경계 미구현으로 실패한다.
|
||||
- GREEN: 테스트가 실패하면 최소 수정만 적용한다. 예를 들어 security filter가 `/ws/v2/user-creator-chat` handshake를 interceptor까지 통과시키지 못하는 경우에만 해당 경로 정책을 조정한다.
|
||||
- 통과 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandshakeIntegrationTest`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- REFACTOR: 단위 테스트가 이미 검증하는 token parsing 세부 로직을 중복하지 않고, endpoint 등록과 auth interceptor handshake 성공/실패 연결 경계만 검증한다.
|
||||
- 검증 기록:
|
||||
- 무엇: WebSocket config slice에서 `/ws/v2/user-creator-chat` handler 등록과 등록된 `UserCreatorChatWebSocketAuthInterceptor`의 handshake 성공/실패를 검증하는 테스트를 추가했다.
|
||||
- 왜: `@SpringBootTest(webEnvironment = RANDOM_PORT)` 기반 실제 server/client 테스트가 전체 suite 말미에 `java.lang.OutOfMemoryError: Java heap space`를 유발해, 동일 Phase 4 경계를 더 가벼운 slice로 검증하기 위해서다.
|
||||
- 어떻게: `TokenProvider`는 mock으로 고정하고, 유효 `Authorization: Bearer <token>`은 handshake 성공, header 누락과 invalid token은 handshake 실패를 검증했다. 실제 token parsing 세부 로직은 `UserCreatorChatWebSocketAuthInterceptorTest`에 맡겼다.
|
||||
- 결과: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandshakeIntegrationTest`가 `BUILD SUCCESSFUL in 1m 11s`로 통과했다. production `SecurityConfig`/interceptor 수정은 필요하지 않았다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: 기존 SSE 제거와 REST 경계 정리
|
||||
|
||||
- [x] **Task 5.1: SSE controller endpoint 제거**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
|
||||
- RED: `disconnectRealtime` 관련 테스트를 제거하거나 WebSocket close/presence 테스트로 이동한다.
|
||||
- 실패 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
||||
- Expected: 제거 대상 method 참조가 남아 있으면 실패한다.
|
||||
- GREEN: `/events`, `/events/disconnect`, `/messages/text` REST endpoint를 제거한다. 텍스트 메시지는 WebSocket 전송만 허용한다.
|
||||
- 통과 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- REFACTOR: 사용하지 않는 `MediaType.TEXT_EVENT_STREAM_VALUE` import를 제거한다.
|
||||
- 검증 기록:
|
||||
- 무엇: `/events`, `/events/disconnect`, `/messages/text` REST endpoint를 controller에서 제거하고, 제거된 경로가 더 이상 매핑되지 않는 테스트를 추가했다.
|
||||
- 왜: 텍스트 메시지 송수신과 presence lifecycle을 WebSocket으로만 처리하기 위해서다.
|
||||
- RED: `UserCreatorChatControllerMappingTest.shouldReturnNotFoundForRemovedSseAndTextMessageEndpoints` 추가 후 기존 코드에서 매핑이 실행되어 `NestedServletException`으로 실패함을 확인했다.
|
||||
- GREEN: endpoint 제거 후 `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest`가 `BUILD SUCCESSFUL in 56s`로 통과했다.
|
||||
|
||||
- [x] **Task 5.2: `UserCreatorChatRealtimeService` 제거**
|
||||
- Files:
|
||||
- Delete: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatRealtimeService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
|
||||
- RED: service 테스트에서 SSE realtime mock 의존성을 제거하고 WebSocket presence/broker mock을 주입하도록 변경한다.
|
||||
- 실패 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
||||
- Expected: constructor signature 불일치 또는 미구현 mock으로 실패한다.
|
||||
- GREEN: `UserCreatorChatService` 생성자와 메시지 전달 로직에서 `UserCreatorChatRealtimeService`를 제거한다.
|
||||
- 통과 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- REFACTOR: `rg -n "SseEmitter|connectEvents|disconnectRealtime|TEXT_EVENT_STREAM|UserCreatorChatRealtimeService" src/main src/test`로 잔여 참조가 없는지 확인한다.
|
||||
- 검증 기록:
|
||||
- 무엇: `UserCreatorChatRealtimeService` 파일과 `UserCreatorChatService`의 SSE 의존성, `connect`, `disconnectRealtime`, REST text service method를 제거했다. 음성 REST 전송은 유지하되 상대방 presence가 있으면 WebSocket broker로 `MESSAGE`를 발행하고, presence가 없으면 기존 푸시 이벤트를 발행하도록 정리했다.
|
||||
- 왜: 기존 SSE 구현을 완전히 제거하면서도 유지 대상인 음성 업로드 API의 실시간/푸시 분기 동작을 보존하기 위해서다.
|
||||
- RED: 제거 전 `rg -n "SseEmitter|TEXT_EVENT_STREAM|connectEvents|disconnectRealtime|UserCreatorChatRealtimeService" src/main src/test`에서 `UserCreatorChatRealtimeService`, controller SSE method, service/test 참조가 확인되었다.
|
||||
- GREEN: `UserCreatorChatServiceTest`의 WebSocket text, voice REST WebSocket broker, voice REST push 분기 테스트가 포함된 focused 명령이 `BUILD SUCCESSFUL in 56s`로 통과했다.
|
||||
- 정적 확인: `rg -n "SseEmitter|TEXT_EVENT_STREAM|connectEvents|disconnectRealtime|UserCreatorChatRealtimeService" src/main src/test` 결과가 없음을 확인했다.
|
||||
|
||||
- [x] **Task 5.3: 클라이언트 연동 문서 갱신**
|
||||
- Files:
|
||||
- Modify: `docs/plan-task/20260513_유저크리에이터채팅방개편.md`
|
||||
- Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md`
|
||||
- TDD 예외 사유: 문서 갱신은 자동화 테스트 작성 대상이 아니다.
|
||||
- 대체 검증 방법:
|
||||
- Run: `rg -n "GET /api/v2/user-creator-chat/rooms/\\{roomId\\}/events|events/disconnect|messages/text|JOIN_ROOM|SEND_TEXT|LEAVE_ROOM|deep_link" docs/20260618_유저크리에이터채팅_WebSocket전환`
|
||||
- Expected: 제거 대상 API와 신규 WebSocket/푸시 계약이 모두 문서에 명시되어야 한다.
|
||||
- GREEN: 클라이언트 연동 순서를 `openRoom` REST 호출 후 WebSocket connect + `JOIN_ROOM`으로 갱신한다.
|
||||
- 통과 확인:
|
||||
- Run: `./gradlew tasks --all`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- 검증 기록:
|
||||
- 무엇: `docs/plan-task/20260513_유저크리에이터채팅방개편.md`의 클라이언트 연동 프롬프트를 SSE 연결/해제/REST text 전송에서 WebSocket `JOIN_ROOM`, `SEND_TEXT`, `MESSAGE`, `SEND_ACK`, `LEAVE_ROOM` 기준으로 갱신했다.
|
||||
- 왜: 과거 SSE 기준 연동 프롬프트가 현재 서버 구현과 충돌하지 않도록 클라이언트 안내를 최신 계약으로 맞추기 위해서다.
|
||||
- 어떻게: 유지 REST API는 방 생성/open/messages/voice로 남기고, 제거된 `/events`, `/events/disconnect`, `/messages/text` 호출과 SSE reconnect 처리 코드를 제거 대상 목록에 명시했다.
|
||||
|
||||
- [x] **Task 5.4: iOS/Android 앱 반영 체크리스트 확인**
|
||||
- Files:
|
||||
- Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md`
|
||||
- Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md`
|
||||
- TDD 예외 사유: 앱 코드가 현재 서버 저장소에 없으므로 자동화 테스트를 작성할 수 없다.
|
||||
- 대체 검증 방법:
|
||||
- Run: `rg -n "iOS|Android|JOIN_ROOM|SEND_ACK|MESSAGE|LEAVE_ROOM|푸시|room_id|deep_link" docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md`
|
||||
- Expected: 클라이언트 변경 사항이 PRD와 plan-task 양쪽에 기록되어야 한다.
|
||||
- GREEN: 앱 담당자가 구현해야 할 진입, 전송, 수신, 이탈, 재연결, 푸시 이동, 제거 대상 API를 문서에 유지한다.
|
||||
- 통과 확인:
|
||||
- Run: `./gradlew tasks --all`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- 검증 기록:
|
||||
- 무엇: PRD와 plan-task에 iOS/Android 진입, 전송, 수신, 이탈, 재연결, 토큰 갱신, 푸시 이동, 제거 대상 API가 유지되는지 확인했다.
|
||||
- 왜: 서버 저장소에는 앱 코드가 없으므로 앱 반영 범위는 문서 계약으로 추적해야 하기 때문이다.
|
||||
- 결과: PRD는 기존 SSE 사용 현황과 WebSocket 전환 요구사항을 유지하고, plan-task는 앱 변경 체크리스트와 제거 대상 호출 목록을 유지한다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 최종 회귀 검증
|
||||
|
||||
- Run: `rg -n "open-in-view" src/main/resources src/test/resources`
|
||||
- Expected: OSIV 정책이 명시되어 있어야 한다.
|
||||
- Run: `./gradlew test -Dspring.jpa.open-in-view=false --tests '<OSIV off 우선 테스트 목록>'`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.*`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandshakeIntegrationTest`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- Run: `./gradlew ktlintCheck`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- Run: `./gradlew test`
|
||||
- Expected: `BUILD SUCCESSFUL`
|
||||
- Run: `rg -n "SseEmitter|TEXT_EVENT_STREAM|connectEvents|disconnectRealtime|UserCreatorChatRealtimeService" src/main src/test`
|
||||
- Expected: 검색 결과 없음
|
||||
- Run: `rg -n "spring-boot-starter-websocket|/ws/v2/user-creator-chat|deep_link|USER_CREATOR" build.gradle.kts src/main src/test`
|
||||
- Expected: WebSocket 의존성, endpoint, 푸시 payload 관련 구현이 확인됨
|
||||
|
||||
---
|
||||
|
||||
## 5. 구현 후 검증 기록
|
||||
|
||||
- Phase 4 코드 리뷰 및 전체 검증:
|
||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process`
|
||||
- Result: `BUILD FAILED in 6m 31s`; 전체 suite 실행 중 `Gradle Test Executor 1`이 `java.lang.OutOfMemoryError: Java heap space`로 실패했다. 리포트 기준 Phase 4 관련 단위/통합 테스트 대부분은 `failures=0`, `errors=0`였으나 `UserCreatorChatWebSocketHandshakeIntegrationTest`는 suite 말미 context 초기화 중 OOM으로 `tests=0` 상태였다.
|
||||
- 조치: `UserCreatorChatWebSocketHandshakeIntegrationTest`를 `RANDOM_PORT` 전체 context/실제 `StandardWebSocketClient` 방식에서 WebSocket config slice + mock `TokenProvider` 방식으로 축소했다.
|
||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandshakeIntegrationTest`
|
||||
- Result: `BUILD SUCCESSFUL in 1m 11s`; 축소한 handshake slice 테스트 단독 실행이 통과했다.
|
||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process`
|
||||
- Result: `BUILD FAILED in 1m 22s`; 이전 `OutOfMemoryError`는 재발하지 않았고 `UserCreatorChatWebSocketHandshakeIntegrationTest`도 OOM 원인으로 나타나지 않았다. 실패는 embedded Redis 시작 중 `127.0.0.1:16379: bind: Address already in use`로 발생했다.
|
||||
- 확인: `lsof -nP -iTCP:16379 -sTCP:LISTEN` 결과 `redis-ser` PID `99457`이 `127.0.0.1:16379`를 점유 중이었다. 외부 프로세스 종료는 작업 범위 밖이라 수행하지 않았다.
|
||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandshakeIntegrationTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest`
|
||||
- Result: `BUILD SUCCESSFUL in 46s`; Phase 4 WebSocket handler/service/presence/broker/Redis/handshake/FCM payload focused 테스트가 통과했다.
|
||||
- Run: `./gradlew --no-daemon ktlintCheck`
|
||||
- Result: `BUILD SUCCESSFUL in 7s`.
|
||||
- 코드 리뷰 메모: Phase 4 기능 경로에서 즉시 수정이 필요한 production 코드 결함은 발견하지 못했다. 다만 전체 suite OOM으로 인해 `./gradlew test` 전체 통과 상태는 아직 확보하지 못했다.
|
||||
- Fresh 전체 검증 Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process`
|
||||
- Fresh 전체 검증 Result: `BUILD SUCCESSFUL in 1m 36s`; 이번 실행에서는 전체 테스트 suite가 통과했다.
|
||||
- Fresh lint Run: `./gradlew --no-daemon ktlintCheck`
|
||||
- Fresh lint Result: `BUILD SUCCESSFUL in 7s`.
|
||||
- Fresh 정적 확인 Run: `rg -n "open-in-view" src/main/resources src/test/resources`
|
||||
- Fresh 정적 확인 Result: main/test 설정의 `spring.jpa.open-in-view=false` 명시를 확인했다.
|
||||
- Fresh 정적 확인 Run: `rg -n "spring-boot-starter-websocket|/ws/v2/user-creator-chat|deep_link|USER_CREATOR" build.gradle.kts src/main src/test`
|
||||
- Fresh 정적 확인 Result: WebSocket 의존성, endpoint, FCM `deep_link` 구현과 테스트를 확인했다. v2 채팅 푸시 payload는 `deep_link` 단일 값이다.
|
||||
- Fresh 코드 리뷰 메모: `JOIN_ROOM`/`SEND_TEXT`/`LEAVE_ROOM`/`PING`, 미JOIN/다른 room 차단, malformed/unknown message error, 상대방 presence 유무에 따른 WebSocket publish/FCM push 분기, handshake slice 테스트 범위를 대조했다. Phase 4 범위에서 즉시 수정이 필요한 production 코드 결함은 발견하지 못했다.
|
||||
- Phase 5:
|
||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest`
|
||||
- RED Result: `UserCreatorChatControllerMappingTest.shouldReturnNotFoundForRemovedSseAndTextMessageEndpoints` 추가 후 기존 코드에서는 제거 대상 endpoint가 아직 매핑되어 controller method가 실행되면서 `NestedServletException`으로 실패했다.
|
||||
- GREEN Result: endpoint/service 제거와 voice delivery WebSocket 전환 후 같은 focused 명령이 `BUILD SUCCESSFUL in 56s`로 통과했다.
|
||||
- Run: `rg -n "SseEmitter|TEXT_EVENT_STREAM|connectEvents|disconnectRealtime|UserCreatorChatRealtimeService|SendUserCreatorTextMessageRequest" src/main src/test`
|
||||
- Result: 검색 결과 없음. SSE runtime 구현, REST text request DTO, 관련 테스트 참조가 제거되었음을 확인했다.
|
||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandshakeIntegrationTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest`
|
||||
- Result: `BUILD SUCCESSFUL in 5m 24s`; WebSocket handler/presence/broker/handshake와 FCM payload 인접 회귀가 통과했다.
|
||||
- Run: `rg -n "GET /api/v2/user-creator-chat/rooms/\\{roomId\\}/events|events/disconnect|messages/text|JOIN_ROOM|SEND_TEXT|LEAVE_ROOM|deep_link" docs/20260618_유저크리에이터채팅_WebSocket전환 docs/plan-task/20260513_유저크리에이터채팅방개편.md`
|
||||
- Result: 제거 대상 API와 신규 WebSocket/푸시 계약이 문서에 함께 명시되어 있음을 확인했다.
|
||||
- Run: `./gradlew --no-daemon ktlintCheck`
|
||||
- Result: import 정렬 수정 후 `BUILD SUCCESSFUL in 29s`.
|
||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process`
|
||||
- Result: `BUILD SUCCESSFUL in 3m 21s`; 전체 테스트 suite가 통과했다.
|
||||
- Fresh 코드 리뷰 메모: SSE controller endpoint 제거, `UserCreatorChatRealtimeService` 삭제, REST text request DTO 제거, 음성 REST 전송의 WebSocket broker/FCM push 분기, 클라이언트 연동 문서 갱신 범위를 대조했다. Phase 5 범위에서 즉시 수정이 필요한 production 코드 결함은 발견하지 못했다.
|
||||
- Fresh 정적 확인 Run: `rg -n "SseEmitter|TEXT_EVENT_STREAM|connectEvents|disconnectRealtime|UserCreatorChatRealtimeService|SendUserCreatorTextMessageRequest" src/main src/test`
|
||||
- Fresh 정적 확인 Result: 검색 결과 없음. `src/main`/`src/test`에 SSE runtime 구현, REST text request DTO, 관련 참조가 남아 있지 않다.
|
||||
- Fresh 정적 확인 Run: `rg -n "spring-boot-starter-websocket|/ws/v2/user-creator-chat|deep_link|USER_CREATOR" build.gradle.kts src/main src/test`
|
||||
- Fresh 정적 확인 Result: WebSocket 의존성, endpoint, FCM `deep_link` 구현과 테스트를 확인했다. v2 채팅 푸시 payload는 `deep_link` 단일 값이다.
|
||||
- Fresh 문서 확인 Run: `rg -n "GET /api/v2/user-creator-chat/rooms/\\{roomId\\}/events|events/disconnect|messages/text|JOIN_ROOM|SEND_TEXT|LEAVE_ROOM|deep_link" docs/20260618_유저크리에이터채팅_WebSocket전환 docs/plan-task/20260513_유저크리에이터채팅방개편.md`
|
||||
- Fresh 문서 확인 Result: 제거 대상 API와 신규 WebSocket/푸시 계약이 문서에 함께 명시되어 있음을 확인했다.
|
||||
- Fresh focused 검증 Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest`
|
||||
- Fresh focused 검증 Result: `BUILD SUCCESSFUL in 44s`.
|
||||
- Fresh 인접 회귀 Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandshakeIntegrationTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest`
|
||||
- Fresh 인접 회귀 Result: `BUILD SUCCESSFUL in 16s`.
|
||||
- Fresh lint Run: `./gradlew --no-daemon ktlintCheck`
|
||||
- Fresh lint Result: `BUILD SUCCESSFUL in 7s`.
|
||||
- Fresh 전체 검증 Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process`
|
||||
- Fresh 전체 검증 Result: `BUILD SUCCESSFUL in 1m 52s`.
|
||||
- 잔여 리스크 개선:
|
||||
- 대상: `UserCreatorChatService.deliverRealtime`
|
||||
- 무엇: Redis/WebSocket 전달 경계의 fail-open 처리 범위를 전체 `Exception`에서 Redis 접근 예외인 `DataAccessException`으로 좁히고, Redis 오류는 warn 로그를 남긴 뒤 푸시 발송으로 fail-open 하도록 정리했다. Redis 계층이 아닌 broker 예외는 숨기지 않고 전파한다.
|
||||
- 왜: Redis 장애 시 메시지 저장 후 푸시 발송 요구사항은 유지하되, 프로그래밍 오류나 예상하지 못한 런타임 오류까지 푸시 fallback으로 숨기지 않기 위해서다.
|
||||
- RED: `UserCreatorChatServiceTest.shouldPropagateNonRedisBrokerExceptionDuringVoiceMessage`를 추가했다. 기존 `runCatching` 구현에서는 `IllegalStateException`이 전파되지 않아 `AssertionFailedError`로 실패했다.
|
||||
- GREEN: `DataAccessException`만 catch하도록 수정한 뒤 `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`가 `BUILD SUCCESSFUL in 3m 33s`로 통과했다.
|
||||
- 인접 회귀: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.*'`가 `BUILD SUCCESSFUL in 38s`로 통과했다.
|
||||
- Lint: import 정렬 수정 후 `./gradlew --no-daemon ktlintCheck`가 `BUILD SUCCESSFUL in 21s`로 통과했다.
|
||||
- 전체 회귀: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false`가 `BUILD SUCCESSFUL in 4m 39s`로 통과했다.
|
||||
- Phase 3:
|
||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest`
|
||||
- RED Result: 테스트 파일 부재 상태에서 `No tests found for given includes`로 실패했다.
|
||||
- GREEN Result: `BUILD SUCCESSFUL in 33s`; embedded Redis 기준 presence key/member session set/room set/TTL, markLeft 정리, stale session pruning, Redis pub/sub listener delivery 테스트 4개가 통과했다.
|
||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest`
|
||||
- RED Result: `UserCreatorChatPresenceService`, `UserCreatorChatRoomMessageBroker`, `UserCreatorChatRoomPublishedMessage` unresolved reference로 `compileTestKotlin` 실패를 확인했다.
|
||||
- GREEN Result: `BUILD SUCCESSFUL in 3m 22s`; presence join/refresh/leave/hasPresence 테스트 4개와 broker publish/pattern subscribe/local target delivery 테스트 3개가 통과했다.
|
||||
- Reviewer 보강 RED: stale session id가 남은 상태에서 마지막 live session이 leave 하는 테스트가 기존 구현에서 `ArgumentsAreDifferent`로 실패했다.
|
||||
- Reviewer 보강 GREEN: `UserCreatorChatPresenceServiceTest`가 `BUILD SUCCESSFUL in 1m 22s`, WebSocket focused 테스트 묶음이 `BUILD SUCCESSFUL in 31s`, `./gradlew --no-daemon ktlintCheck`가 `BUILD SUCCESSFUL in 50s`로 통과했다.
|
||||
- Broker 보강 RED: broken local session의 `sendMessage`가 `IOException`을 던질 때 같은 member의 healthy session 전송이 중단되어 `UserCreatorChatRoomMessageBrokerTest`가 실패했다.
|
||||
- Broker 보강 GREEN: `UserCreatorChatRoomMessageBrokerTest`가 `BUILD SUCCESSFUL in 21s`, WebSocket focused 테스트 묶음이 `BUILD SUCCESSFUL in 21s`, `./gradlew --no-daemon ktlintCheck`가 `BUILD SUCCESSFUL in 37s`로 통과했다.
|
||||
- Phase 2:
|
||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketSessionRegistryTest`
|
||||
- Result: `BUILD SUCCESSFUL in 1m 46s`; message envelope enum/JsonNode 역직렬화 테스트 3개와 local session registry 등록/조회/제거/room 전환/동시 같은 session 전환/session lock map 비유지 테스트 6개가 통과했다.
|
||||
- Phase 0:
|
||||
- Run: `rg -n "open-in-view|spring.jpa" src/main/resources src/test/resources`
|
||||
- Result: main/test 설정의 `spring.jpa.open-in-view=false` 명시를 확인했다.
|
||||
- Run: `./gradlew --no-daemon cleanTest test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests '*CreatorChannelHomeControllerTest' --tests '*CreatorChannelLiveControllerTest' --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest`
|
||||
- Result: `BUILD SUCCESSFUL in 1m 24s`; XML 기준 `UserCreatorChatServiceIntegrationTest` 2개, `UserCreatorChatServiceTest` 8개, `CreatorChannelHomeControllerTest` 2개, `CreatorChannelLiveControllerTest` 5개, `HomeRecommendationControllerTest` 21개, `CreatorRankingControllerTest` 3개 모두 `failures=0`, `errors=0`.
|
||||
|
||||
## 6. OSIV 점검 기록
|
||||
|
||||
- API/기능:
|
||||
- 파일: `src/main/resources/application.yml`, `src/test/resources/application.yml`
|
||||
- 위험 유형: OSIV 정책 미명시
|
||||
- lazy 접근 대상: 해당 없음
|
||||
- OSIV off 테스트: `rg -n "open-in-view|spring.jpa" src/main/resources src/test/resources`
|
||||
- 수정 방향: main/test `spring.jpa.open-in-view=false` 명시
|
||||
- 처리 상태: 완료
|
||||
- API/기능: 유저-크리에이터 채팅 room open/messages/voice service 경계
|
||||
- 파일: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
|
||||
- 위험 유형: service/facade 트랜잭션 밖 DTO 변환 중 lazy 연관 접근 가능성
|
||||
- lazy 접근 대상: `UserCreatorChatParticipant.member`, `UserCreatorChatMessage.participant`
|
||||
- OSIV off 테스트: Phase 0 묶음 검증 명령에 포함
|
||||
- 수정 방향: 현재 범위에서는 DTO 변환이 service 트랜잭션 안에서 수행되어 추가 수정 없음
|
||||
- 처리 상태: XML 기준 `UserCreatorChatServiceIntegrationTest` 2개, `UserCreatorChatServiceTest` 8개 모두 `failures=0`, `errors=0`
|
||||
- API/기능: 홈 추천/크리에이터 랭킹 controller 통합 테스트
|
||||
- 파일: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt`
|
||||
- 위험 유형: controller 요청 처리 또는 응답 직렬화 중 lazy 연관 접근 가능성
|
||||
- lazy 접근 대상: `MemberAdapter.member.auth`, 홈/랭킹 조회 응답 DTO 관련 entity 연관
|
||||
- OSIV off 테스트: Phase 0 묶음 검증 명령에 포함
|
||||
- 수정 방향: 확인된 `LazyInitializationException` 없음. 추가 lazy 수정 없음
|
||||
- 처리 상태: XML 기준 `HomeRecommendationControllerTest` 21개, `CreatorRankingControllerTest` 3개 모두 `failures=0`, `errors=0`
|
||||
- API/기능: 크리에이터 채널 home/live WebMvc controller 표면
|
||||
- 파일: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveControllerTest.kt`
|
||||
- 위험 유형: controller에서 인증 principal을 facade로 전달하는 표면 회귀 가능성
|
||||
- lazy 접근 대상: `MemberAdapter.member`
|
||||
- OSIV off 테스트: Phase 0 묶음 검증 명령에 포함
|
||||
- 수정 방향: JPA lazy loading 직접 검증은 아니며, WebMvc 표면 회귀만 확인
|
||||
- 처리 상태: XML 기준 `CreatorChannelHomeControllerTest` 2개, `CreatorChannelLiveControllerTest` 5개 모두 `failures=0`, `errors=0`
|
||||
275
docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md
Normal file
275
docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# PRD: 유저-크리에이터 채팅 WebSocket 전환
|
||||
|
||||
## 1. Overview
|
||||
유저-크리에이터 1:1 채팅의 실시간 전송 방식을 SSE에서 WebSocket으로 전환해, 네이티브 앱의 채팅방 화면 진입 중에는 실시간 메시지를 받고 푸시는 받지 않으며, 화면 밖에서는 푸시로 채팅방 이동 정보를 받을 수 있게 한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. Problem
|
||||
- 현재 `GET /api/v2/user-creator-chat/rooms/{roomId}/events` SSE 연결은 모바일 네이티브 앱의 HTTP connection pool과 충돌해, SSE 연결 중 다른 API 동작이 지연되거나 막히는 문제가 발생한다.
|
||||
- SSE는 서버에서 클라이언트로만 이벤트를 보내는 단방향 방식이므로, 1:1 채팅의 접속 상태, 방 진입/이탈, 메시지 송수신 생명주기를 표현하기에 부적합하다.
|
||||
- 기존 SSE presence는 서버 메모리와 Redis TTL을 함께 사용하지만, 여러 서버 인스턴스에서 메시지를 받는 사용자 세션이 어느 서버에 있는지 전달하는 구조가 없다.
|
||||
- 클라이언트 요구사항은 "해당 채팅방 화면에 있으면 푸시 미발송, 화면에 없으면 푸시 발송 및 푸시 터치 시 해당 방 이동"이므로, 방 단위의 정확한 presence가 필요하다.
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals
|
||||
- 유저-크리에이터 채팅 실시간 연결을 SSE에서 WebSocket으로 전환한다.
|
||||
- 기존 SSE endpoint와 `SseEmitter` 기반 구현은 제거한다.
|
||||
- WebSocket 연결은 앱 로그인 전체 수명이 아니라 채팅방 화면에 들어와 있을 때만 유지한다.
|
||||
- 서버가 여러 대라고 가정하고 Redis를 사용해 방 단위 presence와 서버 간 메시지 전달을 처리한다.
|
||||
- WebSocket 전환과 별도로 `spring.jpa.open-in-view=false` 적용 가능성을 점검하고, 트랜잭션 밖 lazy loading에 의존하는 API를 먼저 식별한다.
|
||||
- 같은 `roomId` 채팅방 화면에 상대방이 접속 중이면 새 메시지를 WebSocket으로 전달하고 푸시는 발송하지 않는다.
|
||||
- 상대방이 해당 `roomId` 채팅방 화면에 접속 중이 아니면 새 메시지 저장 후 푸시를 발송한다.
|
||||
- 푸시 payload에는 앱이 해당 채팅방으로 이동할 수 있는 `deep_link`만 포함한다.
|
||||
- 기존 방 생성, 방 열기, 과거 메시지 조회, 음성 메시지 업로드 API는 유지한다.
|
||||
- 텍스트 메시지는 WebSocket으로 전송하고, 서버는 저장 결과를 sender에게 ack로 돌려준다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Non-Goals
|
||||
- 전체 앱 로그인 수명 동안 유지되는 글로벌 WebSocket 연결은 이번 범위에 포함하지 않는다.
|
||||
- AI 캐릭터 채팅, 라이브 채팅, 기존 `/api/chat/room` 기능은 이번 범위에서 변경하지 않는다.
|
||||
- 메시지 읽음 처리, typing indicator, 온라인 사용자 목록 노출은 이번 범위에 포함하지 않는다.
|
||||
- 음성 파일 자체를 WebSocket binary로 전송하지 않는다.
|
||||
- DB 스키마 변경은 이번 범위에 포함하지 않는다.
|
||||
- STOMP broker 도입은 이번 범위에 포함하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 5. Target Users
|
||||
- iOS/Android 네이티브 앱 사용자: 채팅방 화면에서 실시간으로 메시지를 주고받는 회원
|
||||
- 모바일 클라이언트: 채팅방 화면 진입/이탈 시 연결 생명주기와 푸시 이동 처리를 구현하는 클라이언트
|
||||
- 운영 서버: 여러 인스턴스에서 동일한 presence와 메시지 전달 정책을 유지해야 하는 서버
|
||||
|
||||
---
|
||||
|
||||
## 6. User Stories
|
||||
- 사용자는 채팅방 화면에 들어와 있으면 새 메시지를 즉시 보고 싶고 같은 메시지의 푸시는 받고 싶지 않다.
|
||||
- 사용자는 채팅방 화면 밖에 있으면 상대방 메시지를 푸시로 받고 싶다.
|
||||
- 사용자는 푸시 알림을 터치하면 해당 유저-크리에이터 채팅방으로 바로 이동하고 싶다.
|
||||
- 앱은 채팅방 진입 시 초기 메시지를 REST로 조회하고 이후 새 메시지는 WebSocket으로 받고 싶다.
|
||||
- 서버는 여러 인스턴스 중 어느 인스턴스에 상대방 WebSocket session이 붙어 있어도 메시지를 전달하거나 푸시 여부를 올바르게 결정해야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 7. Core Features
|
||||
|
||||
### Feature A. WebSocket 연결과 인증
|
||||
|
||||
#### Requirements
|
||||
- WebSocket endpoint는 `/ws/v2/user-creator-chat`을 기본안으로 한다.
|
||||
- 네이티브 앱은 WebSocket handshake에 기존 `Authorization: Bearer <accessToken>` 헤더를 전달한다.
|
||||
- 서버는 기존 JWT 검증 흐름을 재사용해 인증 회원을 식별한다.
|
||||
- 인증 실패 시 WebSocket 연결을 수락하지 않는다.
|
||||
- 연결은 특정 채팅방 화면에 들어왔을 때만 생성한다.
|
||||
- 연결 직후 클라이언트는 `JOIN_ROOM` 메시지로 `roomId`를 전달한다.
|
||||
- 서버는 `JOIN_ROOM` 처리 시 회원이 해당 방의 활성 참여자인지 검증한다.
|
||||
- 검증 성공 시 서버는 해당 WebSocket session을 `roomId/memberId/sessionId/serverId` 기준으로 등록한다.
|
||||
- 하나의 WebSocket session은 하나의 `roomId`만 활성 방으로 가진다.
|
||||
|
||||
#### Edge Cases
|
||||
- 인증은 성공했지만 `JOIN_ROOM`의 `roomId` 참여자가 아니면 error 메시지를 보내고 연결을 종료한다.
|
||||
- 같은 회원이 같은 방을 여러 기기에서 열 수 있으므로 presence는 session 단위로 관리한다.
|
||||
- 같은 session에서 다른 `roomId`로 다시 `JOIN_ROOM`을 보내면 기존 방 presence를 제거한 뒤 새 방으로 전환한다.
|
||||
|
||||
### Feature B. WebSocket 메시지 프로토콜
|
||||
|
||||
#### Requirements
|
||||
- WebSocket은 raw JSON message protocol을 사용한다.
|
||||
- 공통 envelope는 다음 필드를 사용한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "JOIN_ROOM",
|
||||
"requestId": "client-generated-id",
|
||||
"roomId": 10,
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
- 클라이언트에서 서버로 보내는 메시지 타입은 다음을 기본으로 한다.
|
||||
- `JOIN_ROOM`: 방 입장 및 presence 등록
|
||||
- `SEND_TEXT`: 텍스트 메시지 저장 및 전달
|
||||
- `LEAVE_ROOM`: 방 이탈 및 presence 제거
|
||||
- `PING`: 연결 유지 확인
|
||||
- 서버에서 클라이언트로 보내는 메시지 타입은 다음을 기본으로 한다.
|
||||
- `JOINED`: 방 입장 성공
|
||||
- `MESSAGE`: 새 메시지 수신
|
||||
- `SEND_ACK`: sender에게 메시지 저장 결과 전달
|
||||
- `ERROR`: 처리 실패
|
||||
- `PONG`: `PING` 응답
|
||||
- `SEND_TEXT` payload는 `{ "textMessage": "..." }`를 사용한다.
|
||||
- `MESSAGE`와 `SEND_ACK` payload는 기존 `UserCreatorChatMessageItemDto`와 같은 메시지 item 구조를 사용한다.
|
||||
- 빈 문자열 또는 공백뿐인 텍스트 메시지는 기존 `sendTextMessage`와 동일하게 `common.error.invalid_request` 의미의 error로 처리한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 클라이언트가 `JOIN_ROOM` 전에 `SEND_TEXT`를 보내면 `ERROR`를 반환한다.
|
||||
- 알 수 없는 `type`은 `ERROR`를 반환하되 서버 connection은 유지한다.
|
||||
- 메시지 저장 성공 후 sender ack 전송이 실패해도 DB 저장은 롤백하지 않는다.
|
||||
|
||||
### Feature C. Redis 기반 presence와 다중 서버 메시지 전달
|
||||
|
||||
#### Requirements
|
||||
- 서버는 여러 대라고 가정한다.
|
||||
- 각 서버 인스턴스는 고유한 `serverId`를 가진다. 기본값은 application start 시 생성한 UUID이며, 운영 환경에서는 env 기반 지정도 허용한다.
|
||||
- local memory에는 현재 서버에 붙은 WebSocket session만 저장한다.
|
||||
- Redis에는 방 단위 presence를 session 단위로 저장한다.
|
||||
- Redis key 기본안:
|
||||
- session presence: `v2:user-creator-chat:ws:presence:{roomId}:{memberId}:{sessionId}`
|
||||
- room member index: `v2:user-creator-chat:ws:room:{roomId}:member:{memberId}:sessions`
|
||||
- pub/sub channel: `v2:user-creator-chat:ws:room:{roomId}`
|
||||
- presence value에는 `serverId`, `memberId`, `roomId`, `sessionId`, `lastSeenAt`을 포함한다.
|
||||
- WebSocket 연결 유지 중 heartbeat 또는 메시지 송수신 시 presence TTL을 갱신한다.
|
||||
- presence TTL 기본값은 90초로 한다.
|
||||
- 서버는 메시지 저장 후 상대방의 해당 `roomId` presence가 Redis에 하나 이상 있는지 확인한다.
|
||||
- 상대방 presence가 있으면 Redis pub/sub으로 room channel에 메시지를 발행한다.
|
||||
- 각 서버는 자신에게 연결된 session 중 대상 `roomId/memberId` session에만 메시지를 전송한다.
|
||||
- 상대방 presence가 없으면 푸시 이벤트를 발행한다.
|
||||
|
||||
#### Edge Cases
|
||||
- Redis presence는 남아 있지만 실제 WebSocket 전송이 실패하면 해당 local session을 정리한다.
|
||||
- Redis pub/sub 전달 실패 또는 Redis 장애 시에는 presence 판단을 신뢰할 수 없으므로 푸시 발송 쪽으로 fail-open 한다.
|
||||
- presence TTL 만료 전 앱이 비정상 종료되어도 최대 TTL 이후에는 오프라인으로 판단되어 푸시가 발송되어야 한다.
|
||||
|
||||
### Feature D. 푸시 발송과 채팅방 이동 payload
|
||||
|
||||
#### Requirements
|
||||
- 상대방이 해당 채팅방 화면에 있지 않으면 기존 FCM/APNs 푸시 발송 흐름을 사용한다.
|
||||
- 푸시 category는 기존 `PushNotificationCategory.MESSAGE`를 사용한다.
|
||||
- 푸시 payload에는 `deep_link`만 포함한다.
|
||||
- 운영: `voiceon://chat/{roomId}`
|
||||
- 개발/테스트: `voiceon-test://chat/{roomId}`
|
||||
- v2 채팅 푸시에서는 기존 `room_id`, `message_id`, `chat_type` data payload를 사용하지 않는다.
|
||||
- 앱은 푸시 터치 시 `deep_link`에서 `{roomId}`를 해석해 `GET /api/v2/user-creator-chat/rooms/{roomId}/open` 호출 후 WebSocket 연결을 시작한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 푸시 발송 대상 push token이 없으면 메시지 저장은 성공하고 `pushSent == false` 의미의 내부 결과를 남긴다.
|
||||
- 상대방이 여러 기기 중 하나에서 같은 방을 열고 있으면 푸시는 발송하지 않는다.
|
||||
- 상대방이 다른 채팅방을 열고 있거나 앱의 다른 화면에 있으면 현재 방 presence가 아니므로 푸시를 발송한다.
|
||||
|
||||
### Feature E. 기존 SSE 제거
|
||||
|
||||
#### Requirements
|
||||
- `GET /api/v2/user-creator-chat/rooms/{roomId}/events` endpoint를 제거한다.
|
||||
- `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect` endpoint를 제거한다.
|
||||
- `UserCreatorChatRealtimeService`의 `SseEmitter` 기반 구현을 제거한다.
|
||||
- SSE 관련 DTO, 테스트, 문서 언급은 WebSocket 기준으로 갱신한다.
|
||||
- 클라이언트 연동 문서에는 기존 SSE API가 더 이상 사용되지 않음을 명시한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 제거된 SSE endpoint를 호출하면 Spring MVC 기본 404 또는 security 정책에 따른 기존 오류 흐름을 따른다.
|
||||
- 기존 REST 메시지 조회/방 생성/open API는 제거하지 않는다.
|
||||
|
||||
### Feature F. iOS/Android 클라이언트 변경 사항
|
||||
|
||||
#### Current Native App Usage
|
||||
- 개발 중 테스트 중이던 iOS/Android 네이티브 앱은 채팅방 화면 진입 시 `GET /api/v2/user-creator-chat/rooms/{roomId}/events` SSE 연결을 사용하고 있었다.
|
||||
- 해당 앱은 채팅방 화면 이탈, 앱 백그라운드 진입, 로그아웃 등 실시간 수신을 중단해야 하는 시점에 `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect`를 호출하고 있었다.
|
||||
- WebSocket 전환 후에는 위 두 API가 제거되므로, 서버 배포와 같은 릴리스 범위에서 네이티브 앱의 SSE 연결/해제 코드도 함께 변경되어야 한다.
|
||||
|
||||
#### Requirements
|
||||
- 클라이언트는 채팅방 화면 진입 시 기존 SSE 연결을 열지 않는다.
|
||||
- 클라이언트는 채팅방 화면 진입 시 기존 `GET /api/v2/user-creator-chat/rooms/{roomId}/open`으로 초기 메시지와 상대방 정보를 조회한다.
|
||||
- `openRoom` 성공 후 WebSocket `/ws/v2/user-creator-chat`에 연결한다.
|
||||
- WebSocket handshake에는 기존 API와 동일한 access token을 `Authorization: Bearer <accessToken>` 헤더로 전달한다.
|
||||
- WebSocket 연결 직후 `JOIN_ROOM` 메시지를 전송한다.
|
||||
- `JOINED`를 수신하면 해당 채팅방이 실시간 수신 상태라고 판단한다.
|
||||
- 기존 SSE `connected` 이벤트 기반 연결 확인 로직은 WebSocket `JOINED` 수신 기준으로 변경한다.
|
||||
- 기존 SSE `message` 이벤트 수신 로직은 WebSocket `MESSAGE` 수신 로직으로 변경한다.
|
||||
- 텍스트 메시지 전송은 기존 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 대신 WebSocket `SEND_TEXT`를 사용한다.
|
||||
- 텍스트 메시지 전송 UI는 `requestId`를 생성해 pending 메시지와 서버 `SEND_ACK`를 매칭한다.
|
||||
- `SEND_ACK`를 수신하면 pending 메시지를 서버가 내려준 `messageId`, `createdAt`, 프로필 정보 기준으로 확정한다.
|
||||
- 상대방 메시지는 `MESSAGE` 이벤트 수신 시 현재 채팅방 메시지 목록에 append한다.
|
||||
- 음성 메시지는 기존 multipart REST API를 유지한다.
|
||||
- 기존 `events/disconnect` 호출 위치는 WebSocket `LEAVE_ROOM` 전송 후 socket close 처리로 대체한다.
|
||||
- 채팅방 화면 이탈, 앱 백그라운드 진입, 로그아웃 시 `LEAVE_ROOM`을 보낸 뒤 WebSocket을 close한다.
|
||||
- 네트워크 오류로 WebSocket이 끊기면 현재 채팅방 화면에 남아 있는 동안에만 재연결한다.
|
||||
- 재연결 성공 후 `JOIN_ROOM`을 다시 보내고, 필요하면 REST `messages` API로 누락 메시지를 동기화한다.
|
||||
- 기존 SSE retry/backoff 코드는 WebSocket 재연결 정책으로 대체하고, 채팅방 화면 밖에서는 재연결하지 않는다.
|
||||
- 앱은 heartbeat로 `PING`을 주기적으로 보내고 `PONG`을 수신해 연결 상태를 판단한다.
|
||||
- 푸시 알림을 터치하면 payload의 `deep_link`를 확인해 해당 채팅방 화면으로 이동한다.
|
||||
- 푸시로 채팅방에 진입한 뒤에도 일반 진입과 동일하게 `openRoom` 호출 후 WebSocket 연결/`JOIN_ROOM`을 수행한다.
|
||||
- 클라이언트는 제거된 SSE endpoint와 `events/disconnect` endpoint를 더 이상 호출하지 않는다.
|
||||
- 클라이언트는 기존 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 응답의 `deliveredRealtime`/`pushSent`를 텍스트 전송 UI 판단에 사용하지 않는다. 텍스트 전송 성공 여부는 WebSocket `SEND_ACK`/`ERROR`/timeout으로 판단한다.
|
||||
|
||||
#### Native App Migration Checklist
|
||||
- SSE client 또는 `EventSource` wrapper 제거: `GET /api/v2/user-creator-chat/rooms/{roomId}/events` 호출, `Accept: text/event-stream`, SSE event parser, SSE reconnect/retry timer를 삭제한다.
|
||||
- 연결 확인 기준 변경: SSE `connected` 이벤트 수신 완료를 WebSocket `JOINED` 수신 완료로 대체한다.
|
||||
- 메시지 수신 기준 변경: SSE `message` event payload append를 WebSocket `MESSAGE` envelope payload append로 대체한다.
|
||||
- 연결 해제 기준 변경: `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect` 호출을 제거하고, 같은 lifecycle 위치에서 `LEAVE_ROOM` 메시지를 보낸 뒤 WebSocket을 close한다.
|
||||
- 텍스트 전송 변경: `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 호출을 제거하고, `SEND_TEXT` 메시지와 `SEND_ACK` 매칭 방식으로 pending/성공/실패 상태를 관리한다.
|
||||
- 음성 전송 유지: `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voice` multipart 호출은 유지하되, 음성 전송 후 상대방 실시간 수신 여부는 서버 정책에 따른다.
|
||||
- 토큰 갱신 처리 변경: access token refresh 시 기존 WebSocket을 close하고 새 token의 `Authorization` 헤더로 다시 연결한 뒤 `JOIN_ROOM`을 다시 보낸다.
|
||||
- 푸시 이동 처리 확인: `deep_link`의 `voiceon://chat/{roomId}` 또는 `voiceon-test://chat/{roomId}`를 기준으로 채팅방에 진입하고, 진입 후 `openRoom` 호출과 WebSocket `JOIN_ROOM`을 일반 진입과 동일하게 수행한다.
|
||||
|
||||
#### Edge Cases
|
||||
- WebSocket 연결은 성공했지만 `JOIN_ROOM`이 실패하면 채팅방 화면에 오류를 표시하고 텍스트 전송을 막는다.
|
||||
- `SEND_TEXT` 후 일정 시간 안에 `SEND_ACK`가 오지 않으면 메시지를 전송 실패 상태로 표시하고 재시도 UI를 제공한다.
|
||||
- 재연결 전 pending 메시지를 자동 재전송할 경우 같은 `requestId`를 재사용해 중복 표시를 방지한다.
|
||||
- 앱이 다른 채팅방으로 이동하면 기존 방 WebSocket session을 종료하고 새 방으로 다시 연결한다.
|
||||
- 푸시 payload에 `deep_link`가 없거나 `voiceon://chat/{roomId}` / `voiceon-test://chat/{roomId}` 형식에서 `roomId`를 해석할 수 없으면 채팅방 직접 이동을 하지 않고 앱 기본 알림 처리 흐름을 따른다.
|
||||
|
||||
### Feature G. OSIV 비활성화 사전 점검
|
||||
|
||||
#### Requirements
|
||||
- 현재 `spring.jpa.open-in-view` 설정이 명시되어 있는지 확인한다.
|
||||
- 설정이 명시되어 있지 않으면 Spring Boot 2.7 기본값상 OSIV enabled로 동작할 수 있으므로, 운영 설정에 명시적으로 둘지 여부를 결정한다.
|
||||
- `spring.jpa.open-in-view=false`를 바로 적용하기 전에 트랜잭션 밖 lazy loading 의존 API를 먼저 식별한다.
|
||||
- 점검 대상은 다음 패턴을 포함한다.
|
||||
- controller에서 `@AuthenticationPrincipal`로 받은 `Member`의 LAZY 연관(`auth`, `notification` 등)을 직접 접근하는 코드
|
||||
- controller가 JPA entity 또는 LAZY 연관을 가진 객체를 그대로 `ApiResponse.ok(...)`로 반환하는 코드
|
||||
- `@Transactional`이 없는 service/facade/query method에서 repository 조회 후 LAZY 연관을 DTO 변환에 사용하는 코드
|
||||
- Jackson 직렬화 시점에 JPA entity의 LAZY 연관이 열릴 수 있는 응답
|
||||
- self-invocation 때문에 기대한 `@Transactional`이 적용되지 않는 service 내부 호출
|
||||
- 발견된 항목은 API 경로, 파일 경로, lazy 접근 대상, 권장 수정 방향을 문서에 기록한다.
|
||||
- 권장 수정 방향은 controller에서 lazy 접근을 하지 않고 service/query 계층의 트랜잭션 안에서 DTO projection, fetch join, 명시 조회로 필요한 값을 채우는 것이다.
|
||||
- lazy loading 의존성이 해소된 뒤에만 `spring.jpa.open-in-view=false`를 application 설정에 명시한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 테스트 코드가 `@Transactional`로 감싸져 있으면 OSIV off 문제를 가릴 수 있으므로 controller/MockMvc 또는 실제 HTTP 계층 테스트를 우선한다.
|
||||
- 인증 principal의 `Member`는 JWT filter에서 로드된 엔티티이므로, controller에서 LAZY 연관을 직접 열면 OSIV off 후 실패할 수 있다.
|
||||
- 관리자/크리에이터/사용자 API가 서로 다른 controller 패키지에 흩어져 있으므로 특정 패키지 검색만으로 점검을 끝내지 않는다.
|
||||
- OSIV off 적용 후 일부 API가 실패하면 WebSocket 전환과 섞어 수정하지 않고, lazy loading 제거 task로 분리해 먼저 처리한다.
|
||||
|
||||
---
|
||||
|
||||
## 8. UX / UI Expectations
|
||||
- 채팅방 화면 진입 시 REST `openRoom` 응답으로 초기 화면을 그리고, WebSocket `JOINED` 이후 새 메시지를 실시간으로 append한다.
|
||||
- 채팅방 화면에 있는 동안 같은 방의 메시지 푸시가 나타나지 않아야 한다.
|
||||
- 채팅방 화면 밖에서 푸시를 터치하면 `deep_link`의 `{roomId}`에 해당하는 채팅방으로 이동해야 한다.
|
||||
- WebSocket 재연결 중 사용자가 보낸 메시지는 앱에서 전송 실패 또는 재시도 상태로 표시할 수 있어야 한다.
|
||||
- 앱 백그라운드 진입 또는 화면 이탈 시 `LEAVE_ROOM`을 보내고 WebSocket을 close한다.
|
||||
- 앱은 기존 SSE 연결 코드와 `events/disconnect` 호출 코드를 제거한다.
|
||||
- 앱은 텍스트 메시지 전송 성공 기준을 HTTP 200 응답이 아니라 WebSocket `SEND_ACK` 수신으로 변경한다.
|
||||
|
||||
---
|
||||
|
||||
## 9. Technical Constraints
|
||||
- Kotlin + Java 17 + Spring Boot 2.7.14 + Gradle Wrapper 구조를 유지한다.
|
||||
- WebSocket 구현은 `spring-boot-starter-websocket`을 사용한다.
|
||||
- STOMP는 도입하지 않고 raw WebSocket JSON protocol을 사용한다.
|
||||
- Redis는 현재 연결된 인프라를 사용한다.
|
||||
- RedisTemplate 또는 Redisson 중 기존 코드 패턴과 테스트 용이성을 기준으로 선택하되, presence TTL과 pub/sub을 모두 구현해야 한다.
|
||||
- Redis presence TTL, session set 정리, pub/sub listener 전달은 mock 검증만으로 완료하지 않고 embedded Redis 또는 동등한 실제 Redis 테스트 인프라로 통합 검증한다.
|
||||
- `spring.jpa.open-in-view=false`는 lazy loading 의존 API 점검과 수정이 끝난 뒤 명시한다.
|
||||
- 공개 REST API 스키마는 필요한 범위 외에는 변경하지 않는다.
|
||||
- 텍스트 메시지 저장은 기존 `UserCreatorChatMessage` 엔티티와 repository를 사용한다.
|
||||
- 음성 메시지 업로드는 기존 REST multipart API를 유지한다.
|
||||
- 기존 `FcmEvent`/`FcmService` 구조를 우선 재사용한다.
|
||||
- iOS/Android 클라이언트는 WebSocket 전용 연결을 일반 REST API 호출과 분리해 관리한다.
|
||||
- iOS/Android 클라이언트는 access token refresh 시 기존 WebSocket을 닫고 새 token으로 재연결한다.
|
||||
|
||||
---
|
||||
|
||||
## 10. Metrics
|
||||
- 채팅방 화면에 접속 중인 수신자에게 같은 방 메시지 푸시가 발송된 건수 0건
|
||||
- 채팅방 화면 밖 수신자에게 메시지 푸시가 누락된 건수 0건
|
||||
- WebSocket `JOIN_ROOM` 성공률
|
||||
- WebSocket 연결 중 메시지 전송 성공률
|
||||
- Redis presence TTL 만료로 정리된 orphan session 수
|
||||
|
||||
---
|
||||
|
||||
## 11. Open Questions
|
||||
- 없음. 이번 문서 기준 확정안은 채팅방 화면 진입 중에만 유지하는 raw WebSocket, Redis 기반 다중 서버 presence/pub-sub, 기존 SSE 완전 제거다.
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
> 이 문서는 현재 요청 범위인 문서 작성만 다룬다. 실제 구현 시에는 이 계획의 구현 항목을 기준으로 세부 API, 엔티티, 테스트를 별도 작업 단위로 쪼갠다.
|
||||
|
||||
> 최신화: 실시간 전송 정책은 `docs/20260618_유저크리에이터채팅_WebSocket전환/` 문서로 대체되었다. 이 문서의 SSE(`SseEmitter`) 결정/연동 문구는 2026-05 초기 계획의 역사적 기록이며, 현재 구현 기준은 raw WebSocket + Redis presence/pub-sub이다.
|
||||
|
||||
## 결정 요약
|
||||
|
||||
- 새 기능부터 유저-크리에이터 채팅방을 제공한다.
|
||||
@@ -201,8 +203,10 @@ CREATE TABLE user_creator_chat_message (
|
||||
- 모든 요청은 `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초까지 지수 백오프한다.
|
||||
- 채팅방 화면 진입 시 `openRoom` REST 호출 후 WebSocket `/ws/v2/user-creator-chat`에 연결한다.
|
||||
- WebSocket 연결 직후 `JOIN_ROOM`을 보내고, `JOINED` 수신 후 텍스트 전송을 허용한다.
|
||||
- 앱이 백그라운드로 전환되거나 채팅방 화면에서 나가면 `LEAVE_ROOM`을 보낸 뒤 WebSocket을 close한다.
|
||||
- 현재 채팅방 화면에 남아 있는 동안 WebSocket이 끊기면 지수 백오프로 재연결하고 `JOIN_ROOM`을 다시 보낸다.
|
||||
|
||||
연동할 API:
|
||||
0. 채팅방 리스트 조회
|
||||
@@ -225,16 +229,16 @@ CREATE TABLE user_creator_chat_message (
|
||||
- `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`는 연결 확인용으로만 사용한다.
|
||||
4. WebSocket 연결
|
||||
- endpoint: `/ws/v2/user-creator-chat`
|
||||
- handshake header: `Authorization: Bearer <accessToken>`
|
||||
- 연결 직후 `{ "type": "JOIN_ROOM", "requestId": "client-request-id", "roomId": roomId, "payload": {} }`를 전송한다.
|
||||
- `JOINED`를 수신하면 현재 방 실시간 수신 상태로 판단한다.
|
||||
|
||||
5. 텍스트 메시지 전송
|
||||
- `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text`
|
||||
- body: `{ "textMessage": string }`
|
||||
- response data: `{ "message", "deliveredRealtime", "pushSent" }`
|
||||
5. 텍스트 메시지 전송(WebSocket)
|
||||
- `{ "type": "SEND_TEXT", "requestId": "client-request-id", "roomId": roomId, "payload": { "textMessage": string } }`를 전송한다.
|
||||
- `SEND_ACK` 수신 시 pending 메시지를 서버 응답의 `messageId`, `createdAt`, 프로필 정보 기준으로 확정한다.
|
||||
- `MESSAGE` 수신 시 현재 채팅방 `roomId`와 일치하면 메시지 목록에 append한다.
|
||||
|
||||
6. 음성 메시지 전송
|
||||
- `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voice`
|
||||
@@ -243,9 +247,15 @@ CREATE TABLE user_creator_chat_message (
|
||||
- part `request`: `{}` JSON 문자열
|
||||
- response data: `{ "message", "deliveredRealtime", "pushSent" }`
|
||||
|
||||
7. 실시간 연결 해제
|
||||
- `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect`
|
||||
- DB 참여자를 삭제하거나 비활성화하지 않고 SSE/presence 상태만 해제한다.
|
||||
7. 실시간 연결 해제(WebSocket)
|
||||
- `{ "type": "LEAVE_ROOM", "requestId": "client-request-id", "roomId": roomId, "payload": {} }`를 전송한 뒤 WebSocket을 close한다.
|
||||
- DB 참여자를 삭제하거나 비활성화하지 않고 WebSocket presence 상태만 해제한다.
|
||||
|
||||
제거된 호출:
|
||||
- `GET /api/v2/user-creator-chat/rooms/{roomId}/events`
|
||||
- `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect`
|
||||
- `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text`
|
||||
- SSE reconnect/retry 처리 코드
|
||||
|
||||
메시지 DTO 필드:
|
||||
- `messageId`: number
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# PRD: 유저-크리에이터 채팅방 개편
|
||||
|
||||
> 최신화: 실시간 전송 정책은 `docs/20260618_유저크리에이터채팅_WebSocket전환/` 문서로 대체되었다. 이 문서의 SSE(`SseEmitter`) 요구사항은 2026-05 초기 계획의 역사적 기록이며, 현재 구현 기준은 raw WebSocket + Redis presence/pub-sub이다.
|
||||
|
||||
## 1. Overview
|
||||
유저와 크리에이터가 주고받는 신규 메시지를 채팅방 형태로 제공하고, 텍스트/음성 메시지, 실시간 수신, 조건부 푸시 알림을 지원한다.
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ class AuditionController(private val service: AuditionService) {
|
||||
service.getAuditionList(
|
||||
offset = pageable.offset,
|
||||
limit = pageable.pageSize.toLong(),
|
||||
isAdult = member?.auth != null
|
||||
memberId = member?.id
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package kr.co.vividnext.sodalive.audition
|
||||
|
||||
import kr.co.vividnext.sodalive.audition.role.AuditionRoleRepository
|
||||
import kr.co.vividnext.sodalive.member.auth.AuthRepository
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class AuditionService(
|
||||
private val repository: AuditionRepository,
|
||||
private val roleRepository: AuditionRoleRepository
|
||||
private val roleRepository: AuditionRoleRepository,
|
||||
private val authRepository: AuthRepository
|
||||
) {
|
||||
fun getAuditionList(offset: Long, limit: Long, isAdult: Boolean): GetAuditionListResponse {
|
||||
val inProgressCount = repository.getInProgressAuditionCount(isAdult = isAdult)
|
||||
@@ -16,6 +18,11 @@ class AuditionService(
|
||||
return GetAuditionListResponse(inProgressCount, completedCount, items)
|
||||
}
|
||||
|
||||
fun getAuditionList(offset: Long, limit: Long, memberId: Long?): GetAuditionListResponse {
|
||||
val isAdult = memberId?.let { authRepository.getAuthIdByMemberId(it) != null } ?: false
|
||||
return getAuditionList(offset = offset, limit = limit, isAdult = isAdult)
|
||||
}
|
||||
|
||||
fun getAuditionDetail(auditionId: Long): GetAuditionDetailResponse {
|
||||
val auditionDetail = repository.getAuditionDetail(auditionId = auditionId)
|
||||
val roleList = roleRepository.getAuditionRoleListByAuditionId(auditionId = auditionId)
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.springframework.data.redis.connection.RedisStandaloneConfiguration
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
|
||||
import org.springframework.data.redis.core.RedisTemplate
|
||||
import org.springframework.data.redis.listener.RedisMessageListenerContainer
|
||||
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories
|
||||
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer
|
||||
import org.springframework.data.redis.serializer.RedisSerializationContext
|
||||
@@ -63,6 +64,13 @@ class RedisConfig(
|
||||
return redisTemplate
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun redisMessageListenerContainer(redisConnectionFactory: RedisConnectionFactory): RedisMessageListenerContainer {
|
||||
val container = RedisMessageListenerContainer()
|
||||
container.setConnectionFactory(redisConnectionFactory)
|
||||
return container
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun cacheManager(redisConnectionFactory: RedisConnectionFactory): RedisCacheManager {
|
||||
val defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
|
||||
|
||||
@@ -39,14 +39,12 @@ class AudioContentCommentController(
|
||||
try {
|
||||
userActionService.recordAction(
|
||||
memberId = member.id!!,
|
||||
isAuth = member.auth != null,
|
||||
actionType = ActionType.CONTENT_COMMENT,
|
||||
contentCommentId = commentId
|
||||
)
|
||||
|
||||
userActionService.recordAction(
|
||||
memberId = member.id!!,
|
||||
isAuth = member.auth != null,
|
||||
actionType = ActionType.ORDER_CONTENT_COMMENT,
|
||||
contentId = request.contentId,
|
||||
contentCommentId = commentId
|
||||
|
||||
@@ -19,6 +19,6 @@ class CreatorAdminContentSeriesGenreController(private val service: CreatorAdmin
|
||||
) = run {
|
||||
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
|
||||
ApiResponse.ok(service.getGenreList(isAdult = member.auth != null))
|
||||
ApiResponse.ok(service.getGenreList(memberId = member.id!!))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
package kr.co.vividnext.sodalive.creator.admin.content.series.genre
|
||||
|
||||
import kr.co.vividnext.sodalive.member.auth.AuthRepository
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class CreatorAdminContentSeriesGenreService(private val repository: CreatorAdminContentSeriesGenreRepository) {
|
||||
class CreatorAdminContentSeriesGenreService(
|
||||
private val repository: CreatorAdminContentSeriesGenreRepository,
|
||||
private val authRepository: AuthRepository
|
||||
) {
|
||||
fun getGenreList(isAdult: Boolean): List<GetGenreListResponse> {
|
||||
return repository.getGenreList(isAdult = isAdult)
|
||||
}
|
||||
|
||||
fun getGenreList(memberId: Long): List<GetGenreListResponse> {
|
||||
val isAdult = authRepository.getAuthIdByMemberId(memberId) != null
|
||||
return getGenreList(isAdult = isAdult)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.event
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
@@ -24,11 +23,8 @@ class EventController(private val service: EventService) {
|
||||
) = run {
|
||||
ApiResponse.ok(
|
||||
service.getEventList(
|
||||
if (member?.role == MemberRole.ADMIN) {
|
||||
null
|
||||
} else {
|
||||
member?.auth != null
|
||||
}
|
||||
memberId = member?.id,
|
||||
memberRole = member?.role
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -36,7 +32,7 @@ class EventController(private val service: EventService) {
|
||||
@GetMapping("/popup")
|
||||
fun getEventPopup(
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = ApiResponse.ok(service.getEventPopup(member?.auth != null))
|
||||
) = ApiResponse.ok(service.getEventPopup(memberId = member?.id))
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
|
||||
@@ -3,6 +3,8 @@ package kr.co.vividnext.sodalive.event
|
||||
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.auth.AuthRepository
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
@@ -17,6 +19,7 @@ import java.time.format.DateTimeFormatter
|
||||
class EventService(
|
||||
private val repository: EventRepository,
|
||||
private val s3Uploader: S3Uploader,
|
||||
private val authRepository: AuthRepository,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val bucket: String,
|
||||
@@ -45,6 +48,16 @@ class EventService(
|
||||
return GetEventResponse(0, eventList)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getEventList(memberId: Long?, memberRole: MemberRole?): GetEventResponse {
|
||||
val isAdult = if (memberRole == MemberRole.ADMIN) {
|
||||
null
|
||||
} else {
|
||||
memberId?.let { authRepository.getAuthIdByMemberId(it) != null } ?: false
|
||||
}
|
||||
return getEventList(isAdult)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getEventPopup(isAdult: Boolean): EventItem? {
|
||||
val eventPopup = repository.getMainEventPopup(isAdult)
|
||||
@@ -66,6 +79,12 @@ class EventService(
|
||||
return eventPopup
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getEventPopup(memberId: Long?): EventItem? {
|
||||
val isAdult = memberId?.let { authRepository.getAuthIdByMemberId(it) != null } ?: false
|
||||
return getEventPopup(isAdult)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun save(
|
||||
thumbnail: MultipartFile,
|
||||
|
||||
@@ -24,7 +24,8 @@ enum class FcmDeepLinkValue(val value: String) {
|
||||
CONTENT("content"),
|
||||
SERIES("series"),
|
||||
AUDITION("audition"),
|
||||
COMMUNITY("community")
|
||||
COMMUNITY("community"),
|
||||
CHAT("chat")
|
||||
}
|
||||
|
||||
class FcmEvent(
|
||||
@@ -45,6 +46,7 @@ class FcmEvent(
|
||||
val roomId: Long? = null,
|
||||
val contentId: Long? = null,
|
||||
val messageId: Long? = null,
|
||||
val chatType: String? = null,
|
||||
val creatorId: Long? = null,
|
||||
val auditionId: Long? = null,
|
||||
val deepLinkValue: FcmDeepLinkValue? = null,
|
||||
@@ -191,6 +193,7 @@ class FcmSendListener(
|
||||
roomId = roomId ?: fcmEvent.roomId,
|
||||
contentId = contentId ?: fcmEvent.contentId,
|
||||
messageId = messageId ?: fcmEvent.messageId,
|
||||
chatType = fcmEvent.chatType,
|
||||
creatorId = creatorId ?: fcmEvent.creatorId,
|
||||
auditionId = auditionId ?: fcmEvent.auditionId,
|
||||
deepLinkValue = fcmEvent.deepLinkValue,
|
||||
|
||||
@@ -33,7 +33,8 @@ class FcmService(
|
||||
auditionId: Long? = null,
|
||||
deepLinkValue: FcmDeepLinkValue? = null,
|
||||
deepLinkId: Long? = null,
|
||||
deepLinkCommentPostId: Long? = null
|
||||
deepLinkCommentPostId: Long? = null,
|
||||
chatType: String? = null
|
||||
) {
|
||||
if (tokens.isEmpty()) return
|
||||
logger.info("os: $container")
|
||||
@@ -70,30 +71,17 @@ class FcmService(
|
||||
.build()
|
||||
)
|
||||
|
||||
if (roomId != null) {
|
||||
multicastMessage.putData("room_id", roomId.toString())
|
||||
}
|
||||
|
||||
if (messageId != null) {
|
||||
multicastMessage.putData("message_id", messageId.toString())
|
||||
}
|
||||
|
||||
if (contentId != null) {
|
||||
multicastMessage.putData("content_id", contentId.toString())
|
||||
}
|
||||
|
||||
if (creatorId != null) {
|
||||
multicastMessage.putData("channel_id", creatorId.toString())
|
||||
}
|
||||
|
||||
if (auditionId != null) {
|
||||
multicastMessage.putData("audition_id", auditionId.toString())
|
||||
}
|
||||
|
||||
val deepLink = createDeepLink(deepLinkValue, deepLinkId, deepLinkCommentPostId)
|
||||
if (deepLink != null) {
|
||||
multicastMessage.putData("deep_link", deepLink)
|
||||
}
|
||||
multicastMessage.putAllData(
|
||||
buildDataPayload(
|
||||
roomId = roomId,
|
||||
messageId = messageId,
|
||||
contentId = contentId,
|
||||
creatorId = creatorId,
|
||||
auditionId = auditionId,
|
||||
deepLink = createDeepLink(deepLinkValue, deepLinkId, deepLinkCommentPostId),
|
||||
chatType = chatType
|
||||
)
|
||||
)
|
||||
|
||||
val response = FirebaseMessaging.getInstance().sendEachForMulticast(multicastMessage.build())
|
||||
val failedTokens = mutableListOf<String>()
|
||||
@@ -226,5 +214,29 @@ class FcmService(
|
||||
|
||||
return baseDeepLink
|
||||
}
|
||||
|
||||
fun buildDataPayload(
|
||||
roomId: Long? = null,
|
||||
messageId: Long? = null,
|
||||
contentId: Long? = null,
|
||||
creatorId: Long? = null,
|
||||
auditionId: Long? = null,
|
||||
deepLinkValue: FcmDeepLinkValue? = null,
|
||||
deepLinkId: Long? = null,
|
||||
deepLinkCommentPostId: Long? = null,
|
||||
deepLink: String? = null,
|
||||
chatType: String? = null
|
||||
): Map<String, String> {
|
||||
val payload = mutableMapOf<String, String>()
|
||||
if (roomId != null) payload["room_id"] = roomId.toString()
|
||||
if (messageId != null) payload["message_id"] = messageId.toString()
|
||||
if (chatType != null) payload["chat_type"] = chatType
|
||||
if (contentId != null) payload["content_id"] = contentId.toString()
|
||||
if (creatorId != null) payload["channel_id"] = creatorId.toString()
|
||||
if (auditionId != null) payload["audition_id"] = auditionId.toString()
|
||||
val resolvedDeepLink = deepLink ?: buildDeepLink("", deepLinkValue, deepLinkId, deepLinkCommentPostId)
|
||||
if (resolvedDeepLink != null) payload["deep_link"] = resolvedDeepLink
|
||||
return payload
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ class UserActionController(private val service: UserActionService) {
|
||||
|
||||
service.recordAction(
|
||||
memberId = member.id!!,
|
||||
isAuth = member.auth != null,
|
||||
actionType = request.actionType
|
||||
)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kr.co.vividnext.sodalive.content.order.Order
|
||||
import kr.co.vividnext.sodalive.content.order.OrderRepository
|
||||
import kr.co.vividnext.sodalive.member.auth.AuthRepository
|
||||
import kr.co.vividnext.sodalive.point.MemberPoint
|
||||
import kr.co.vividnext.sodalive.point.MemberPointRepository
|
||||
import kr.co.vividnext.sodalive.point.PointGrantLog
|
||||
@@ -26,7 +27,8 @@ class UserActionService(
|
||||
private val policyRepository: PointRewardPolicyRepository,
|
||||
private val grantLogRepository: PointGrantLogRepository,
|
||||
private val memberPointRepository: MemberPointRepository,
|
||||
private val transactionTemplate: TransactionTemplate
|
||||
private val transactionTemplate: TransactionTemplate,
|
||||
private val authRepository: AuthRepository
|
||||
) {
|
||||
|
||||
private val coroutineScope = CoroutineScope(
|
||||
@@ -160,6 +162,21 @@ class UserActionService(
|
||||
}
|
||||
}
|
||||
|
||||
fun recordAction(
|
||||
memberId: Long,
|
||||
actionType: ActionType,
|
||||
contentId: Long? = null,
|
||||
contentCommentId: Long? = null
|
||||
) {
|
||||
recordAction(
|
||||
memberId = memberId,
|
||||
isAuth = authRepository.getAuthIdByMemberId(memberId) != null,
|
||||
actionType = actionType,
|
||||
contentId = contentId,
|
||||
contentCommentId = contentCommentId
|
||||
)
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun onDestroy() {
|
||||
coroutineScope.cancel("UserActionService 종료")
|
||||
|
||||
@@ -4,7 +4,6 @@ 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
|
||||
@@ -42,16 +41,6 @@ class UserCreatorChatController(
|
||||
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?,
|
||||
@@ -63,25 +52,6 @@ class UserCreatorChatController(
|
||||
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?,
|
||||
|
||||
@@ -8,10 +8,6 @@ data class CreateUserCreatorChatRoomResponse(
|
||||
val roomId: Long
|
||||
)
|
||||
|
||||
data class SendUserCreatorTextMessageRequest(
|
||||
val textMessage: String
|
||||
)
|
||||
|
||||
data class SendUserCreatorVoiceMessageRequest(
|
||||
val recipientId: Long? = null
|
||||
)
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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.FcmDeepLinkValue
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
|
||||
@@ -18,7 +19,6 @@ 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
|
||||
@@ -26,8 +26,13 @@ import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatRoomOpenRe
|
||||
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.websocket.UserCreatorChatPresenceService
|
||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBroker
|
||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageType
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.dao.DataAccessException
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
@@ -42,7 +47,8 @@ class UserCreatorChatService(
|
||||
private val messageRepository: UserCreatorChatMessageRepository,
|
||||
private val memberRepository: MemberRepository,
|
||||
private val blockMemberRepository: BlockMemberRepository,
|
||||
private val realtimeService: UserCreatorChatRealtimeService,
|
||||
private val presenceService: UserCreatorChatPresenceService,
|
||||
private val roomMessageBroker: UserCreatorChatRoomMessageBroker,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val s3Uploader: S3Uploader,
|
||||
@@ -107,22 +113,19 @@ class UserCreatorChatService(
|
||||
}
|
||||
|
||||
@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)
|
||||
fun sendTextMessageByWebSocket(memberId: Long, roomId: Long, textMessage: String): UserCreatorChatMessageItemDto {
|
||||
if (textMessage.isBlank()) throw SodaException(messageKey = "common.error.invalid_request")
|
||||
val senderParticipant = validateParticipant(roomId, memberId)
|
||||
val sender = senderParticipant.member
|
||||
val context = resolveSendContext(sender, roomId)
|
||||
val message = saveTextMessage(context, textMessage)
|
||||
val senderMessage = toMessageItemDto(message, sender)
|
||||
val opponent = context.opponentParticipant.member
|
||||
val deliveredRealtime = deliverRealtime(message, opponent)
|
||||
if (!deliveredRealtime) {
|
||||
publishMessagePush(message, sender, opponent)
|
||||
}
|
||||
return senderMessage
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -149,17 +152,22 @@ class UserCreatorChatService(
|
||||
filePath = "user_creator_chat_voice/${message.id}/${generateFileName(prefix = "${message.id}-message-")}",
|
||||
metadata = metadata
|
||||
)
|
||||
return deliverMessage(message, member, context.opponentParticipant)
|
||||
return deliverRestMessage(message, member, context.opponentParticipant)
|
||||
}
|
||||
|
||||
fun connect(member: Member, roomId: Long) = run {
|
||||
requireParticipant(roomId, member.id!!)
|
||||
realtimeService.connect(roomId, member.id!!)
|
||||
fun validateParticipant(roomId: Long, memberId: Long): UserCreatorChatParticipant {
|
||||
return requireParticipant(roomId, memberId)
|
||||
}
|
||||
|
||||
fun disconnectRealtime(member: Member, roomId: Long) {
|
||||
requireParticipant(roomId, member.id!!)
|
||||
realtimeService.disconnect(roomId, member.id!!)
|
||||
private fun saveTextMessage(context: SendContext, textMessage: String): UserCreatorChatMessage {
|
||||
return messageRepository.save(
|
||||
UserCreatorChatMessage(
|
||||
chatRoom = context.room,
|
||||
participant = context.senderParticipant,
|
||||
messageType = UserCreatorChatMessageType.TEXT,
|
||||
textMessage = textMessage
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveSendContext(member: Member, roomId: Long): SendContext {
|
||||
@@ -171,23 +179,47 @@ class UserCreatorChatService(
|
||||
return SendContext(room, senderParticipant, opponentParticipant)
|
||||
}
|
||||
|
||||
private fun deliverMessage(
|
||||
private fun deliverRestMessage(
|
||||
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)
|
||||
val deliveredRealtime = deliverRealtime(message, opponent)
|
||||
if (deliveredRealtime) {
|
||||
return SendUserCreatorChatMessageResponse(message = item, deliveredRealtime = true, pushSent = false)
|
||||
}
|
||||
|
||||
publishMessagePush(message, member, opponent)
|
||||
return SendUserCreatorChatMessageResponse(message = item, deliveredRealtime = false, pushSent = true)
|
||||
}
|
||||
|
||||
private fun deliverRealtime(message: UserCreatorChatMessage, opponent: Member): Boolean {
|
||||
val roomId = message.chatRoom.id!!
|
||||
val opponentId = opponent.id!!
|
||||
return try {
|
||||
if (!presenceService.hasPresence(roomId, opponentId)) {
|
||||
return false
|
||||
}
|
||||
val opponentMessage = toMessageItemDto(message, opponent)
|
||||
roomMessageBroker.publish(
|
||||
roomId = roomId,
|
||||
memberId = opponentId,
|
||||
payload = websocketMessagePayload(UserCreatorChatWebSocketMessageType.MESSAGE, roomId, opponentMessage)
|
||||
)
|
||||
true
|
||||
} catch (e: DataAccessException) {
|
||||
logger.warn(
|
||||
"유저-크리에이터 채팅 실시간 전달 Redis 오류로 푸시 fail-open 처리: roomId={}, opponentId={}, cause={}",
|
||||
roomId,
|
||||
opponentId,
|
||||
e.message
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishMessagePush(message: UserCreatorChatMessage, sender: Member, opponent: Member) {
|
||||
val messageKey = if (message.messageType == UserCreatorChatMessageType.VOICE) {
|
||||
"message.fcm.voice_received"
|
||||
@@ -203,7 +235,23 @@ class UserCreatorChatService(
|
||||
senderMemberId = sender.id,
|
||||
args = listOf(sender.nickname),
|
||||
recipients = listOf(opponent.id!!),
|
||||
messageId = message.id
|
||||
deepLinkValue = FcmDeepLinkValue.CHAT,
|
||||
deepLinkId = message.chatRoom.id
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun websocketMessagePayload(
|
||||
type: UserCreatorChatWebSocketMessageType,
|
||||
roomId: Long,
|
||||
payload: UserCreatorChatMessageItemDto
|
||||
): String {
|
||||
return objectMapper.writeValueAsString(
|
||||
mapOf(
|
||||
"type" to type,
|
||||
"requestId" to null,
|
||||
"roomId" to roomId,
|
||||
"payload" to payload
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -250,4 +298,8 @@ class UserCreatorChatService(
|
||||
val senderParticipant: UserCreatorChatParticipant,
|
||||
val opponentParticipant: UserCreatorChatParticipant
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val logger = LoggerFactory.getLogger(UserCreatorChatService::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import org.springframework.beans.factory.annotation.Qualifier
|
||||
import org.springframework.data.redis.core.StringRedisTemplate
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
@Service
|
||||
class UserCreatorChatPresenceService(
|
||||
private val stringRedisTemplate: StringRedisTemplate,
|
||||
private val objectMapper: ObjectMapper,
|
||||
@Qualifier("userCreatorChatWebSocketServerId") private val serverId: String
|
||||
) {
|
||||
fun markJoined(roomId: Long, memberId: Long, sessionId: String) {
|
||||
stringRedisTemplate.opsForValue().set(
|
||||
presenceKey(roomId, memberId, sessionId),
|
||||
presenceJson(roomId, memberId, sessionId),
|
||||
PRESENCE_TTL
|
||||
)
|
||||
stringRedisTemplate.opsForSet().add(memberSessionsKey(roomId, memberId), sessionId)
|
||||
stringRedisTemplate.expire(memberSessionsKey(roomId, memberId), PRESENCE_TTL)
|
||||
stringRedisTemplate.opsForSet().add(roomKey(roomId), memberId.toString())
|
||||
stringRedisTemplate.expire(roomKey(roomId), PRESENCE_TTL)
|
||||
}
|
||||
|
||||
fun refresh(roomId: Long, memberId: Long, sessionId: String) {
|
||||
stringRedisTemplate.opsForValue().set(
|
||||
presenceKey(roomId, memberId, sessionId),
|
||||
presenceJson(roomId, memberId, sessionId),
|
||||
PRESENCE_TTL
|
||||
)
|
||||
stringRedisTemplate.expire(memberSessionsKey(roomId, memberId), PRESENCE_TTL)
|
||||
stringRedisTemplate.expire(roomKey(roomId), PRESENCE_TTL)
|
||||
}
|
||||
|
||||
fun markLeft(roomId: Long, memberId: Long, sessionId: String) {
|
||||
stringRedisTemplate.delete(presenceKey(roomId, memberId, sessionId))
|
||||
stringRedisTemplate.opsForSet().remove(memberSessionsKey(roomId, memberId), sessionId)
|
||||
|
||||
removeMemberPresenceIfNoLiveSession(roomId, memberId)
|
||||
}
|
||||
|
||||
fun hasPresence(roomId: Long, memberId: Long): Boolean {
|
||||
return findLiveSessionIds(roomId, memberId).isNotEmpty()
|
||||
}
|
||||
|
||||
private fun removeMemberPresenceIfNoLiveSession(roomId: Long, memberId: Long) {
|
||||
if (findLiveSessionIds(roomId, memberId).isNotEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
stringRedisTemplate.delete(memberSessionsKey(roomId, memberId))
|
||||
stringRedisTemplate.opsForSet().remove(roomKey(roomId), memberId.toString())
|
||||
}
|
||||
|
||||
private fun findLiveSessionIds(roomId: Long, memberId: Long): Set<String> {
|
||||
val sessionIds = stringRedisTemplate.opsForSet().members(memberSessionsKey(roomId, memberId)) ?: emptySet()
|
||||
return sessionIds.filterTo(mutableSetOf()) { sessionId ->
|
||||
val isLive = stringRedisTemplate.hasKey(presenceKey(roomId, memberId, sessionId)) == true
|
||||
if (!isLive) {
|
||||
stringRedisTemplate.opsForSet().remove(memberSessionsKey(roomId, memberId), sessionId)
|
||||
}
|
||||
isLive
|
||||
}
|
||||
}
|
||||
|
||||
private fun presenceJson(roomId: Long, memberId: Long, sessionId: String): String {
|
||||
return objectMapper.writeValueAsString(
|
||||
UserCreatorChatRedisPresence(
|
||||
serverId = serverId,
|
||||
memberId = memberId,
|
||||
roomId = roomId,
|
||||
sessionId = sessionId,
|
||||
lastSeenAt = Instant.now()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val PRESENCE_TTL = Duration.ofSeconds(90)
|
||||
|
||||
fun presenceKey(roomId: Long, memberId: Long, sessionId: String): String {
|
||||
return "v2:user-creator-chat:ws:presence:$roomId:$memberId:$sessionId"
|
||||
}
|
||||
|
||||
fun memberSessionsKey(roomId: Long, memberId: Long): String {
|
||||
return "v2:user-creator-chat:ws:room:$roomId:member:$memberId:sessions"
|
||||
}
|
||||
|
||||
fun roomKey(roomId: Long): String {
|
||||
return "v2:user-creator-chat:ws:room:$roomId"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class UserCreatorChatRedisPresence(
|
||||
val serverId: String,
|
||||
val memberId: Long,
|
||||
val roomId: Long,
|
||||
val sessionId: String,
|
||||
val lastSeenAt: Instant
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import org.springframework.data.redis.connection.Message
|
||||
import org.springframework.data.redis.connection.MessageListener
|
||||
import org.springframework.data.redis.core.StringRedisTemplate
|
||||
import org.springframework.data.redis.listener.PatternTopic
|
||||
import org.springframework.data.redis.listener.RedisMessageListenerContainer
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.socket.TextMessage
|
||||
import org.springframework.web.socket.WebSocketSession
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
@Component
|
||||
class UserCreatorChatRoomMessageBroker(
|
||||
private val stringRedisTemplate: StringRedisTemplate,
|
||||
private val sessionRegistry: UserCreatorChatWebSocketSessionRegistry,
|
||||
private val objectMapper: ObjectMapper,
|
||||
listenerContainer: RedisMessageListenerContainer
|
||||
) : MessageListener {
|
||||
init {
|
||||
listenerContainer.addMessageListener(this, PatternTopic("$ROOM_CHANNEL_PREFIX:*"))
|
||||
}
|
||||
|
||||
fun publish(roomId: Long, memberId: Long, payload: String) {
|
||||
val message = UserCreatorChatRoomPublishedMessage(
|
||||
roomId = roomId,
|
||||
memberId = memberId,
|
||||
payload = payload
|
||||
)
|
||||
stringRedisTemplate.convertAndSend(roomChannel(roomId), objectMapper.writeValueAsString(message))
|
||||
}
|
||||
|
||||
override fun onMessage(message: Message, pattern: ByteArray?) {
|
||||
val published = objectMapper.readValue(
|
||||
String(message.body, StandardCharsets.UTF_8),
|
||||
UserCreatorChatRoomPublishedMessage::class.java
|
||||
)
|
||||
sessionRegistry.findSessions(published.roomId, published.memberId)
|
||||
.filter { session -> session.isOpen }
|
||||
.forEach { session -> sendMessage(session, published.payload) }
|
||||
}
|
||||
|
||||
private fun sendMessage(session: WebSocketSession, payload: String) {
|
||||
try {
|
||||
session.sendMessage(TextMessage(payload))
|
||||
} catch (_: Exception) {
|
||||
sessionRegistry.remove(session.id)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ROOM_CHANNEL_PREFIX = "v2:user-creator-chat:ws:room"
|
||||
|
||||
fun roomChannel(roomId: Long): String {
|
||||
return "$ROOM_CHANNEL_PREFIX:$roomId"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class UserCreatorChatRoomPublishedMessage(
|
||||
val roomId: Long,
|
||||
val memberId: Long,
|
||||
val payload: String
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
|
||||
|
||||
import kr.co.vividnext.sodalive.jwt.JwtFilter
|
||||
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import org.springframework.http.server.ServerHttpRequest
|
||||
import org.springframework.http.server.ServerHttpResponse
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.util.StringUtils
|
||||
import org.springframework.web.socket.WebSocketHandler
|
||||
import org.springframework.web.socket.server.HandshakeInterceptor
|
||||
|
||||
@Component
|
||||
class UserCreatorChatWebSocketAuthInterceptor(
|
||||
private val tokenProvider: TokenProvider
|
||||
) : HandshakeInterceptor {
|
||||
override fun beforeHandshake(
|
||||
request: ServerHttpRequest,
|
||||
response: ServerHttpResponse,
|
||||
wsHandler: WebSocketHandler,
|
||||
attributes: MutableMap<String, Any>
|
||||
): Boolean {
|
||||
val token = resolveToken(request) ?: return false
|
||||
if (!tokenProvider.validateToken(token)) {
|
||||
return false
|
||||
}
|
||||
|
||||
val authentication = try {
|
||||
tokenProvider.getAuthentication(token)
|
||||
} catch (e: RuntimeException) {
|
||||
return false
|
||||
}
|
||||
val principal = authentication.principal as? MemberAdapter ?: return false
|
||||
val memberId = principal.member.id ?: return false
|
||||
|
||||
attributes[MEMBER_ID_ATTRIBUTE] = memberId
|
||||
attributes[AUTHENTICATION_ATTRIBUTE] = authentication
|
||||
return true
|
||||
}
|
||||
|
||||
override fun afterHandshake(
|
||||
request: ServerHttpRequest,
|
||||
response: ServerHttpResponse,
|
||||
wsHandler: WebSocketHandler,
|
||||
exception: Exception?
|
||||
) {
|
||||
}
|
||||
|
||||
private fun resolveToken(request: ServerHttpRequest): String? {
|
||||
val bearerToken = request.headers.getFirst(JwtFilter.AUTHORIZATION_HEADER)
|
||||
if (StringUtils.hasText(bearerToken) && bearerToken!!.startsWith(BEARER_PREFIX)) {
|
||||
return bearerToken.substring(BEARER_PREFIX.length)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MEMBER_ID_ATTRIBUTE = "memberId"
|
||||
const val AUTHENTICATION_ATTRIBUTE = "authentication"
|
||||
private const val BEARER_PREFIX = "Bearer "
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
|
||||
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocket
|
||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer
|
||||
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry
|
||||
|
||||
@Configuration
|
||||
@EnableWebSocket
|
||||
class UserCreatorChatWebSocketConfig(
|
||||
private val handler: UserCreatorChatWebSocketHandler,
|
||||
private val authInterceptor: UserCreatorChatWebSocketAuthInterceptor
|
||||
) : WebSocketConfigurer {
|
||||
override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
|
||||
registry.addHandler(handler, ENDPOINT)
|
||||
.addInterceptors(authInterceptor)
|
||||
.setAllowedOrigins(
|
||||
"http://localhost:8888",
|
||||
"https://creator.sodalive.net",
|
||||
"https://test-creator.sodalive.net",
|
||||
"https://test-admin.sodalive.net",
|
||||
"https://admin.sodalive.net"
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ENDPOINT = "/ws/v2/user-creator-chat"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.socket.CloseStatus
|
||||
import org.springframework.web.socket.TextMessage
|
||||
import org.springframework.web.socket.WebSocketSession
|
||||
import org.springframework.web.socket.handler.TextWebSocketHandler
|
||||
|
||||
@Component
|
||||
class UserCreatorChatWebSocketHandler(
|
||||
private val service: UserCreatorChatService,
|
||||
private val presenceService: UserCreatorChatPresenceService,
|
||||
private val sessionRegistry: UserCreatorChatWebSocketSessionRegistry,
|
||||
private val objectMapper: ObjectMapper
|
||||
) : TextWebSocketHandler() {
|
||||
override fun handleTextMessage(session: WebSocketSession, message: TextMessage) {
|
||||
val request = try {
|
||||
objectMapper.readValue(message.payload, UserCreatorChatWebSocketMessage::class.java)
|
||||
} catch (e: Exception) {
|
||||
sendProtocolError(session)
|
||||
return
|
||||
}
|
||||
when (request.type) {
|
||||
UserCreatorChatWebSocketMessageType.JOIN_ROOM -> handleJoinRoom(session, request)
|
||||
UserCreatorChatWebSocketMessageType.SEND_TEXT -> handleSendText(session, request)
|
||||
UserCreatorChatWebSocketMessageType.LEAVE_ROOM -> handleLeaveRoom(session, request)
|
||||
UserCreatorChatWebSocketMessageType.PING -> handlePing(session, request)
|
||||
else -> sendError(session, request, "common.error.invalid_request")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleJoinRoom(session: WebSocketSession, request: UserCreatorChatWebSocketMessage) {
|
||||
try {
|
||||
val memberId = memberId(session)
|
||||
service.validateParticipant(roomId = request.roomId, memberId = memberId)
|
||||
clearJoinedRoom(session)
|
||||
sessionRegistry.register(roomId = request.roomId, memberId = memberId, session = session)
|
||||
presenceService.markJoined(roomId = request.roomId, memberId = memberId, sessionId = session.id)
|
||||
session.attributes[JOINED_ROOM_ID_ATTRIBUTE] = request.roomId
|
||||
sendEnvelope(
|
||||
session,
|
||||
UserCreatorChatWebSocketMessageType.JOINED,
|
||||
request.requestId,
|
||||
request.roomId,
|
||||
emptyMap<String, Any>()
|
||||
)
|
||||
} catch (e: SodaException) {
|
||||
sendError(session, request, e.messageKey ?: "common.error.invalid_request")
|
||||
session.close(CloseStatus.POLICY_VIOLATION)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSendText(session: WebSocketSession, request: UserCreatorChatWebSocketMessage) {
|
||||
try {
|
||||
requireJoinedRoom(session, request.roomId)
|
||||
val textMessage = request.payload["textMessage"]?.asText().orEmpty()
|
||||
val message = service.sendTextMessageByWebSocket(
|
||||
memberId = memberId(session),
|
||||
roomId = request.roomId,
|
||||
textMessage = textMessage
|
||||
)
|
||||
sendEnvelope(session, UserCreatorChatWebSocketMessageType.SEND_ACK, request.requestId, request.roomId, message)
|
||||
} catch (e: SodaException) {
|
||||
sendError(session, request, e.messageKey ?: "common.error.invalid_request")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleLeaveRoom(session: WebSocketSession, request: UserCreatorChatWebSocketMessage) {
|
||||
try {
|
||||
requireJoinedRoom(session, request.roomId)
|
||||
clearJoinedRoom(session)
|
||||
} catch (e: SodaException) {
|
||||
sendError(session, request, e.messageKey ?: "common.error.invalid_request")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePing(session: WebSocketSession, request: UserCreatorChatWebSocketMessage) {
|
||||
try {
|
||||
requireJoinedRoom(session, request.roomId)
|
||||
presenceService.refresh(roomId = request.roomId, memberId = memberId(session), sessionId = session.id)
|
||||
sendEnvelope(
|
||||
session,
|
||||
UserCreatorChatWebSocketMessageType.PONG,
|
||||
request.requestId,
|
||||
request.roomId,
|
||||
emptyMap<String, Any>()
|
||||
)
|
||||
} catch (e: SodaException) {
|
||||
sendError(session, request, e.messageKey ?: "common.error.invalid_request")
|
||||
}
|
||||
}
|
||||
|
||||
override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) {
|
||||
clearJoinedRoom(session)
|
||||
}
|
||||
|
||||
private fun memberId(session: WebSocketSession): Long {
|
||||
return session.attributes[UserCreatorChatWebSocketAuthInterceptor.MEMBER_ID_ATTRIBUTE] as? Long
|
||||
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
}
|
||||
|
||||
private fun clearJoinedRoom(session: WebSocketSession) {
|
||||
val roomId = session.attributes.remove(JOINED_ROOM_ID_ATTRIBUTE) as? Long
|
||||
if (roomId != null) {
|
||||
presenceService.markLeft(roomId = roomId, memberId = memberId(session), sessionId = session.id)
|
||||
}
|
||||
sessionRegistry.remove(session.id)
|
||||
}
|
||||
|
||||
private fun requireJoinedRoom(session: WebSocketSession, roomId: Long) {
|
||||
if (session.attributes[JOINED_ROOM_ID_ATTRIBUTE] != roomId) {
|
||||
throw SodaException(messageKey = "chat.room.join_required")
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendError(session: WebSocketSession, request: UserCreatorChatWebSocketMessage, messageKey: String) {
|
||||
sendEnvelope(
|
||||
session,
|
||||
UserCreatorChatWebSocketMessageType.ERROR,
|
||||
request.requestId,
|
||||
request.roomId,
|
||||
mapOf("messageKey" to messageKey)
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendProtocolError(session: WebSocketSession) {
|
||||
sendEnvelope(
|
||||
session,
|
||||
UserCreatorChatWebSocketMessageType.ERROR,
|
||||
null,
|
||||
0L,
|
||||
mapOf("messageKey" to "common.error.invalid_request")
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendEnvelope(
|
||||
session: WebSocketSession,
|
||||
type: UserCreatorChatWebSocketMessageType,
|
||||
requestId: String?,
|
||||
roomId: Long,
|
||||
payload: Any
|
||||
) {
|
||||
session.sendMessage(
|
||||
TextMessage(
|
||||
objectMapper.writeValueAsString(
|
||||
mapOf(
|
||||
"type" to type,
|
||||
"requestId" to requestId,
|
||||
"roomId" to roomId,
|
||||
"payload" to payload
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val JOINED_ROOM_ID_ATTRIBUTE = "userCreatorChatJoinedRoomId"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
|
||||
data class UserCreatorChatWebSocketMessage(
|
||||
val type: UserCreatorChatWebSocketMessageType,
|
||||
val requestId: String?,
|
||||
val roomId: Long,
|
||||
val payload: JsonNode
|
||||
)
|
||||
|
||||
enum class UserCreatorChatWebSocketMessageType {
|
||||
JOIN_ROOM,
|
||||
SEND_TEXT,
|
||||
LEAVE_ROOM,
|
||||
PING,
|
||||
JOINED,
|
||||
MESSAGE,
|
||||
SEND_ACK,
|
||||
ERROR,
|
||||
PONG
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import java.util.UUID
|
||||
|
||||
@Configuration
|
||||
class UserCreatorChatWebSocketServerIdConfig {
|
||||
@Bean
|
||||
fun userCreatorChatWebSocketServerId(
|
||||
@Value("\${user-creator-chat.websocket.server-id:}") configuredServerId: String
|
||||
): String {
|
||||
return configuredServerId.takeIf { it.isNotBlank() } ?: UUID.randomUUID().toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
|
||||
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.socket.WebSocketSession
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@Component
|
||||
class UserCreatorChatWebSocketSessionRegistry {
|
||||
private val sessionsByRoomMember = ConcurrentHashMap<RoomMemberKey, ConcurrentHashMap<String, WebSocketSession>>()
|
||||
private val sessionIndexes = ConcurrentHashMap<String, RoomMemberKey>()
|
||||
private val lockStripes = Array(LOCK_STRIPE_COUNT) { Any() }
|
||||
|
||||
fun register(roomId: Long, memberId: Long, session: WebSocketSession) {
|
||||
val sessionId = session.id
|
||||
synchronized(lockFor(sessionId)) {
|
||||
removeLocked(sessionId)
|
||||
|
||||
val key = RoomMemberKey(roomId, memberId)
|
||||
sessionsByRoomMember.computeIfAbsent(key) { ConcurrentHashMap() }[sessionId] = session
|
||||
sessionIndexes[sessionId] = key
|
||||
}
|
||||
}
|
||||
|
||||
fun findSessions(roomId: Long, memberId: Long): List<WebSocketSession> {
|
||||
return sessionsByRoomMember[RoomMemberKey(roomId, memberId)]?.values?.toList() ?: emptyList()
|
||||
}
|
||||
|
||||
fun remove(sessionId: String) {
|
||||
synchronized(lockFor(sessionId)) {
|
||||
removeLocked(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeLocked(sessionId: String) {
|
||||
val key = sessionIndexes.remove(sessionId) ?: return
|
||||
val sessions = sessionsByRoomMember[key] ?: return
|
||||
sessions.remove(sessionId)
|
||||
if (sessions.isEmpty()) {
|
||||
sessionsByRoomMember.remove(key, sessions)
|
||||
}
|
||||
}
|
||||
|
||||
private fun lockFor(sessionId: String): Any {
|
||||
return lockStripes[Math.floorMod(sessionId.hashCode(), lockStripes.size)]
|
||||
}
|
||||
|
||||
private data class RoomMemberKey(
|
||||
val roomId: Long,
|
||||
val memberId: Long
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val LOCK_STRIPE_COUNT = 64
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,7 @@ spring:
|
||||
keepalive-time: ${DB_POOL_KEEPALIVE_TIME_MS:0}
|
||||
|
||||
jpa:
|
||||
open-in-view: false
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
database: mysql
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package kr.co.vividnext.sodalive.audition
|
||||
|
||||
import kr.co.vividnext.sodalive.audition.role.AuditionRoleRepository
|
||||
import kr.co.vividnext.sodalive.member.auth.AuthRepository
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
|
||||
class AuditionServiceTest {
|
||||
private val repository = Mockito.mock(AuditionRepository::class.java)
|
||||
private val authRepository = Mockito.mock(AuthRepository::class.java)
|
||||
private val service = AuditionService(
|
||||
repository = repository,
|
||||
roleRepository = Mockito.mock(AuditionRoleRepository::class.java),
|
||||
authRepository = authRepository
|
||||
)
|
||||
|
||||
@Test
|
||||
fun shouldResolveAdultFlagFromAuthRepositoryForAuditionList() {
|
||||
Mockito.`when`(authRepository.getAuthIdByMemberId(10L)).thenReturn(100L)
|
||||
Mockito.`when`(repository.getInProgressAuditionCount(true)).thenReturn(1)
|
||||
Mockito.`when`(repository.getCompletedAuditionCount(true)).thenReturn(2)
|
||||
Mockito.`when`(repository.getAuditionList(0L, 20L, true)).thenReturn(emptyList())
|
||||
|
||||
service.getAuditionList(offset = 0L, limit = 20L, memberId = 10L)
|
||||
|
||||
Mockito.verify(repository).getAuditionList(0L, 20L, true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package kr.co.vividnext.sodalive.creator.admin.content.series.genre
|
||||
|
||||
import kr.co.vividnext.sodalive.member.auth.AuthRepository
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
|
||||
class CreatorAdminContentSeriesGenreServiceTest {
|
||||
private val repository = Mockito.mock(CreatorAdminContentSeriesGenreRepository::class.java)
|
||||
private val authRepository = Mockito.mock(AuthRepository::class.java)
|
||||
private val service = CreatorAdminContentSeriesGenreService(
|
||||
repository = repository,
|
||||
authRepository = authRepository
|
||||
)
|
||||
|
||||
@Test
|
||||
fun shouldResolveAdultFlagFromAuthRepositoryForGenreList() {
|
||||
Mockito.`when`(authRepository.getAuthIdByMemberId(10L)).thenReturn(100L)
|
||||
Mockito.`when`(repository.getGenreList(true)).thenReturn(emptyList())
|
||||
|
||||
service.getGenreList(memberId = 10L)
|
||||
|
||||
Mockito.verify(repository).getGenreList(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package kr.co.vividnext.sodalive.event
|
||||
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.auth.AuthRepository
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
|
||||
class EventServiceTest {
|
||||
private val repository = Mockito.mock(EventRepository::class.java)
|
||||
private val authRepository = Mockito.mock(AuthRepository::class.java)
|
||||
private val service = EventService(
|
||||
repository = repository,
|
||||
s3Uploader = Mockito.mock(S3Uploader::class.java),
|
||||
authRepository = authRepository,
|
||||
bucket = "test-bucket",
|
||||
cloudFrontHost = "https://cdn.test"
|
||||
)
|
||||
|
||||
@Test
|
||||
fun shouldResolveAdultFlagFromAuthRepositoryForMemberEventList() {
|
||||
Mockito.`when`(authRepository.getAuthIdByMemberId(10L)).thenReturn(100L)
|
||||
Mockito.`when`(repository.getEventList(true)).thenReturn(emptyList())
|
||||
|
||||
service.getEventList(memberId = 10L, memberRole = MemberRole.USER)
|
||||
|
||||
Mockito.verify(repository).getEventList(true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldKeepAdminEventListUnfiltered() {
|
||||
Mockito.`when`(repository.getEventList(null)).thenReturn(emptyList())
|
||||
|
||||
service.getEventList(memberId = 1L, memberRole = MemberRole.ADMIN)
|
||||
|
||||
Mockito.verify(repository).getEventList(null)
|
||||
Mockito.verifyNoInteractions(authRepository)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package kr.co.vividnext.sodalive.fcm
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class FcmServiceTest {
|
||||
@Test
|
||||
@DisplayName("v2 채팅 푸시 data payload는 deep_link만 포함한다")
|
||||
fun shouldBuildV2ChatPayloadWithOnlyDeepLink() {
|
||||
val payload = FcmService.buildDataPayload(
|
||||
roomId = null,
|
||||
messageId = null,
|
||||
contentId = null,
|
||||
creatorId = null,
|
||||
auditionId = null,
|
||||
deepLinkValue = null,
|
||||
deepLinkId = null,
|
||||
deepLinkCommentPostId = null,
|
||||
deepLink = "voiceon-test://chat/10",
|
||||
chatType = null
|
||||
)
|
||||
|
||||
assertEquals(mapOf("deep_link" to "voiceon-test://chat/10"), payload)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("v2 채팅 deep_link는 환경별 scheme과 chat room id로 생성된다")
|
||||
fun shouldBuildV2ChatDeepLinkWithRoomId() {
|
||||
assertEquals("voiceon://chat/10", FcmService.buildDeepLink("voiceon", FcmDeepLinkValue.CHAT, 10L))
|
||||
assertEquals("voiceon-test://chat/10", FcmService.buildDeepLink("local", FcmDeepLinkValue.CHAT, 10L))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package kr.co.vividnext.sodalive.v2.usercreatorchat
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.controller.UserCreatorChatController
|
||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders
|
||||
|
||||
class UserCreatorChatControllerMappingTest {
|
||||
private val mockMvc = MockMvcBuilders
|
||||
.standaloneSetup(UserCreatorChatController(Mockito.mock(UserCreatorChatService::class.java)))
|
||||
.build()
|
||||
|
||||
@Test
|
||||
fun shouldReturnNotFoundForRemovedSseAndTextMessageEndpoints() {
|
||||
mockMvc.perform(get("/api/v2/user-creator-chat/rooms/10/events"))
|
||||
.andExpect(status().isNotFound)
|
||||
mockMvc.perform(post("/api/v2/user-creator-chat/rooms/10/events/disconnect"))
|
||||
.andExpect(status().isNotFound)
|
||||
mockMvc.perform(post("/api/v2/user-creator-chat/rooms/10/messages/text"))
|
||||
.andExpect(status().isNotFound)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import kr.co.vividnext.sodalive.member.MemberKind
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorTextMessageRequest
|
||||
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
|
||||
@@ -58,7 +57,7 @@ class UserCreatorChatServiceIntegrationTest @Autowired constructor(
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AI 캐릭터용 Member가 참여한 기존 DM 방에는 메시지를 보낼 수 없다")
|
||||
@DisplayName("AI 캐릭터용 Member가 참여한 기존 DM 방에는 WebSocket 텍스트 메시지를 보낼 수 없다")
|
||||
fun shouldRejectSendTextMessageWhenOpponentIsAiCharacterMember() {
|
||||
val user = memberRepository.save(Member(email = "dm-message-user@test.com", password = "pw", nickname = "user"))
|
||||
val creator = memberRepository.save(
|
||||
@@ -77,7 +76,7 @@ class UserCreatorChatServiceIntegrationTest @Autowired constructor(
|
||||
entityManager.clear()
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
service.sendTextMessage(user, room.id!!, SendUserCreatorTextMessageRequest("hello"))
|
||||
service.sendTextMessageByWebSocket(user.id!!, room.id!!, "hello")
|
||||
}
|
||||
|
||||
assertEquals("message.error.recipient_not_found", exception.messageKey)
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
package kr.co.vividnext.sodalive.v2.usercreatorchat
|
||||
|
||||
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.fcm.FcmDeepLinkValue
|
||||
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 kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceService
|
||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBroker
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
@@ -23,7 +26,11 @@ import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.dao.DataAccessResourceFailureException
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.mock.web.MockMultipartFile
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
import java.time.LocalDateTime
|
||||
import java.util.Optional
|
||||
|
||||
@@ -33,8 +40,10 @@ class UserCreatorChatServiceTest {
|
||||
private lateinit var messageRepository: UserCreatorChatMessageRepository
|
||||
private lateinit var memberRepository: MemberRepository
|
||||
private lateinit var blockMemberRepository: BlockMemberRepository
|
||||
private lateinit var realtimeService: UserCreatorChatRealtimeService
|
||||
private lateinit var presenceService: UserCreatorChatPresenceService
|
||||
private lateinit var roomMessageBroker: UserCreatorChatRoomMessageBroker
|
||||
private lateinit var eventPublisher: ApplicationEventPublisher
|
||||
private lateinit var s3Uploader: S3Uploader
|
||||
private lateinit var service: UserCreatorChatService
|
||||
|
||||
@BeforeEach
|
||||
@@ -44,8 +53,10 @@ class UserCreatorChatServiceTest {
|
||||
messageRepository = Mockito.mock(UserCreatorChatMessageRepository::class.java)
|
||||
memberRepository = Mockito.mock(MemberRepository::class.java)
|
||||
blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java)
|
||||
realtimeService = Mockito.mock(UserCreatorChatRealtimeService::class.java)
|
||||
presenceService = Mockito.mock(UserCreatorChatPresenceService::class.java)
|
||||
roomMessageBroker = Mockito.mock(UserCreatorChatRoomMessageBroker::class.java)
|
||||
eventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||
s3Uploader = Mockito.mock(S3Uploader::class.java)
|
||||
|
||||
service = UserCreatorChatService(
|
||||
roomRepository = roomRepository,
|
||||
@@ -53,10 +64,11 @@ class UserCreatorChatServiceTest {
|
||||
messageRepository = messageRepository,
|
||||
memberRepository = memberRepository,
|
||||
blockMemberRepository = blockMemberRepository,
|
||||
realtimeService = realtimeService,
|
||||
presenceService = presenceService,
|
||||
roomMessageBroker = roomMessageBroker,
|
||||
applicationEventPublisher = eventPublisher,
|
||||
objectMapper = ObjectMapper(),
|
||||
s3Uploader = Mockito.mock(S3Uploader::class.java),
|
||||
s3Uploader = s3Uploader,
|
||||
bucket = "test-bucket",
|
||||
cloudFrontHost = "https://cdn.test"
|
||||
)
|
||||
@@ -137,8 +149,8 @@ class UserCreatorChatServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("상대방이 같은 방에 입장 중이면 텍스트 메시지를 실시간 전송하고 푸시를 보내지 않는다")
|
||||
fun shouldSendRealtimeMessageWithoutPushWhenOpponentIsPresent() {
|
||||
@DisplayName("WebSocket 텍스트 전송은 상대방 presence가 있으면 메시지를 저장하고 broker로 MESSAGE를 발행하며 푸시를 보내지 않는다")
|
||||
fun shouldPublishWebSocketMessageWithoutPushWhenOpponentPresenceExists() {
|
||||
val user = member(1L, "user")
|
||||
val creator = member(2L, "creator")
|
||||
val room = room(10L)
|
||||
@@ -147,25 +159,26 @@ class UserCreatorChatServiceTest {
|
||||
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`(presenceService.hasPresence(10L, 2L)).thenReturn(true)
|
||||
Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation ->
|
||||
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 200L }
|
||||
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 203L }
|
||||
}
|
||||
|
||||
val response = service.sendTextMessage(user, 10L, SendUserCreatorTextMessageRequest("hello"))
|
||||
val response = service.sendTextMessageByWebSocket(memberId = 1L, roomId = 10L, textMessage = "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())
|
||||
assertEquals(203L, response.messageId)
|
||||
assertEquals("hello", response.textMessage)
|
||||
assertTrue(response.mine)
|
||||
val payloadCaptor = ArgumentCaptor.forClass(String::class.java)
|
||||
Mockito.verify(roomMessageBroker).publish(Mockito.eq(10L), Mockito.eq(2L), captureString(payloadCaptor))
|
||||
assertTrue(payloadCaptor.value.contains("\"type\":\"MESSAGE\""))
|
||||
assertTrue(payloadCaptor.value.contains("\"messageId\":203"))
|
||||
Mockito.verifyNoInteractions(eventPublisher)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("상대방이 같은 방에 없으면 텍스트 메시지를 저장하고 푸시 이벤트를 발행한다")
|
||||
fun shouldPublishPushEventWhenOpponentIsNotPresent() {
|
||||
@DisplayName("WebSocket 텍스트 전송은 상대방 presence가 없으면 채팅방 이동용 푸시 이벤트를 발행한다")
|
||||
fun shouldPublishPushEventWithChatPayloadWhenOpponentPresenceDoesNotExist() {
|
||||
val user = member(1L, "user")
|
||||
val creator = member(2L, "creator")
|
||||
val room = room(10L)
|
||||
@@ -174,12 +187,99 @@ class UserCreatorChatServiceTest {
|
||||
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`(presenceService.hasPresence(10L, 2L)).thenReturn(false)
|
||||
Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation ->
|
||||
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 201L }
|
||||
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 204L }
|
||||
}
|
||||
|
||||
val response = service.sendTextMessage(user, 10L, SendUserCreatorTextMessageRequest("hello"))
|
||||
service.sendTextMessageByWebSocket(memberId = 1L, roomId = 10L, textMessage = "hello")
|
||||
|
||||
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(null, eventCaptor.value.roomId)
|
||||
assertEquals(null, eventCaptor.value.messageId)
|
||||
assertEquals(null, eventCaptor.value.chatType)
|
||||
assertEquals(FcmDeepLinkValue.CHAT, eventCaptor.value.deepLinkValue)
|
||||
assertEquals(10L, eventCaptor.value.deepLinkId)
|
||||
Mockito.verifyNoInteractions(roomMessageBroker)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("WebSocket 텍스트 전송은 Redis presence 확인 실패 시 메시지를 저장하고 푸시 이벤트를 발행한다")
|
||||
fun shouldPublishPushEventWhenPresenceCheckFailsDuringWebSocketTextMessage() {
|
||||
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`(presenceService.hasPresence(10L, 2L))
|
||||
.thenThrow(DataAccessResourceFailureException("redis down"))
|
||||
Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation ->
|
||||
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 207L }
|
||||
}
|
||||
|
||||
val response = service.sendTextMessageByWebSocket(memberId = 1L, roomId = 10L, textMessage = "hello")
|
||||
|
||||
assertEquals(207L, response.messageId)
|
||||
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)
|
||||
Mockito.verifyNoInteractions(roomMessageBroker)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("음성 메시지 REST 전송은 상대방 presence가 있으면 WebSocket broker로 MESSAGE를 발행하고 푸시를 보내지 않는다")
|
||||
fun shouldPublishVoiceMessageToWebSocketWhenOpponentPresenceExists() {
|
||||
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`(presenceService.hasPresence(10L, 2L)).thenReturn(true)
|
||||
Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation ->
|
||||
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 205L }
|
||||
}
|
||||
givenVoiceUploadReturns("voice/205.m4a")
|
||||
|
||||
val response = service.sendVoiceMessage(user, 10L, voiceFile(), "{}")
|
||||
|
||||
assertEquals(205L, response.message.messageId)
|
||||
assertTrue(response.deliveredRealtime)
|
||||
assertFalse(response.pushSent)
|
||||
val payloadCaptor = ArgumentCaptor.forClass(String::class.java)
|
||||
Mockito.verify(roomMessageBroker).publish(Mockito.eq(10L), Mockito.eq(2L), captureString(payloadCaptor))
|
||||
assertTrue(payloadCaptor.value.contains("\"type\":\"MESSAGE\""))
|
||||
assertTrue(payloadCaptor.value.contains("\"messageType\":\"VOICE\""))
|
||||
Mockito.verifyNoInteractions(eventPublisher)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("음성 메시지 REST 전송은 상대방 presence가 없으면 푸시 이벤트를 발행한다")
|
||||
fun shouldPublishPushEventForVoiceMessageWhenOpponentPresenceDoesNotExist() {
|
||||
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`(presenceService.hasPresence(10L, 2L)).thenReturn(false)
|
||||
Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation ->
|
||||
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 206L }
|
||||
}
|
||||
givenVoiceUploadReturns("voice/206.m4a")
|
||||
|
||||
val response = service.sendVoiceMessage(user, 10L, voiceFile(), "{}")
|
||||
|
||||
assertFalse(response.deliveredRealtime)
|
||||
assertTrue(response.pushSent)
|
||||
@@ -187,12 +287,17 @@ class UserCreatorChatServiceTest {
|
||||
Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture())
|
||||
assertEquals(FcmEventType.INDIVIDUAL, eventCaptor.value.type)
|
||||
assertEquals(listOf(2L), eventCaptor.value.recipients)
|
||||
assertEquals(201L, eventCaptor.value.messageId)
|
||||
assertEquals(null, eventCaptor.value.roomId)
|
||||
assertEquals(null, eventCaptor.value.messageId)
|
||||
assertEquals(null, eventCaptor.value.chatType)
|
||||
assertEquals(FcmDeepLinkValue.CHAT, eventCaptor.value.deepLinkValue)
|
||||
assertEquals(10L, eventCaptor.value.deepLinkId)
|
||||
Mockito.verifyNoInteractions(roomMessageBroker)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("상대방이 입장 중이면 실시간 전송 실패 시에도 보완 푸시를 보내지 않는다")
|
||||
fun shouldNotFallbackToPushWhenRealtimeDeliveryFailsForPresentOpponent() {
|
||||
@DisplayName("음성 메시지 REST 전송은 Redis broker 발행 실패 시 푸시 이벤트를 발행한다")
|
||||
fun shouldPublishPushEventWhenBrokerPublishFailsDuringVoiceMessage() {
|
||||
val user = member(1L, "user")
|
||||
val creator = member(2L, "creator")
|
||||
val room = room(10L)
|
||||
@@ -201,17 +306,49 @@ class UserCreatorChatServiceTest {
|
||||
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`(presenceService.hasPresence(10L, 2L)).thenReturn(true)
|
||||
Mockito.doThrow(DataAccessResourceFailureException("redis publish down"))
|
||||
.`when`(roomMessageBroker)
|
||||
.publish(Mockito.eq(10L), Mockito.eq(2L), Mockito.anyString())
|
||||
Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation ->
|
||||
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 202L }
|
||||
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 208L }
|
||||
}
|
||||
givenVoiceUploadReturns("voice/208.m4a")
|
||||
|
||||
val response = service.sendTextMessage(user, 10L, SendUserCreatorTextMessageRequest("hello"))
|
||||
val response = service.sendVoiceMessage(user, 10L, voiceFile(), "{}")
|
||||
|
||||
assertEquals(208L, response.message.messageId)
|
||||
assertFalse(response.deliveredRealtime)
|
||||
assertFalse(response.pushSent)
|
||||
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)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("음성 메시지 REST 전송은 Redis 계층이 아닌 broker 예외를 푸시로 숨기지 않는다")
|
||||
fun shouldPropagateNonRedisBrokerExceptionDuringVoiceMessage() {
|
||||
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`(presenceService.hasPresence(10L, 2L)).thenReturn(true)
|
||||
Mockito.doThrow(IllegalStateException("programming error"))
|
||||
.`when`(roomMessageBroker)
|
||||
.publish(Mockito.eq(10L), Mockito.eq(2L), Mockito.anyString())
|
||||
Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation ->
|
||||
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 209L }
|
||||
}
|
||||
givenVoiceUploadReturns("voice/209.m4a")
|
||||
|
||||
assertThrows(IllegalStateException::class.java) {
|
||||
service.sendVoiceMessage(user, 10L, voiceFile(), "{}")
|
||||
}
|
||||
Mockito.verifyNoInteractions(eventPublisher)
|
||||
}
|
||||
|
||||
@@ -261,20 +398,6 @@ class UserCreatorChatServiceTest {
|
||||
)
|
||||
}
|
||||
|
||||
@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 {
|
||||
@@ -291,6 +414,39 @@ class UserCreatorChatServiceTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun captureString(captor: ArgumentCaptor<String>): String {
|
||||
return captor.capture() ?: ""
|
||||
}
|
||||
|
||||
private fun voiceFile() = MockMultipartFile("voiceMessageFile", "voice.m4a", "audio/mp4", byteArrayOf(1, 2, 3))
|
||||
|
||||
private fun anyInputStream(): InputStream {
|
||||
return Mockito.any(InputStream::class.java) ?: ByteArrayInputStream(ByteArray(0))
|
||||
}
|
||||
|
||||
private fun anyObjectMetadata(): ObjectMetadata {
|
||||
return Mockito.any(ObjectMetadata::class.java) ?: ObjectMetadata()
|
||||
}
|
||||
|
||||
private fun anyStringValue(): String {
|
||||
return Mockito.anyString() ?: ""
|
||||
}
|
||||
|
||||
private fun eqString(value: String): String {
|
||||
return Mockito.eq(value) ?: value
|
||||
}
|
||||
|
||||
private fun givenVoiceUploadReturns(path: String) {
|
||||
Mockito.`when`(
|
||||
s3Uploader.upload(
|
||||
anyInputStream(),
|
||||
eqString("test-bucket"),
|
||||
anyStringValue(),
|
||||
anyObjectMetadata()
|
||||
)
|
||||
).thenReturn(path)
|
||||
}
|
||||
|
||||
private fun room(id: Long) = UserCreatorChatRoom().apply {
|
||||
this.id = id
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.SerializationFeature
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.data.redis.core.SetOperations
|
||||
import org.springframework.data.redis.core.StringRedisTemplate
|
||||
import org.springframework.data.redis.core.ValueOperations
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
class UserCreatorChatPresenceServiceTest {
|
||||
private val stringRedisTemplate = Mockito.mock(StringRedisTemplate::class.java)
|
||||
private val valueOperations = Mockito.mock(ValueOperations::class.java) as ValueOperations<String, String>
|
||||
private val setOperations = Mockito.mock(SetOperations::class.java) as SetOperations<String, String>
|
||||
private val objectMapper = ObjectMapper()
|
||||
.findAndRegisterModules()
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
private val service = UserCreatorChatPresenceService(stringRedisTemplate, objectMapper, "test-server")
|
||||
|
||||
@Test
|
||||
@DisplayName("join 시 session presence와 member session index에 TTL을 설정한다")
|
||||
fun shouldMarkJoinedWithTtl() {
|
||||
givenRedisOperations()
|
||||
|
||||
service.markJoined(roomId = 10L, memberId = 20L, sessionId = "session-1")
|
||||
|
||||
val presenceJsonCaptor = ArgumentCaptor.forClass(String::class.java)
|
||||
Mockito.verify(valueOperations).set(
|
||||
Mockito.eq("v2:user-creator-chat:ws:presence:10:20:session-1"),
|
||||
presenceJsonCaptor.capture(),
|
||||
Mockito.eq(Duration.ofSeconds(90))
|
||||
)
|
||||
assertPresenceJson(presenceJsonCaptor.value)
|
||||
Mockito.verify(setOperations).add("v2:user-creator-chat:ws:room:10:member:20:sessions", "session-1")
|
||||
Mockito.verify(stringRedisTemplate).expire(
|
||||
"v2:user-creator-chat:ws:room:10:member:20:sessions",
|
||||
Duration.ofSeconds(90)
|
||||
)
|
||||
Mockito.verify(setOperations).add("v2:user-creator-chat:ws:room:10", "20")
|
||||
Mockito.verify(stringRedisTemplate).expire("v2:user-creator-chat:ws:room:10", Duration.ofSeconds(90))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("refresh 시 기존 session presence와 index TTL을 갱신한다")
|
||||
fun shouldRefreshPresenceTtl() {
|
||||
givenRedisOperations()
|
||||
|
||||
service.refresh(roomId = 10L, memberId = 20L, sessionId = "session-1")
|
||||
|
||||
val presenceJsonCaptor = ArgumentCaptor.forClass(String::class.java)
|
||||
Mockito.verify(valueOperations).set(
|
||||
Mockito.eq("v2:user-creator-chat:ws:presence:10:20:session-1"),
|
||||
presenceJsonCaptor.capture(),
|
||||
Mockito.eq(Duration.ofSeconds(90))
|
||||
)
|
||||
assertPresenceJson(presenceJsonCaptor.value)
|
||||
Mockito.verify(stringRedisTemplate).expire(
|
||||
"v2:user-creator-chat:ws:room:10:member:20:sessions",
|
||||
Duration.ofSeconds(90)
|
||||
)
|
||||
Mockito.verify(stringRedisTemplate).expire("v2:user-creator-chat:ws:room:10", Duration.ofSeconds(90))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("leave 시 session presence를 삭제하고 마지막 session이면 member presence를 제거한다")
|
||||
fun shouldMarkLeftAndRemoveMemberPresenceWhenLastSessionLeaves() {
|
||||
givenRedisOperations()
|
||||
Mockito.`when`(setOperations.size("v2:user-creator-chat:ws:room:10:member:20:sessions")).thenReturn(0L)
|
||||
|
||||
service.markLeft(roomId = 10L, memberId = 20L, sessionId = "session-1")
|
||||
|
||||
Mockito.verify(stringRedisTemplate).delete("v2:user-creator-chat:ws:presence:10:20:session-1")
|
||||
Mockito.verify(setOperations).remove("v2:user-creator-chat:ws:room:10:member:20:sessions", "session-1")
|
||||
Mockito.verify(stringRedisTemplate).delete("v2:user-creator-chat:ws:room:10:member:20:sessions")
|
||||
Mockito.verify(setOperations).remove("v2:user-creator-chat:ws:room:10", "20")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("leave 후 남은 session id가 stale이면 member presence를 제거한다")
|
||||
fun shouldRemoveMemberPresenceWhenRemainingSessionIdsAreStale() {
|
||||
givenRedisOperations()
|
||||
Mockito.`when`(setOperations.size("v2:user-creator-chat:ws:room:10:member:20:sessions")).thenReturn(1L)
|
||||
Mockito.`when`(setOperations.members("v2:user-creator-chat:ws:room:10:member:20:sessions"))
|
||||
.thenReturn(setOf("session-2"))
|
||||
Mockito.`when`(stringRedisTemplate.hasKey("v2:user-creator-chat:ws:presence:10:20:session-2"))
|
||||
.thenReturn(false)
|
||||
|
||||
service.markLeft(roomId = 10L, memberId = 20L, sessionId = "session-1")
|
||||
|
||||
Mockito.verify(setOperations).remove("v2:user-creator-chat:ws:room:10:member:20:sessions", "session-2")
|
||||
Mockito.verify(stringRedisTemplate).delete("v2:user-creator-chat:ws:room:10:member:20:sessions")
|
||||
Mockito.verify(setOperations).remove("v2:user-creator-chat:ws:room:10", "20")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("member session index에 live session key가 있으면 room presence가 있다고 판단한다")
|
||||
fun shouldReturnPresenceFromMemberSessionIndex() {
|
||||
givenRedisOperations()
|
||||
Mockito.`when`(setOperations.members("v2:user-creator-chat:ws:room:10:member:20:sessions"))
|
||||
.thenReturn(setOf("session-1"))
|
||||
Mockito.`when`(stringRedisTemplate.hasKey("v2:user-creator-chat:ws:presence:10:20:session-1"))
|
||||
.thenReturn(true)
|
||||
|
||||
val hasPresence = service.hasPresence(roomId = 10L, memberId = 20L)
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertEquals(true, hasPresence, "Expected member presence to be true")
|
||||
}
|
||||
|
||||
private fun givenRedisOperations() {
|
||||
Mockito.`when`(stringRedisTemplate.opsForValue()).thenReturn(valueOperations)
|
||||
Mockito.`when`(stringRedisTemplate.opsForSet()).thenReturn(setOperations)
|
||||
}
|
||||
|
||||
private fun assertPresenceJson(json: String) {
|
||||
val presence = objectMapper.readTree(json)
|
||||
assertEquals("test-server", presence["serverId"].asText())
|
||||
assertEquals(20L, presence["memberId"].asLong())
|
||||
assertEquals(10L, presence["roomId"].asLong())
|
||||
assertEquals("session-1", presence["sessionId"].asText())
|
||||
assertNotNull(Instant.parse(presence["lastSeenAt"].asText()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.data.redis.core.StringRedisTemplate
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.test.context.TestPropertySource
|
||||
import org.springframework.web.socket.TextMessage
|
||||
import org.springframework.web.socket.WebSocketSession
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@SpringBootTest
|
||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||
@TestPropertySource(properties = ["user-creator-chat.websocket.server-id=redis-test-server"])
|
||||
class UserCreatorChatRedisIntegrationTest {
|
||||
@Autowired
|
||||
private lateinit var stringRedisTemplate: StringRedisTemplate
|
||||
|
||||
@Autowired
|
||||
private lateinit var objectMapper: ObjectMapper
|
||||
|
||||
@Autowired
|
||||
private lateinit var presenceService: UserCreatorChatPresenceService
|
||||
|
||||
@Autowired
|
||||
private lateinit var sessionRegistry: UserCreatorChatWebSocketSessionRegistry
|
||||
|
||||
@Autowired
|
||||
private lateinit var roomMessageBroker: UserCreatorChatRoomMessageBroker
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
sessionRegistry.remove("redis-integration-session")
|
||||
sessionRegistry.remove("redis-integration-other-session")
|
||||
stringRedisTemplate.connectionFactory?.connection?.use { connection ->
|
||||
connection.flushDb()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("embedded Redis에 join presence key와 index를 저장하고 TTL을 설정한다")
|
||||
fun shouldStorePresenceKeysWithTtlOnJoin() {
|
||||
presenceService.markJoined(roomId = 10L, memberId = 20L, sessionId = "session-1")
|
||||
|
||||
val presenceKey = UserCreatorChatPresenceService.presenceKey(10L, 20L, "session-1")
|
||||
val memberSessionsKey = UserCreatorChatPresenceService.memberSessionsKey(10L, 20L)
|
||||
val roomKey = UserCreatorChatPresenceService.roomKey(10L)
|
||||
|
||||
assertPresenceJson(stringRedisTemplate.opsForValue().get(presenceKey))
|
||||
assertTrue(stringRedisTemplate.opsForSet().isMember(memberSessionsKey, "session-1") == true)
|
||||
assertTrue(stringRedisTemplate.opsForSet().isMember(roomKey, "20") == true)
|
||||
assertTrue(stringRedisTemplate.getExpire(presenceKey) > 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("embedded Redis에서 마지막 session leave 시 presence key와 index를 정리한다")
|
||||
fun shouldRemovePresenceKeysWhenLastSessionLeaves() {
|
||||
presenceService.markJoined(roomId = 10L, memberId = 20L, sessionId = "session-1")
|
||||
|
||||
presenceService.markLeft(roomId = 10L, memberId = 20L, sessionId = "session-1")
|
||||
|
||||
val presenceKey = UserCreatorChatPresenceService.presenceKey(10L, 20L, "session-1")
|
||||
val memberSessionsKey = UserCreatorChatPresenceService.memberSessionsKey(10L, 20L)
|
||||
val roomKey = UserCreatorChatPresenceService.roomKey(10L)
|
||||
assertFalse(stringRedisTemplate.hasKey(presenceKey) == true)
|
||||
assertFalse(stringRedisTemplate.hasKey(memberSessionsKey) == true)
|
||||
assertFalse(stringRedisTemplate.opsForSet().isMember(roomKey, "20") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("stale session id만 남으면 presence 없음으로 판단하고 stale id를 제거한다")
|
||||
fun shouldPruneStaleSessionIdWhenCheckingPresence() {
|
||||
val memberSessionsKey = UserCreatorChatPresenceService.memberSessionsKey(10L, 20L)
|
||||
stringRedisTemplate.opsForSet().add(memberSessionsKey, "stale-session")
|
||||
|
||||
val hasPresence = presenceService.hasPresence(roomId = 10L, memberId = 20L)
|
||||
|
||||
assertFalse(hasPresence)
|
||||
assertFalse(stringRedisTemplate.opsForSet().isMember(memberSessionsKey, "stale-session") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("publish는 embedded Redis pub/sub listener를 거쳐 대상 local session에 payload를 전달한다")
|
||||
fun shouldPublishThroughRedisAndDeliverToLocalTargetSession() {
|
||||
val latch = CountDownLatch(1)
|
||||
val targetSession = session("redis-integration-session", latch)
|
||||
val otherSession = session("redis-integration-other-session", CountDownLatch(1))
|
||||
sessionRegistry.register(roomId = 10L, memberId = 20L, session = targetSession)
|
||||
sessionRegistry.register(roomId = 10L, memberId = 21L, session = otherSession)
|
||||
|
||||
roomMessageBroker.publish(roomId = 10L, memberId = 20L, payload = "{\"type\":\"MESSAGE\"}")
|
||||
|
||||
assertTrue(latch.await(3, TimeUnit.SECONDS), "Expected Redis pub/sub payload to reach target session")
|
||||
val textCaptor = ArgumentCaptor.forClass(TextMessage::class.java)
|
||||
Mockito.verify(targetSession).sendMessage(textCaptor.capture())
|
||||
assertEquals("{\"type\":\"MESSAGE\"}", textCaptor.value.payload)
|
||||
Mockito.verify(otherSession, Mockito.never()).sendMessage(Mockito.any(TextMessage::class.java))
|
||||
}
|
||||
|
||||
private fun session(id: String, latch: CountDownLatch): WebSocketSession {
|
||||
val session = Mockito.mock(WebSocketSession::class.java)
|
||||
Mockito.`when`(session.id).thenReturn(id)
|
||||
Mockito.`when`(session.isOpen).thenReturn(true)
|
||||
Mockito.doAnswer {
|
||||
latch.countDown()
|
||||
null
|
||||
}.`when`(session).sendMessage(Mockito.any(TextMessage::class.java))
|
||||
return session
|
||||
}
|
||||
|
||||
private fun assertPresenceJson(json: String?) {
|
||||
assertNotNull(json)
|
||||
val presence = objectMapper.readTree(json)
|
||||
assertEquals("redis-test-server", presence["serverId"].asText())
|
||||
assertEquals(20L, presence["memberId"].asLong())
|
||||
assertEquals(10L, presence["roomId"].asLong())
|
||||
assertEquals("session-1", presence["sessionId"].asText())
|
||||
assertNotNull(Instant.parse(presence["lastSeenAt"].asText()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.data.redis.connection.Message
|
||||
import org.springframework.data.redis.connection.MessageListener
|
||||
import org.springframework.data.redis.core.StringRedisTemplate
|
||||
import org.springframework.data.redis.listener.PatternTopic
|
||||
import org.springframework.data.redis.listener.RedisMessageListenerContainer
|
||||
import org.springframework.web.socket.TextMessage
|
||||
import org.springframework.web.socket.WebSocketSession
|
||||
import java.io.IOException
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
class UserCreatorChatRoomMessageBrokerTest {
|
||||
private val stringRedisTemplate = Mockito.mock(StringRedisTemplate::class.java)
|
||||
private val registry = UserCreatorChatWebSocketSessionRegistry()
|
||||
private val listenerContainer = Mockito.mock(RedisMessageListenerContainer::class.java)
|
||||
private val objectMapper = ObjectMapper().findAndRegisterModules()
|
||||
private val broker = UserCreatorChatRoomMessageBroker(
|
||||
stringRedisTemplate = stringRedisTemplate,
|
||||
sessionRegistry = registry,
|
||||
objectMapper = objectMapper,
|
||||
listenerContainer = listenerContainer
|
||||
)
|
||||
|
||||
@Test
|
||||
@DisplayName("room channel로 target member와 payload를 publish한다")
|
||||
fun shouldPublishMessageToRoomChannel() {
|
||||
broker.publish(roomId = 10L, memberId = 20L, payload = "{\"type\":\"MESSAGE\"}")
|
||||
|
||||
val messageCaptor = ArgumentCaptor.forClass(String::class.java)
|
||||
Mockito.verify(stringRedisTemplate).convertAndSend(
|
||||
Mockito.eq("v2:user-creator-chat:ws:room:10"),
|
||||
messageCaptor.capture()
|
||||
)
|
||||
|
||||
val published = objectMapper.readValue(messageCaptor.value, UserCreatorChatRoomPublishedMessage::class.java)
|
||||
assertEquals(10L, published.roomId)
|
||||
assertEquals(20L, published.memberId)
|
||||
assertEquals("{\"type\":\"MESSAGE\"}", published.payload)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("생성 시 ws room pattern topic을 구독한다")
|
||||
fun shouldSubscribeRoomPatternOnCreation() {
|
||||
Mockito.verify(listenerContainer).addMessageListener(
|
||||
Mockito.any(MessageListener::class.java),
|
||||
Mockito.eq(PatternTopic("v2:user-creator-chat:ws:room:*"))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("subscribe callback은 대상 member의 local session에만 메시지를 전송한다")
|
||||
fun shouldDeliverSubscribedMessageOnlyToTargetMemberSessions() {
|
||||
val targetSession = session("target-session")
|
||||
val otherMemberSession = session("other-session")
|
||||
registry.register(roomId = 10L, memberId = 20L, session = targetSession)
|
||||
registry.register(roomId = 10L, memberId = 21L, session = otherMemberSession)
|
||||
val published = UserCreatorChatRoomPublishedMessage(
|
||||
roomId = 10L,
|
||||
memberId = 20L,
|
||||
payload = "{\"type\":\"MESSAGE\"}"
|
||||
)
|
||||
|
||||
broker.onMessage(redisMessage(objectMapper.writeValueAsString(published)), null)
|
||||
|
||||
val textCaptor = ArgumentCaptor.forClass(TextMessage::class.java)
|
||||
Mockito.verify(targetSession).sendMessage(textCaptor.capture())
|
||||
assertEquals("{\"type\":\"MESSAGE\"}", textCaptor.value.payload)
|
||||
Mockito.verify(otherMemberSession, Mockito.never()).sendMessage(Mockito.any(TextMessage::class.java))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("일부 local session 전송이 실패해도 같은 member의 다른 session 전송을 계속한다")
|
||||
fun shouldContinueDeliveryWhenOneTargetSessionFails() {
|
||||
val brokenSession = session("broken-session")
|
||||
val healthySession = session("healthy-session")
|
||||
Mockito.doThrow(IOException("broken socket"))
|
||||
.`when`(brokenSession)
|
||||
.sendMessage(Mockito.any(TextMessage::class.java))
|
||||
registry.register(roomId = 10L, memberId = 20L, session = brokenSession)
|
||||
registry.register(roomId = 10L, memberId = 20L, session = healthySession)
|
||||
val published = UserCreatorChatRoomPublishedMessage(
|
||||
roomId = 10L,
|
||||
memberId = 20L,
|
||||
payload = "{\"type\":\"MESSAGE\"}"
|
||||
)
|
||||
|
||||
broker.onMessage(redisMessage(objectMapper.writeValueAsString(published)), null)
|
||||
|
||||
val textCaptor = ArgumentCaptor.forClass(TextMessage::class.java)
|
||||
Mockito.verify(healthySession).sendMessage(textCaptor.capture())
|
||||
assertEquals("{\"type\":\"MESSAGE\"}", textCaptor.value.payload)
|
||||
}
|
||||
|
||||
private fun redisMessage(body: String): Message {
|
||||
val message = Mockito.mock(Message::class.java)
|
||||
Mockito.`when`(message.body).thenReturn(body.toByteArray(StandardCharsets.UTF_8))
|
||||
return message
|
||||
}
|
||||
|
||||
private fun session(id: String): WebSocketSession {
|
||||
val session = Mockito.mock(WebSocketSession::class.java)
|
||||
Mockito.`when`(session.id).thenReturn(id)
|
||||
Mockito.`when`(session.isOpen).thenReturn(true)
|
||||
return session
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
|
||||
|
||||
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.server.ServerHttpRequest
|
||||
import org.springframework.http.server.ServerHttpResponse
|
||||
import org.springframework.http.server.ServletServerHttpRequest
|
||||
import org.springframework.mock.web.MockHttpServletRequest
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.web.socket.WebSocketHandler
|
||||
|
||||
class UserCreatorChatWebSocketAuthInterceptorTest {
|
||||
private val tokenProvider = Mockito.mock(TokenProvider::class.java)
|
||||
private val interceptor = UserCreatorChatWebSocketAuthInterceptor(tokenProvider)
|
||||
private val response = Mockito.mock(ServerHttpResponse::class.java)
|
||||
private val wsHandler = Mockito.mock(WebSocketHandler::class.java)
|
||||
|
||||
@Test
|
||||
@DisplayName("Authorization Bearer token이 유효하면 handshake attributes에 인증 정보를 저장한다")
|
||||
fun shouldStoreAuthenticationAttributesWhenBearerTokenIsValid() {
|
||||
val member = Member(email = "viewer@test.com", password = "password", nickname = "viewer").apply { id = 10L }
|
||||
val authentication = UsernamePasswordAuthenticationToken(MemberAdapter(member), "valid-token")
|
||||
Mockito.`when`(tokenProvider.validateToken("valid-token")).thenReturn(true)
|
||||
Mockito.`when`(tokenProvider.getAuthentication("valid-token")).thenReturn(authentication)
|
||||
|
||||
val attributes = mutableMapOf<String, Any>()
|
||||
val result = interceptor.beforeHandshake(
|
||||
requestWithAuthorization("Bearer valid-token"),
|
||||
response,
|
||||
wsHandler,
|
||||
attributes
|
||||
)
|
||||
|
||||
assertTrue(result, "Expected valid Bearer token handshake to proceed")
|
||||
assertEquals(10L, attributes[UserCreatorChatWebSocketAuthInterceptor.MEMBER_ID_ATTRIBUTE])
|
||||
assertSame(authentication, attributes[UserCreatorChatWebSocketAuthInterceptor.AUTHENTICATION_ATTRIBUTE])
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Authorization header가 없으면 handshake를 거부한다")
|
||||
fun shouldRejectHandshakeWithoutAuthorizationHeader() {
|
||||
val attributes = mutableMapOf<String, Any>()
|
||||
|
||||
val result = interceptor.beforeHandshake(
|
||||
requestWithAuthorization(null),
|
||||
response,
|
||||
wsHandler,
|
||||
attributes
|
||||
)
|
||||
|
||||
assertFalse(result, "Expected missing Authorization header handshake to be rejected")
|
||||
assertTrue(attributes.isEmpty(), "Expected rejected handshake to leave attributes empty")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("유효하지 않은 Bearer token이면 handshake를 거부한다")
|
||||
fun shouldRejectHandshakeWhenBearerTokenIsInvalid() {
|
||||
Mockito.`when`(tokenProvider.validateToken("invalid-token")).thenReturn(false)
|
||||
|
||||
val attributes = mutableMapOf<String, Any>()
|
||||
val result = interceptor.beforeHandshake(
|
||||
requestWithAuthorization("Bearer invalid-token"),
|
||||
response,
|
||||
wsHandler,
|
||||
attributes
|
||||
)
|
||||
|
||||
assertFalse(result, "Expected invalid Bearer token handshake to be rejected")
|
||||
assertTrue(attributes.isEmpty(), "Expected rejected handshake to leave attributes empty")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("토큰 검증 후 인증 정보 조회가 실패하면 handshake를 거부한다")
|
||||
fun shouldRejectHandshakeWhenAuthenticationLookupFails() {
|
||||
Mockito.`when`(tokenProvider.validateToken("logged-out-token")).thenReturn(true)
|
||||
Mockito.`when`(tokenProvider.getAuthentication("logged-out-token"))
|
||||
.thenThrow(IllegalStateException("token not found"))
|
||||
|
||||
val attributes = mutableMapOf<String, Any>()
|
||||
var result = true
|
||||
assertDoesNotThrow {
|
||||
result = interceptor.beforeHandshake(
|
||||
requestWithAuthorization("Bearer logged-out-token"),
|
||||
response,
|
||||
wsHandler,
|
||||
attributes
|
||||
)
|
||||
}
|
||||
|
||||
assertFalse(result, "Expected authentication lookup failure handshake to be rejected")
|
||||
assertTrue(attributes.isEmpty(), "Expected rejected handshake to leave attributes empty")
|
||||
}
|
||||
|
||||
private fun requestWithAuthorization(authorization: String?): ServerHttpRequest {
|
||||
val request = MockHttpServletRequest()
|
||||
if (authorization != null) {
|
||||
request.addHeader(HttpHeaders.AUTHORIZATION, authorization)
|
||||
}
|
||||
return ServletServerHttpRequest(request)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.context.ApplicationContext
|
||||
import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping
|
||||
import org.springframework.web.socket.server.support.OriginHandshakeInterceptor
|
||||
import org.springframework.web.socket.server.support.WebSocketHttpRequestHandler
|
||||
|
||||
@SpringBootTest(
|
||||
classes = [
|
||||
UserCreatorChatWebSocketConfig::class,
|
||||
UserCreatorChatWebSocketHandler::class,
|
||||
UserCreatorChatWebSocketAuthInterceptor::class
|
||||
]
|
||||
)
|
||||
class UserCreatorChatWebSocketConfigTest @Autowired constructor(
|
||||
private val applicationContext: ApplicationContext
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var tokenProvider: TokenProvider
|
||||
|
||||
@MockBean
|
||||
private lateinit var service: UserCreatorChatService
|
||||
|
||||
@MockBean
|
||||
private lateinit var presenceService: UserCreatorChatPresenceService
|
||||
|
||||
@MockBean
|
||||
private lateinit var sessionRegistry: UserCreatorChatWebSocketSessionRegistry
|
||||
|
||||
@MockBean
|
||||
private lateinit var objectMapper: ObjectMapper
|
||||
|
||||
@Test
|
||||
@DisplayName("유저-크리에이터 채팅 WebSocket handler를 지정 경로와 인증 interceptor, origin 제한으로 등록한다")
|
||||
fun shouldRegisterUserCreatorChatWebSocketHandler() {
|
||||
val handlerMappings = applicationContext.getBeansOfType(SimpleUrlHandlerMapping::class.java).values
|
||||
val urlMap = handlerMappings.flatMap { mapping -> mapping.urlMap.entries }
|
||||
val handler = urlMap.firstNotNullOfOrNull { (path, handler) ->
|
||||
if (path == "/ws/v2/user-creator-chat") handler as? WebSocketHttpRequestHandler else null
|
||||
}
|
||||
|
||||
assertNotNull(handler, "Expected /ws/v2/user-creator-chat to be registered")
|
||||
val interceptors = handler!!.handshakeInterceptors
|
||||
assertTrue(interceptors.any { it is UserCreatorChatWebSocketAuthInterceptor })
|
||||
|
||||
val originInterceptor = interceptors.filterIsInstance<OriginHandshakeInterceptor>().single()
|
||||
assertEquals(
|
||||
listOf(
|
||||
"http://localhost:8888",
|
||||
"https://creator.sodalive.net",
|
||||
"https://test-creator.sodalive.net",
|
||||
"https://test-admin.sodalive.net",
|
||||
"https://admin.sodalive.net"
|
||||
),
|
||||
originInterceptor.allowedOrigins.toList()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto
|
||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.web.socket.CloseStatus
|
||||
import org.springframework.web.socket.TextMessage
|
||||
import org.springframework.web.socket.WebSocketSession
|
||||
|
||||
class UserCreatorChatWebSocketHandlerTest {
|
||||
private val service = Mockito.mock(UserCreatorChatService::class.java)
|
||||
private val presenceService = Mockito.mock(UserCreatorChatPresenceService::class.java)
|
||||
private val sessionRegistry = UserCreatorChatWebSocketSessionRegistry()
|
||||
private val objectMapper = ObjectMapper().findAndRegisterModules()
|
||||
private val handler = UserCreatorChatWebSocketHandler(
|
||||
service = service,
|
||||
presenceService = presenceService,
|
||||
sessionRegistry = sessionRegistry,
|
||||
objectMapper = objectMapper
|
||||
)
|
||||
|
||||
@Test
|
||||
@DisplayName("JOIN_ROOM은 참여자 검증 후 local session과 Redis presence를 등록하고 JOINED를 응답한다")
|
||||
fun shouldJoinRoomAndRegisterPresenceWhenParticipantIsValid() {
|
||||
val session = session("session-1", memberId = 1L)
|
||||
|
||||
handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}"))
|
||||
|
||||
Mockito.verify(service).validateParticipant(roomId = 10L, memberId = 1L)
|
||||
assertEquals(listOf(session), sessionRegistry.findSessions(roomId = 10L, memberId = 1L))
|
||||
Mockito.verify(presenceService).markJoined(roomId = 10L, memberId = 1L, sessionId = "session-1")
|
||||
val response = sentJson(session)
|
||||
assertEquals("JOINED", response["type"].asText())
|
||||
assertEquals("join-1", response["requestId"].asText())
|
||||
assertEquals(10L, response["roomId"].asLong())
|
||||
assertTrue(response["payload"].isObject)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("JOIN_ROOM 요청자가 참여자가 아니면 ERROR 응답 후 WebSocket을 닫는다")
|
||||
fun shouldSendErrorAndCloseWhenJoinRoomParticipantIsInvalid() {
|
||||
val session = session("session-1", memberId = 1L)
|
||||
Mockito.doThrow(SodaException(messageKey = "chat.room.invalid_access"))
|
||||
.`when`(service)
|
||||
.validateParticipant(roomId = 10L, memberId = 1L)
|
||||
|
||||
handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}"))
|
||||
|
||||
val response = sentJson(session)
|
||||
assertEquals("ERROR", response["type"].asText())
|
||||
assertEquals("join-1", response["requestId"].asText())
|
||||
assertEquals(10L, response["roomId"].asLong())
|
||||
assertEquals("chat.room.invalid_access", response["payload"]["messageKey"].asText())
|
||||
Mockito.verify(session).close(CloseStatus.POLICY_VIOLATION)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SEND_TEXT는 메시지 저장 후 sender에게 SEND_ACK를 응답한다")
|
||||
fun shouldSendAckToSenderWhenTextMessageIsSaved() {
|
||||
val session = session("session-1", memberId = 1L)
|
||||
Mockito.`when`(service.sendTextMessageByWebSocket(memberId = 1L, roomId = 10L, textMessage = "hello"))
|
||||
.thenReturn(messageItem())
|
||||
handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}"))
|
||||
Mockito.clearInvocations(session)
|
||||
|
||||
handler.handleMessage(
|
||||
session,
|
||||
textMessage("SEND_TEXT", "send-1", 10L, "{\"textMessage\":\"hello\"}")
|
||||
)
|
||||
|
||||
val response = sentJson(session)
|
||||
assertEquals("SEND_ACK", response["type"].asText())
|
||||
assertEquals("send-1", response["requestId"].asText())
|
||||
assertEquals(10L, response["roomId"].asLong())
|
||||
assertEquals(200L, response["payload"]["messageId"].asLong())
|
||||
assertEquals("hello", response["payload"]["textMessage"].asText())
|
||||
assertTrue(response["payload"]["mine"].asBoolean())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SEND_TEXT는 JOIN_ROOM 완료 전이면 저장하지 않고 ERROR를 응답한다")
|
||||
fun shouldRejectSendTextBeforeJoinRoom() {
|
||||
val session = session("session-1", memberId = 1L)
|
||||
|
||||
handler.handleMessage(
|
||||
session,
|
||||
textMessage("SEND_TEXT", "send-1", 10L, "{\"textMessage\":\"hello\"}")
|
||||
)
|
||||
|
||||
val response = sentJson(session)
|
||||
assertEquals("ERROR", response["type"].asText())
|
||||
assertEquals("send-1", response["requestId"].asText())
|
||||
assertEquals(10L, response["roomId"].asLong())
|
||||
assertEquals("chat.room.join_required", response["payload"]["messageKey"].asText())
|
||||
Mockito.verify(service, Mockito.never()).sendTextMessageByWebSocket(
|
||||
Mockito.anyLong(),
|
||||
Mockito.anyLong(),
|
||||
Mockito.anyString()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("같은 session이 다른 방에 JOIN_ROOM하면 기존 방 presence를 제거하고 새 방만 등록한다")
|
||||
fun shouldRemovePreviousPresenceWhenSessionJoinsAnotherRoom() {
|
||||
val session = session("session-1", memberId = 1L)
|
||||
handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}"))
|
||||
|
||||
handler.handleMessage(session, textMessage("JOIN_ROOM", "join-2", 20L, "{}"))
|
||||
|
||||
Mockito.verify(presenceService).markLeft(roomId = 10L, memberId = 1L, sessionId = "session-1")
|
||||
Mockito.verify(presenceService).markJoined(roomId = 20L, memberId = 1L, sessionId = "session-1")
|
||||
assertEquals(emptyList<WebSocketSession>(), sessionRegistry.findSessions(roomId = 10L, memberId = 1L))
|
||||
assertEquals(listOf(session), sessionRegistry.findSessions(roomId = 20L, memberId = 1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("WebSocket close 시 local session과 Redis presence를 제거한다")
|
||||
fun shouldRemoveLocalSessionAndPresenceWhenConnectionCloses() {
|
||||
val session = session("session-1", memberId = 1L)
|
||||
handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}"))
|
||||
|
||||
handler.afterConnectionClosed(session, CloseStatus.NORMAL)
|
||||
|
||||
Mockito.verify(presenceService).markLeft(roomId = 10L, memberId = 1L, sessionId = "session-1")
|
||||
assertEquals(emptyList<WebSocketSession>(), sessionRegistry.findSessions(roomId = 10L, memberId = 1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("LEAVE_ROOM은 local session과 Redis presence를 제거한다")
|
||||
fun shouldRemoveLocalSessionAndPresenceWhenLeaveRoomReceived() {
|
||||
val session = session("session-1", memberId = 1L)
|
||||
handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}"))
|
||||
Mockito.clearInvocations(session, presenceService)
|
||||
|
||||
handler.handleMessage(session, textMessage("LEAVE_ROOM", "leave-1", 10L, "{}"))
|
||||
|
||||
Mockito.verify(presenceService).markLeft(roomId = 10L, memberId = 1L, sessionId = "session-1")
|
||||
assertEquals(emptyList<WebSocketSession>(), sessionRegistry.findSessions(roomId = 10L, memberId = 1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("LEAVE_ROOM은 joined room과 요청 roomId가 다르면 presence를 제거하지 않고 ERROR를 응답한다")
|
||||
fun shouldRejectLeaveRoomWhenRequestRoomDoesNotMatchJoinedRoom() {
|
||||
val session = session("session-1", memberId = 1L)
|
||||
handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}"))
|
||||
Mockito.clearInvocations(session, presenceService)
|
||||
|
||||
handler.handleMessage(session, textMessage("LEAVE_ROOM", "leave-1", 20L, "{}"))
|
||||
|
||||
Mockito.verify(presenceService, Mockito.never()).markLeft(
|
||||
roomId = 10L,
|
||||
memberId = 1L,
|
||||
sessionId = "session-1"
|
||||
)
|
||||
assertEquals(listOf(session), sessionRegistry.findSessions(roomId = 10L, memberId = 1L))
|
||||
val response = sentJson(session)
|
||||
assertEquals("ERROR", response["type"].asText())
|
||||
assertEquals("leave-1", response["requestId"].asText())
|
||||
assertEquals(20L, response["roomId"].asLong())
|
||||
assertEquals("chat.room.join_required", response["payload"]["messageKey"].asText())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PING은 joined room의 presence TTL을 갱신하고 PONG을 응답한다")
|
||||
fun shouldRefreshPresenceAndSendPongWhenPingReceived() {
|
||||
val session = session("session-1", memberId = 1L)
|
||||
handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}"))
|
||||
Mockito.clearInvocations(session, presenceService)
|
||||
|
||||
handler.handleMessage(session, textMessage("PING", "ping-1", 10L, "{}"))
|
||||
|
||||
Mockito.verify(presenceService).refresh(roomId = 10L, memberId = 1L, sessionId = "session-1")
|
||||
val response = sentJson(session)
|
||||
assertEquals("PONG", response["type"].asText())
|
||||
assertEquals("ping-1", response["requestId"].asText())
|
||||
assertEquals(10L, response["roomId"].asLong())
|
||||
assertTrue(response["payload"].isObject)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("잘못된 JSON 메시지는 handler 밖으로 예외를 전파하지 않고 ERROR를 응답한다")
|
||||
fun shouldSendErrorWhenMessagePayloadIsMalformedJson() {
|
||||
val session = session("session-1", memberId = 1L)
|
||||
|
||||
handler.handleMessage(session, TextMessage("{"))
|
||||
|
||||
val response = sentJson(session)
|
||||
assertEquals("ERROR", response["type"].asText())
|
||||
assertTrue(response["requestId"].isNull)
|
||||
assertEquals(0L, response["roomId"].asLong())
|
||||
assertEquals("common.error.invalid_request", response["payload"]["messageKey"].asText())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("알 수 없는 type 메시지는 handler 밖으로 예외를 전파하지 않고 ERROR를 응답한다")
|
||||
fun shouldSendErrorWhenMessageTypeIsUnknown() {
|
||||
val session = session("session-1", memberId = 1L)
|
||||
|
||||
handler.handleMessage(session, textMessage("UNKNOWN", "unknown-1", 10L, "{}"))
|
||||
|
||||
val response = sentJson(session)
|
||||
assertEquals("ERROR", response["type"].asText())
|
||||
assertTrue(response["requestId"].isNull)
|
||||
assertEquals(0L, response["roomId"].asLong())
|
||||
assertEquals("common.error.invalid_request", response["payload"]["messageKey"].asText())
|
||||
}
|
||||
|
||||
private fun textMessage(type: String, requestId: String, roomId: Long, payload: String): TextMessage {
|
||||
return TextMessage(
|
||||
"""
|
||||
{
|
||||
"type": "$type",
|
||||
"requestId": "$requestId",
|
||||
"roomId": $roomId,
|
||||
"payload": $payload
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
private fun session(id: String, memberId: Long): WebSocketSession {
|
||||
val session = Mockito.mock(WebSocketSession::class.java)
|
||||
Mockito.`when`(session.id).thenReturn(id)
|
||||
val attributes = mutableMapOf<String, Any>(UserCreatorChatWebSocketAuthInterceptor.MEMBER_ID_ATTRIBUTE to memberId)
|
||||
Mockito.`when`(session.attributes).thenReturn(attributes)
|
||||
Mockito.`when`(session.isOpen).thenReturn(true)
|
||||
return session
|
||||
}
|
||||
|
||||
private fun sentJson(session: WebSocketSession): JsonNode {
|
||||
val captor = ArgumentCaptor.forClass(TextMessage::class.java)
|
||||
Mockito.verify(session).sendMessage(captor.capture())
|
||||
return objectMapper.readTree(captor.value.payload)
|
||||
}
|
||||
|
||||
private fun messageItem() = UserCreatorChatMessageItemDto(
|
||||
messageId = 200L,
|
||||
messageType = "TEXT",
|
||||
mine = true,
|
||||
createdAt = 1781690401000L,
|
||||
textMessage = "hello",
|
||||
voiceMessageUrl = null,
|
||||
senderId = 1L,
|
||||
senderNickname = "user",
|
||||
senderProfileImageUrl = "https://cdn.test/profile/user.png"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.context.ApplicationContext
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.server.ServerHttpResponse
|
||||
import org.springframework.http.server.ServletServerHttpRequest
|
||||
import org.springframework.mock.web.MockHttpServletRequest
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping
|
||||
import org.springframework.web.socket.WebSocketHandler
|
||||
import org.springframework.web.socket.server.support.WebSocketHttpRequestHandler
|
||||
|
||||
@SpringBootTest(
|
||||
classes = [
|
||||
UserCreatorChatWebSocketConfig::class,
|
||||
UserCreatorChatWebSocketHandler::class,
|
||||
UserCreatorChatWebSocketAuthInterceptor::class
|
||||
]
|
||||
)
|
||||
class UserCreatorChatWebSocketHandshakeIntegrationTest @Autowired constructor(
|
||||
private val applicationContext: ApplicationContext
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var tokenProvider: TokenProvider
|
||||
|
||||
@MockBean
|
||||
private lateinit var service: UserCreatorChatService
|
||||
|
||||
@MockBean
|
||||
private lateinit var presenceService: UserCreatorChatPresenceService
|
||||
|
||||
@MockBean
|
||||
private lateinit var sessionRegistry: UserCreatorChatWebSocketSessionRegistry
|
||||
|
||||
@MockBean
|
||||
private lateinit var objectMapper: ObjectMapper
|
||||
|
||||
private val response = Mockito.mock(ServerHttpResponse::class.java)
|
||||
private val wsHandler = Mockito.mock(WebSocketHandler::class.java)
|
||||
|
||||
@Test
|
||||
@DisplayName("유효한 Bearer token이 있으면 등록된 WebSocket auth interceptor가 handshake를 허용한다")
|
||||
fun shouldAcceptHandshakeWithValidBearerToken() {
|
||||
val member = Member(email = "ws-handshake@test.com", password = "pw", nickname = "ws-handshake")
|
||||
.apply { id = 10L }
|
||||
val authentication = UsernamePasswordAuthenticationToken(MemberAdapter(member), "valid-token")
|
||||
Mockito.`when`(tokenProvider.validateToken("valid-token")).thenReturn(true)
|
||||
Mockito.`when`(tokenProvider.getAuthentication("valid-token")).thenReturn(authentication)
|
||||
val attributes = mutableMapOf<String, Any>()
|
||||
|
||||
val result = authInterceptor().beforeHandshake(
|
||||
requestWithAuthorization("Bearer valid-token"),
|
||||
response,
|
||||
wsHandler,
|
||||
attributes
|
||||
)
|
||||
|
||||
assertTrue(result)
|
||||
assertSame(authentication, attributes[UserCreatorChatWebSocketAuthInterceptor.AUTHENTICATION_ATTRIBUTE])
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Authorization header가 없으면 등록된 WebSocket auth interceptor가 handshake를 거부한다")
|
||||
fun shouldRejectHandshakeWithoutAuthorizationHeader() {
|
||||
val attributes = mutableMapOf<String, Any>()
|
||||
|
||||
val result = authInterceptor().beforeHandshake(
|
||||
requestWithAuthorization(null),
|
||||
response,
|
||||
wsHandler,
|
||||
attributes
|
||||
)
|
||||
|
||||
assertFalse(result)
|
||||
assertTrue(attributes.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("유효하지 않은 token이면 등록된 WebSocket auth interceptor가 handshake를 거부한다")
|
||||
fun shouldRejectHandshakeWithInvalidBearerToken() {
|
||||
Mockito.`when`(tokenProvider.validateToken("invalid-token")).thenReturn(false)
|
||||
val attributes = mutableMapOf<String, Any>()
|
||||
|
||||
val result = authInterceptor().beforeHandshake(
|
||||
requestWithAuthorization("Bearer invalid-token"),
|
||||
response,
|
||||
wsHandler,
|
||||
attributes
|
||||
)
|
||||
|
||||
assertFalse(result)
|
||||
assertTrue(attributes.isEmpty())
|
||||
}
|
||||
|
||||
private fun authInterceptor(): UserCreatorChatWebSocketAuthInterceptor {
|
||||
val handler = registeredWebSocketHandler()
|
||||
return handler.handshakeInterceptors.filterIsInstance<UserCreatorChatWebSocketAuthInterceptor>().single()
|
||||
}
|
||||
|
||||
private fun registeredWebSocketHandler(): WebSocketHttpRequestHandler {
|
||||
val handlerMappings = applicationContext.getBeansOfType(SimpleUrlHandlerMapping::class.java).values
|
||||
val urlMap = handlerMappings.flatMap { mapping -> mapping.urlMap.entries }
|
||||
val handler = urlMap.firstNotNullOfOrNull { (path, handler) ->
|
||||
if (path == UserCreatorChatWebSocketConfig.ENDPOINT) handler as? WebSocketHttpRequestHandler else null
|
||||
}
|
||||
assertNotNull(handler, "Expected /ws/v2/user-creator-chat to be registered")
|
||||
return handler!!
|
||||
}
|
||||
|
||||
private fun requestWithAuthorization(authorization: String?): ServletServerHttpRequest {
|
||||
val request = MockHttpServletRequest()
|
||||
if (authorization != null) {
|
||||
request.addHeader(HttpHeaders.AUTHORIZATION, authorization)
|
||||
}
|
||||
return ServletServerHttpRequest(request)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class UserCreatorChatWebSocketMessageTest {
|
||||
private val objectMapper = jacksonObjectMapper()
|
||||
|
||||
@Test
|
||||
@DisplayName("WebSocket message type enum은 클라이언트 요청과 서버 응답 타입을 모두 가진다")
|
||||
fun shouldDefineAllWebSocketMessageTypes() {
|
||||
val names = UserCreatorChatWebSocketMessageType.values().map { it.name }
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"JOIN_ROOM",
|
||||
"SEND_TEXT",
|
||||
"LEAVE_ROOM",
|
||||
"PING",
|
||||
"JOINED",
|
||||
"MESSAGE",
|
||||
"SEND_ACK",
|
||||
"ERROR",
|
||||
"PONG"
|
||||
),
|
||||
names,
|
||||
"Expected WebSocket message types to match the protocol contract"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SEND_TEXT JSON envelope를 JsonNode payload로 역직렬화한다")
|
||||
fun shouldDeserializeSendTextEnvelopeWithJsonNodePayload() {
|
||||
val json = """
|
||||
{
|
||||
"type": "SEND_TEXT",
|
||||
"requestId": "client-request-id",
|
||||
"roomId": 10,
|
||||
"payload": {
|
||||
"textMessage": "hello"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val message = objectMapper.readValue<UserCreatorChatWebSocketMessage>(json)
|
||||
|
||||
assertEquals(UserCreatorChatWebSocketMessageType.SEND_TEXT, message.type)
|
||||
assertEquals("client-request-id", message.requestId)
|
||||
assertEquals(10L, message.roomId)
|
||||
assertNotNull(message.payload, "Expected payload JsonNode to be present")
|
||||
assertEquals("hello", message.payload["textMessage"].asText())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("payload가 비어 있는 JOIN_ROOM envelope도 역직렬화한다")
|
||||
fun shouldDeserializeJoinRoomEnvelopeWithEmptyPayload() {
|
||||
val json = """
|
||||
{
|
||||
"type": "JOIN_ROOM",
|
||||
"requestId": "join-request-id",
|
||||
"roomId": 10,
|
||||
"payload": {}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val message = objectMapper.readValue<UserCreatorChatWebSocketMessage>(json)
|
||||
|
||||
assertEquals(UserCreatorChatWebSocketMessageType.JOIN_ROOM, message.type)
|
||||
assertEquals("join-request-id", message.requestId)
|
||||
assertEquals(10L, message.roomId)
|
||||
assertEquals(0, message.payload.size(), "Expected empty JSON object payload")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.web.socket.WebSocketSession
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class UserCreatorChatWebSocketSessionRegistryTest {
|
||||
private val registry = UserCreatorChatWebSocketSessionRegistry()
|
||||
|
||||
@Test
|
||||
@DisplayName("roomId/memberId/sessionId 기준으로 local WebSocket session을 등록하고 조회한다")
|
||||
fun shouldRegisterAndFindSessionsByRoomAndMember() {
|
||||
val session = session("session-1")
|
||||
|
||||
registry.register(roomId = 10L, memberId = 20L, session = session)
|
||||
|
||||
val sessions = registry.findSessions(roomId = 10L, memberId = 20L)
|
||||
assertEquals(1, sessions.size, "Expected one registered local WebSocket session")
|
||||
assertSame(session, sessions.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("sessionId로 등록된 local WebSocket session을 제거한다")
|
||||
fun shouldRemoveSessionBySessionId() {
|
||||
val session = session("session-1")
|
||||
registry.register(roomId = 10L, memberId = 20L, session = session)
|
||||
|
||||
registry.remove("session-1")
|
||||
|
||||
assertFalse(
|
||||
registry.findSessions(roomId = 10L, memberId = 20L).isNotEmpty(),
|
||||
"Expected removed WebSocket session not to be returned"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("같은 session이 다른 room으로 전환되면 기존 room 등록을 제거한다")
|
||||
fun shouldRemovePreviousRoomWhenSameSessionSwitchesRoom() {
|
||||
val session = session("session-1")
|
||||
registry.register(roomId = 10L, memberId = 20L, session = session)
|
||||
|
||||
registry.register(roomId = 11L, memberId = 20L, session = session)
|
||||
|
||||
assertFalse(
|
||||
registry.findSessions(roomId = 10L, memberId = 20L).isNotEmpty(),
|
||||
"Expected previous room mapping to be removed when same session switches rooms"
|
||||
)
|
||||
assertEquals(listOf(session), registry.findSessions(roomId = 11L, memberId = 20L))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("room/member에 등록된 여러 local session을 모두 조회한다")
|
||||
fun shouldFindMultipleSessionsForSameRoomMember() {
|
||||
val first = session("session-1")
|
||||
val second = session("session-2")
|
||||
registry.register(roomId = 10L, memberId = 20L, session = first)
|
||||
registry.register(roomId = 10L, memberId = 20L, session = second)
|
||||
|
||||
val sessions = registry.findSessions(roomId = 10L, memberId = 20L)
|
||||
|
||||
assertEquals(setOf(first, second), sessions.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("같은 session의 동시 room 전환에서도 stale room 등록을 남기지 않는다")
|
||||
fun shouldNotLeaveStaleRoomMappingWhenSameSessionSwitchesRoomConcurrently() {
|
||||
val session = sessionWithSynchronizedFirstTwoIdReads("session-1")
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
|
||||
try {
|
||||
val first = executor.submit { registry.register(roomId = 10L, memberId = 20L, session = session) }
|
||||
val second = executor.submit { registry.register(roomId = 11L, memberId = 20L, session = session) }
|
||||
|
||||
first.get(3, TimeUnit.SECONDS)
|
||||
second.get(3, TimeUnit.SECONDS)
|
||||
} finally {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
|
||||
val registeredRooms = listOf(10L, 11L).filter { roomId ->
|
||||
registry.findSessions(roomId = roomId, memberId = 20L).isNotEmpty()
|
||||
}
|
||||
assertEquals(
|
||||
1,
|
||||
registeredRooms.size,
|
||||
"Expected concurrent same-session room switch to leave exactly one active room mapping"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("sessionId별 lock map을 유지하지 않는다")
|
||||
fun shouldNotKeepPerSessionLockMap() {
|
||||
val hasSessionLockMap = UserCreatorChatWebSocketSessionRegistry::class.java.declaredFields
|
||||
.any { field -> field.name == "sessionLocks" }
|
||||
|
||||
assertFalse(
|
||||
hasSessionLockMap,
|
||||
"Expected registry not to keep a per-session lock map that can grow with WebSocket traffic"
|
||||
)
|
||||
}
|
||||
|
||||
private fun sessionWithSynchronizedFirstTwoIdReads(id: String): WebSocketSession {
|
||||
val session = Mockito.mock(WebSocketSession::class.java)
|
||||
val readCount = AtomicInteger()
|
||||
val firstTwoReads = CountDownLatch(2)
|
||||
Mockito.`when`(session.id).thenAnswer {
|
||||
if (readCount.incrementAndGet() <= 2) {
|
||||
firstTwoReads.countDown()
|
||||
firstTwoReads.await(1, TimeUnit.SECONDS)
|
||||
}
|
||||
id
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
private fun session(id: String): WebSocketSession {
|
||||
val session = Mockito.mock(WebSocketSession::class.java)
|
||||
Mockito.`when`(session.id).thenReturn(id)
|
||||
return session
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,7 @@ spring:
|
||||
password:
|
||||
|
||||
jpa:
|
||||
open-in-view: false
|
||||
database: h2
|
||||
database-platform: kr.co.vividnext.sodalive.support.H2MySqlFunctionDialect
|
||||
hibernate:
|
||||
|
||||
Reference in New Issue
Block a user