From e8d5e07104aa10c14643a58144fa9e763c8c8657 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 10 Jun 2026 16:38:16 +0900 Subject: [PATCH] =?UTF-8?q?docs(usercreatorchat):=20openRoom=20=EC=83=81?= =?UTF-8?q?=EB=8C=80=EB=B0=A9=20=ED=94=84=EB=A1=9C=ED=95=84=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 196 ++++++++++++++++++ .../prd.md | 89 ++++++++ 2 files changed, 285 insertions(+) create mode 100644 docs/20260610_openRoom_상대방_프로필_닉네임/plan-task.md create mode 100644 docs/20260610_openRoom_상대방_프로필_닉네임/prd.md diff --git a/docs/20260610_openRoom_상대방_프로필_닉네임/plan-task.md b/docs/20260610_openRoom_상대방_프로필_닉네임/plan-task.md new file mode 100644 index 00000000..3612787a --- /dev/null +++ b/docs/20260610_openRoom_상대방_프로필_닉네임/plan-task.md @@ -0,0 +1,196 @@ +# openRoom 응답 상대방 프로필/닉네임 추가 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다. + +**Goal:** `GET /api/v2/user-creator-chat/rooms/{roomId}/open` 응답에 현재 로그인 회원 기준 상대방 닉네임과 프로필 이미지 URL을 추가한다. + +**Architecture:** 기존 `UserCreatorChatService.openRoom` 흐름을 유지하고, 이미 존재하는 `UserCreatorChatParticipantRepository.findActiveOpponent(roomId, memberId)`로 상대방 참여자를 조회한다. 응답 DTO인 `UserCreatorChatRoomOpenResponse`에 `opponentNickname`, `opponentProfileImageUrl`만 추가하며, 메시지 조회/페이징/참여자 검증 정책은 변경하지 않는다. + +**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, JUnit 5, Mockito, Gradle Wrapper + +--- + +## 0. 구현 전 확정 사항 + +- 대상 PRD: `docs/20260610_openRoom_상대방_프로필_닉네임/prd.md` +- 대상 API: `GET /api/v2/user-creator-chat/rooms/{roomId}/open` +- 응답 DTO: `UserCreatorChatRoomOpenResponse` +- 신규 응답 필드: + - `opponentNickname: String` + - `opponentProfileImageUrl: String` +- 상대방 기준: 현재 로그인 회원을 제외한 활성 참여자 +- 프로필 이미지 URL 정책: `"$cloudFrontHost/$profilePath"` +- 기본 프로필 이미지 경로: `profile/default-profile.png` +- 기존 응답 필드(`roomId`, `messages`, `hasMore`, `nextCursor`)는 제거/이름 변경하지 않는다. +- `createOrGetRoom`, `getMessages`, 메시지 발송 API 응답은 변경하지 않는다. +- DB 스키마 변경은 하지 않는다. + +--- + +## 1. 파일 구조 계획 + +### 수정 대상 +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt` + - `UserCreatorChatRoomOpenResponse`에 상대방 표시 필드 2개를 추가한다. +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt` + - `openRoom`에서 상대방 참여자를 조회하고 응답 필드를 채운다. + - 기존 메시지 DTO와 같은 기본 프로필 이미지 URL 조합 정책을 유지한다. +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt` + - `openRoom` 응답에 상대방 닉네임/프로필 이미지 URL이 포함되는지 검증한다. + - 상대방 프로필 이미지가 `null`일 때 기본 이미지 URL을 반환하는지 검증한다. + +### 신규 파일 +- 없음. + +--- + +### Phase 1: openRoom 응답 확장 TDD + +- [x] **Task 1.1: openRoom 응답에 상대방 닉네임과 프로필 이미지 URL 추가** + - Files: + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt` + - RED: 기존 `UserCreatorChatServiceTest`에 아래 테스트 2개를 추가한다. + - `shouldOpenRoomWithOpponentProfileWhenOpponentHasProfileImage` + - `shouldOpenRoomWithDefaultOpponentProfileWhenOpponentProfileImageIsNull` + - RED 테스트 코드 예시: + +```kotlin +@Test +@DisplayName("방 입장 응답은 상대방 닉네임과 프로필 이미지 URL을 포함한다") +fun shouldOpenRoomWithOpponentProfileWhenOpponentHasProfileImage() { + val user = member(1L, "user") + val creator = member(2L, "creator").apply { + profileImage = "profile/creator.png" + } + val room = room(10L) + val userParticipant = participant(100L, room, user) + val creatorParticipant = participant(101L, room, creator) + Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room) + Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(userParticipant) + Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(creatorParticipant) + Mockito.`when`( + messageRepository.findByChatRoomAndIsActiveTrueOrderByIdDesc( + room, + PageRequest.of(0, 20) + ) + ).thenReturn(emptyList()) + + val response = service.openRoom(user, roomId = 10L) + + assertEquals(10L, response.roomId) + assertEquals("creator", response.opponentNickname) + assertEquals("https://cdn.test/profile/creator.png", response.opponentProfileImageUrl) + assertEquals(emptyList(), response.messages) + assertFalse(response.hasMore) + assertEquals(null, response.nextCursor) + Mockito.verify(participantRepository).findActiveOpponent(10L, 1L) +} + +@Test +@DisplayName("상대방 프로필 이미지가 없으면 방 입장 응답은 기본 프로필 이미지 URL을 반환한다") +fun shouldOpenRoomWithDefaultOpponentProfileWhenOpponentProfileImageIsNull() { + val user = member(1L, "user") + val creator = member(2L, "creator") + val room = room(10L) + val userParticipant = participant(100L, room, user) + val creatorParticipant = participant(101L, room, creator) + Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room) + Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(userParticipant) + Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(creatorParticipant) + Mockito.`when`( + messageRepository.findByChatRoomAndIsActiveTrueOrderByIdDesc( + room, + PageRequest.of(0, 20) + ) + ).thenReturn(emptyList()) + + val response = service.openRoom(user, roomId = 10L) + + assertEquals("creator", response.opponentNickname) + assertEquals("https://cdn.test/profile/default-profile.png", response.opponentProfileImageUrl) +} +``` + + - 실패 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest` + - Expected: `opponentNickname` 또는 `opponentProfileImageUrl` 프로퍼티가 없어 컴파일 실패한다. + - GREEN: `UserCreatorChatRoomOpenResponse` 생성자에 `opponentNickname`, `opponentProfileImageUrl`을 추가한다. + - GREEN 구현 방향: + +```kotlin +data class UserCreatorChatRoomOpenResponse( + val roomId: Long, + val opponentNickname: String, + val opponentProfileImageUrl: String, + val messages: List, + val hasMore: Boolean, + val nextCursor: Long? +) +``` + + - GREEN: `UserCreatorChatService.openRoom`에서 기존 참여자 검증 후 상대방을 조회해 응답에 채운다. + - GREEN 구현 방향: + +```kotlin +@Transactional +fun openRoom(member: Member, roomId: Long, limit: Int = 20): UserCreatorChatRoomOpenResponse { + val room = findRoom(roomId) + requireParticipant(roomId, member.id!!) + val opponentParticipant = participantRepository.findActiveOpponent(roomId, member.id!!) + ?: throw SodaException(messageKey = "chat.room.invalid_access") + val opponent = opponentParticipant.member + val opponentProfilePath = opponent.profileImage ?: "profile/default-profile.png" + val page = getMessages(member, roomId, cursor = null, limit = limit) + return UserCreatorChatRoomOpenResponse( + roomId = room.id!!, + opponentNickname = opponent.nickname, + opponentProfileImageUrl = "$cloudFrontHost/$opponentProfilePath", + messages = page.messages, + hasMore = page.hasMore, + nextCursor = page.nextCursor + ) +} +``` + + - 통과 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest` + - Expected: `BUILD SUCCESSFUL` + - REFACTOR: 기본 프로필 이미지 문자열 중복이 과하다고 판단되면 `UserCreatorChatService` 안에 private 함수로만 정리한다. 새 공용 유틸이나 별도 추상화는 만들지 않는다. + - REFACTOR 후보: + +```kotlin +private fun profileImageUrl(profileImage: String?): String { + val profilePath = profileImage ?: "profile/default-profile.png" + return "$cloudFrontHost/$profilePath" +} +``` + + - 회귀 확인: + - Run: `./gradlew test` + - Expected: `BUILD SUCCESSFUL` + - Run: `./gradlew ktlintCheck` + - Expected: `BUILD SUCCESSFUL` + - 기대 결과: + - 메시지가 없는 방에서도 `opponentNickname`, `opponentProfileImageUrl`이 반환된다. + - 상대방 프로필 이미지가 있으면 CloudFront URL로 반환된다. + - 상대방 프로필 이미지가 없으면 `https://cdn.test/profile/default-profile.png` 형식으로 반환된다. + - 기존 메시지 조회, 페이징, 참여자 검증 동작은 유지된다. + +--- + +## 2. 구현 후 검증 기록 + +- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest` + - 목적: RED 확인. `openRoom` 상대방 닉네임/프로필 이미지 URL 테스트를 먼저 추가한 뒤 실행. + - 결과: `opponentNickname`, `opponentProfileImageUrl` 미정의 컴파일 오류로 실패. +- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest` + - 목적: GREEN 확인. DTO/서비스 최소 구현 후 `UserCreatorChatServiceTest` 회귀 검증. + - 결과: `BUILD SUCCESSFUL`. +- `./gradlew ktlintCheck` + - 목적: Kotlin lint 회귀 검증. + - 결과: `BUILD SUCCESSFUL`. +- `./gradlew test` + - 목적: 전체 테스트 회귀 검증. + - 결과: `BUILD SUCCESSFUL`. diff --git a/docs/20260610_openRoom_상대방_프로필_닉네임/prd.md b/docs/20260610_openRoom_상대방_프로필_닉네임/prd.md new file mode 100644 index 00000000..38c745e3 --- /dev/null +++ b/docs/20260610_openRoom_상대방_프로필_닉네임/prd.md @@ -0,0 +1,89 @@ +# PRD: openRoom 응답 상대방 프로필/닉네임 추가 + +## 1. Overview +`GET /api/v2/user-creator-chat/rooms/{roomId}/open` 응답에 채팅 상대방의 닉네임과 프로필 이미지 URL을 포함해, 클라이언트가 방 입장 직후 별도 조회 없이 상단 프로필 정보를 표시할 수 있게 한다. + +--- + +## 2. Problem +- 현재 `openRoom` API는 `roomId`, 최신 메시지 목록, 페이징 정보만 반환한다. +- 클라이언트는 채팅방 화면에 필요한 상대방 표시 정보를 `openRoom` 응답에서 바로 얻을 수 없다. +- 메시지 목록의 `senderNickname`, `senderProfileImageUrl`은 각 메시지 발신자 정보라서, 메시지가 없거나 마지막 메시지가 본인 발신인 경우 채팅방 상대방 표시 정보로 안정적으로 쓰기 어렵다. + +--- + +## 3. Goals +- `openRoom` 응답에 현재 로그인 회원 기준 상대방 닉네임을 추가한다. +- `openRoom` 응답에 현재 로그인 회원 기준 상대방 프로필 이미지 URL을 추가한다. +- 기존 `messages`, `hasMore`, `nextCursor` 동작은 변경하지 않는다. +- 인증/참여자 검증, 방 조회 실패, 페이징 정책은 기존 `openRoom` 동작을 유지한다. + +--- + +## 4. Non-Goals +- `createOrGetRoom`, `getMessages`, 메시지 발송 API 응답은 이번 범위에서 변경하지 않는다. +- 채팅방 리스트 API 응답 구조는 이번 범위에서 변경하지 않는다. +- DB 스키마 변경은 이번 범위에 포함하지 않는다. +- 상대방 프로필 스냅샷 저장, 닉네임 변경 이력 표시, 탈퇴 회원 표시 정책 변경은 이번 범위에 포함하지 않는다. +- 차단/비활성 회원 정책은 기존 유저-크리에이터 채팅 정책을 변경하지 않는다. + +--- + +## 5. Target Users +- 유저: 크리에이터와의 채팅방에 입장해 상대방 프로필과 닉네임을 즉시 확인하려는 회원 +- 크리에이터: 유저와의 채팅방에 입장해 상대방 프로필과 닉네임을 즉시 확인하려는 회원 +- 모바일 클라이언트: 방 입장 응답만으로 채팅방 헤더를 렌더링하려는 클라이언트 + +--- + +## 6. User Stories +- 사용자는 채팅방에 입장하자마자 상단에서 상대방 닉네임을 보고 싶다. +- 사용자는 채팅방에 메시지가 없어도 상대방 프로필 이미지를 보고 싶다. +- 클라이언트는 방 입장 후 상대방 정보를 얻기 위해 추가 API를 호출하지 않고 싶다. + +--- + +## 7. Core Features + +### openRoom 응답 확장 + +#### Requirements +- 대상 API는 `GET /api/v2/user-creator-chat/rooms/{roomId}/open`이다. +- 응답 DTO는 `UserCreatorChatRoomOpenResponse`를 확장한다. +- 응답에는 기존 필드에 더해 다음 필드를 포함한다. + - `opponentNickname`: 현재 로그인 회원을 제외한 활성 참여 회원의 `Member.nickname` + - `opponentProfileImageUrl`: 현재 로그인 회원을 제외한 활성 참여 회원의 프로필 이미지 URL +- `opponentProfileImageUrl`은 기존 메시지 DTO의 `senderProfileImageUrl`과 같은 CloudFront URL 조합 정책을 따른다. +- 상대방 `Member.profileImage`가 `null`이면 기존 메시지 DTO와 동일하게 `profile/default-profile.png`를 기본 이미지 경로로 사용한다. +- 상대방 산출은 기존 `UserCreatorChatParticipantRepository.findActiveOpponent(roomId, memberId)` 또는 같은 의미의 기존 조회 패턴을 재사용한다. +- 현재 회원이 방 참여자가 아니면 기존처럼 `chat.room.invalid_access` 예외를 유지한다. +- 활성 방이 아니면 기존처럼 `chat.error.room_not_found` 예외를 유지한다. + +#### Edge Cases +- 메시지가 0개인 방도 `opponentNickname`, `opponentProfileImageUrl`을 반환해야 한다. +- 최신 메시지 발신자가 본인이어도 상대방 필드는 현재 로그인 회원을 제외한 참여자를 기준으로 반환해야 한다. +- 프로필 이미지가 없는 상대방은 기본 이미지 URL을 반환해야 한다. +- 잘못된 `limit` 값 보정 정책은 기존 `getMessages`의 `limit.coerceIn(1, 100)` 동작을 유지한다. + +--- + +## 8. Technical Constraints +- Spring Boot 2.7.14, Kotlin, Java 17, Gradle Wrapper 구조를 유지한다. +- 공개 API 응답 필드 추가이므로 기존 필드는 제거하거나 이름을 변경하지 않는다. +- 구현 변경 예상 파일은 다음 범위로 제한한다. + - `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt` + - `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt` + - 관련 테스트 파일 +- `cloud.aws.cloud-front.host`를 사용하는 기존 URL 생성 방식을 유지한다. +- 새 추상화는 만들지 않고, 기존 `toMessageItemDto`의 기본 프로필 이미지 정책과 일치시키는 최소 변경을 우선한다. + +--- + +## 9. Metrics +- 클라이언트의 채팅방 입장 후 상대방 프로필 조회용 추가 API 호출 제거 여부 +- `openRoom` API 성공 응답에서 `opponentNickname`, `opponentProfileImageUrl` 누락 사례 0건 + +--- + +## 10. Open Questions +- 없음. 필드명은 구현 계획에서 `opponentNickname`, `opponentProfileImageUrl` 기준으로 확정한다.