docs(user-creator-chat): DM 방 생성 요구사항을 기록한다
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
# 크리에이터 시작 DM 방 생성 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** `POST /api/v2/user-creator-chat/rooms/create`가 기존 리스너 시작 DM 생성과 새 크리에이터 시작 DM 생성을 모두 지원한다.
|
||||
|
||||
**Architecture:** 기존 endpoint와 응답 DTO를 유지하고 request DTO만 `recipientId` 중심으로 확장한다. Controller에서 `request.recipientMemberId()`로 단일 상대 회원 ID를 산출하고, 기존 `UserCreatorChatService.createOrGetRoom(member, recipientId)` 흐름과 `validateRecipient` 정책을 재사용한다. 메시지 저장/전달/푸시는 기존 WebSocket 및 메시지 발송 흐름에 맡긴다.
|
||||
|
||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, JUnit 5, Mockito, Gradle Wrapper
|
||||
|
||||
---
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 구현 중 |
|
||||
| 작성일 | 2026-09-14 |
|
||||
| 요구사항 기준 | `docs/20260914_크리에이터_리스너_DM방_생성/prd.md` |
|
||||
| API 기준 | PRD §8 |
|
||||
| 현재 Phase | Phase 1 |
|
||||
| 현재 활성 Goal | 없음 |
|
||||
|
||||
## 목표
|
||||
|
||||
기존 `creatorId` 요청을 깨지 않으면서 신규 `recipientId` 요청으로 발신자 역할과 무관하게 유저-크리에이터 DM 방을 생성/조회할 수 있게 한다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1 | 진행 중 | `3/3` | `P1-GATE` | 전체 `./gradlew build`가 `:test` 단계에서 timeout 됨 |
|
||||
|
||||
## 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- `POST /api/v2/user-creator-chat/rooms/create` request DTO 확장
|
||||
- `recipientId` 표준 필드 추가
|
||||
- 기존 `creatorId` alias 유지
|
||||
- `recipientId`/`creatorId` 누락·불일치 validation
|
||||
- 기존 방 재사용 및 새 방 생성 회귀 검증
|
||||
- `/create`가 메시지를 만들지 않는다는 회귀 검증
|
||||
|
||||
### 제외
|
||||
|
||||
- 첫 메시지 발송 request 추가
|
||||
- WebSocket 프로토콜 변경
|
||||
- FCM 푸시 정책 변경
|
||||
- DB 스키마 변경
|
||||
- 새 endpoint 추가
|
||||
- 기존 `creatorId` alias 제거
|
||||
|
||||
## 기술적 제약
|
||||
|
||||
- 공개 API 응답 `CreateUserCreatorChatRoomResponse(roomId)`는 변경하지 않는다.
|
||||
- 신규 클라이언트는 `recipientId`를 사용하지만, 기존 클라이언트의 `creatorId` 요청도 허용한다.
|
||||
- `recipientId`와 `creatorId`가 모두 있고 값이 다르면 `common.error.invalid_request`로 거부한다.
|
||||
- 권한/상태 검증은 기존 `UserCreatorChatService.validateRecipient` 정책을 재사용한다.
|
||||
- 새 공용 abstraction, 새 dependency, DB migration은 만들지 않는다.
|
||||
- 구현 Task는 RED → GREEN → REFACTOR 순서로 수행한다.
|
||||
|
||||
## 파일 구조 계획
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt`
|
||||
- `CreateUserCreatorChatRoomRequest`에 `recipientId` nullable 필드와 기존 `creatorId` nullable alias를 둔다.
|
||||
- 단일 상대 ID 산출 함수를 DTO 내부에 둔다.
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt`
|
||||
- `request.recipientMemberId()` 결과를 `service.createOrGetRoom(member, recipientId)`에 전달한다.
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`
|
||||
- 함수 파라미터명만 의미에 맞게 `recipientId`로 바꾸는 것을 검토한다. 동작은 유지한다.
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
|
||||
- 새 방 생성, 기존 방 재사용, 메시지 미생성, validation 회귀를 검증한다.
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatControllerMappingTest.kt`
|
||||
- request alias 해석 단위 테스트 또는 controller mapping 테스트를 보강한다.
|
||||
|
||||
## Phase 1: `/create` request 일반화
|
||||
|
||||
**Phase 결과:** 기존 클라이언트와 신규 클라이언트 모두 같은 `/create` endpoint로 DM 방을 생성/조회할 수 있다.
|
||||
|
||||
**선행조건:** PRD `DEC-001`~`DEC-003` 확정.
|
||||
|
||||
**Phase 완료 조건:** `P1-T1`~`P1-T3`과 `P1-GATE` 완료, 검증 기록 누적.
|
||||
|
||||
### 구현 항목
|
||||
|
||||
#### Task 1.1 request DTO alias 계약 추가
|
||||
|
||||
**Goal 실행 `P1-T1`:** `CreateUserCreatorChatRoomRequest`가 `recipientId`와 기존 `creatorId` alias에서 하나의 상대 회원 ID를 산출한다.
|
||||
|
||||
- **시작 조건:** PRD `DMROOM-001~004`, `DEC-003` 확인.
|
||||
- **완료 증거:** DTO/Controller focused test 통과와 request 계약 문서 일치.
|
||||
- **범위 밖:** 방 생성 repository 동작 변경, 메시지 발송.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatControllerMappingTest.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: request JSON `{ "recipientId": Long? }`, `{ "creatorId": Long? }`
|
||||
- Produces: `CreateUserCreatorChatRoomRequest.recipientMemberId(): Long`
|
||||
|
||||
- [x] **RED:** `UserCreatorChatControllerMappingTest`에 `recipientId`만 보낸 요청이 service에 해당 ID를 전달하는 실패 테스트를 작성한다.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun shouldCreateRoomWithRecipientId() {
|
||||
val service = Mockito.mock(UserCreatorChatService::class.java)
|
||||
val controller = UserCreatorChatController(service)
|
||||
val member = Member(email = "creator@test.com", password = "pw", nickname = "creator")
|
||||
member.id = 10L
|
||||
Mockito.`when`(service.createOrGetRoom(member, 20L))
|
||||
.thenReturn(CreateUserCreatorChatRoomResponse(roomId = 30L))
|
||||
|
||||
val response = controller.createOrGetRoom(
|
||||
member,
|
||||
CreateUserCreatorChatRoomRequest(recipientId = 20L, creatorId = null)
|
||||
)
|
||||
|
||||
Mockito.verify(service).createOrGetRoom(member, 20L)
|
||||
assertEquals(30L, response.data!!.roomId)
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **RED:** 기존 `creatorId`만 보낸 요청도 service에 해당 ID를 전달하는 실패 테스트를 작성한다.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun shouldCreateRoomWithLegacyCreatorId() {
|
||||
val request = CreateUserCreatorChatRoomRequest(recipientId = null, creatorId = 20L)
|
||||
|
||||
assertEquals(20L, request.recipientMemberId())
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **RED:** 두 필드가 모두 없거나 서로 다르면 `common.error.invalid_request`가 발생하는 실패 테스트를 작성한다.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun shouldRejectMissingOrConflictingRecipientIds() {
|
||||
val missing = assertThrows(SodaException::class.java) {
|
||||
CreateUserCreatorChatRoomRequest(recipientId = null, creatorId = null).recipientMemberId()
|
||||
}
|
||||
assertEquals("common.error.invalid_request", missing.messageKey)
|
||||
|
||||
val conflict = assertThrows(SodaException::class.java) {
|
||||
CreateUserCreatorChatRoomRequest(recipientId = 20L, creatorId = 21L).recipientMemberId()
|
||||
}
|
||||
assertEquals("common.error.invalid_request", conflict.messageKey)
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest`를 실행해 `recipientId` 생성자 파라미터 또는 `recipientMemberId()` 부재로 실패하는 것을 확인한다.
|
||||
- [x] **GREEN:** DTO를 최소 확장한다.
|
||||
|
||||
```kotlin
|
||||
data class CreateUserCreatorChatRoomRequest(
|
||||
val recipientId: Long? = null,
|
||||
val creatorId: Long? = null
|
||||
) {
|
||||
fun recipientMemberId(): Long {
|
||||
if (recipientId != null && creatorId != null && recipientId != creatorId) {
|
||||
throw SodaException(messageKey = "common.error.invalid_request")
|
||||
}
|
||||
return recipientId ?: creatorId ?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **GREEN:** Controller에서 `request.recipientMemberId()`를 service에 전달한다.
|
||||
|
||||
```kotlin
|
||||
ApiResponse.ok(service.createOrGetRoom(member, request.recipientMemberId()))
|
||||
```
|
||||
|
||||
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
|
||||
- [x] **REFACTOR:** 테스트 helper 중 이번 Task가 만든 중복만 정리하고 `./gradlew ktlintCheck` 결과를 Progress에 기록한다.
|
||||
|
||||
#### Task 1.2 service 방 생성 정책 회귀 보강
|
||||
|
||||
**Goal 실행 `P1-T2`:** 발신자 역할과 무관하게 기존 수신자 검증, 기존 방 재사용, 메시지 미생성 정책을 유지한다.
|
||||
|
||||
- **시작 조건:** `P1-T1` 완료.
|
||||
- **완료 증거:** `UserCreatorChatServiceTest` focused test 통과.
|
||||
- **범위 밖:** `validateRecipient` 정책 변경, WebSocket 발송 구현.
|
||||
|
||||
**Files:**
|
||||
|
||||
- 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`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `createOrGetRoom(member: Member, recipientId: Long)`
|
||||
- Produces: `CreateUserCreatorChatRoomResponse(roomId: Long)`
|
||||
|
||||
- [x] **RED:** 크리에이터 역할 회원이 일반 회원에게 방을 생성할 수 있는 실패 테스트를 작성한다.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun shouldCreateRoomWhenCreatorStartsDmToUser() {
|
||||
val creator = member(1L, "creator").apply { role = MemberRole.CREATOR }
|
||||
val user = member(2L, "user")
|
||||
Mockito.`when`(memberRepository.findById(2L)).thenReturn(Optional.of(user))
|
||||
Mockito.`when`(roomRepository.findActiveRoomByParticipantMemberIds(1L, 2L)).thenReturn(null)
|
||||
Mockito.`when`(roomRepository.save(Mockito.any(UserCreatorChatRoom::class.java))).thenReturn(room(10L))
|
||||
|
||||
val response = service.createOrGetRoom(creator, 2L)
|
||||
|
||||
assertEquals(10L, response.roomId)
|
||||
Mockito.verify(participantRepository).save(Mockito.argThat { it.member == creator })
|
||||
Mockito.verify(participantRepository).save(Mockito.argThat { it.member == user })
|
||||
Mockito.verifyNoInteractions(messageRepository)
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **RED:** 기존 활성 방이 있으면 새 방과 메시지를 만들지 않는 회귀 테스트를 작성한다.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun shouldReturnExistingRoomWithoutCreatingMessage() {
|
||||
val creator = member(1L, "creator").apply { role = MemberRole.CREATOR }
|
||||
val user = member(2L, "user")
|
||||
val existingRoom = room(10L)
|
||||
Mockito.`when`(memberRepository.findById(2L)).thenReturn(Optional.of(user))
|
||||
Mockito.`when`(roomRepository.findActiveRoomByParticipantMemberIds(1L, 2L)).thenReturn(existingRoom)
|
||||
|
||||
val response = service.createOrGetRoom(creator, 2L)
|
||||
|
||||
assertEquals(10L, response.roomId)
|
||||
Mockito.verify(roomRepository, Mockito.never()).save(Mockito.any(UserCreatorChatRoom::class.java))
|
||||
Mockito.verifyNoInteractions(messageRepository)
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`를 실행해 누락된 동작 또는 helper compile 실패를 확인한다.
|
||||
- [x] **GREEN:** 필요하면 `createOrGetRoom(member, creatorId)` 파라미터명을 `recipientId`로 변경한다. 함수 내부 로직은 기존 `memberRepository.findById`, `validateRecipient`, `findActiveRoomByParticipantMemberIds`, participant 저장 흐름을 유지한다.
|
||||
|
||||
```kotlin
|
||||
@Transactional
|
||||
fun createOrGetRoom(member: Member, recipientId: Long): CreateUserCreatorChatRoomResponse {
|
||||
val recipient = memberRepository.findById(recipientId).orElseThrow {
|
||||
SodaException(messageKey = "message.error.recipient_not_found")
|
||||
}
|
||||
validateRecipient(member, recipient)
|
||||
|
||||
val existingRoom = roomRepository.findActiveRoomByParticipantMemberIds(member.id!!, recipient.id!!)
|
||||
if (existingRoom != null) {
|
||||
return CreateUserCreatorChatRoomResponse(roomId = existingRoom.id!!)
|
||||
}
|
||||
|
||||
val room = roomRepository.save(UserCreatorChatRoom())
|
||||
participantRepository.save(UserCreatorChatParticipant(room, member))
|
||||
participantRepository.save(UserCreatorChatParticipant(room, recipient))
|
||||
return CreateUserCreatorChatRoomResponse(roomId = room.id!!)
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
|
||||
- [x] **REFACTOR:** 파라미터명 변경으로 테스트/문서와 의미가 맞는지 확인하고 새 abstraction 없이 종료한다.
|
||||
|
||||
#### Task 1.3 통합 회귀 검증 보강
|
||||
|
||||
**Goal 실행 `P1-T3`:** 기존 리스너 시작 흐름과 신규 크리에이터 시작 흐름이 통합 환경에서 모두 동작한다.
|
||||
|
||||
- **시작 조건:** `P1-T1`, `P1-T2` 완료.
|
||||
- **완료 증거:** 통합 테스트와 focused 회귀 명령 통과.
|
||||
- **범위 밖:** 전체 메시징 E2E, 푸시 발송 검증.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt`
|
||||
|
||||
- [x] **RED:** 리스너가 `creatorId` alias로 기존처럼 방을 만들 수 있는 통합 회귀 테스트를 작성한다.
|
||||
- [x] **RED:** 크리에이터가 일반 회원 ID로 방을 만들 수 있는 통합 테스트를 작성한다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest`를 실행해 현재 계약 미지원 실패를 확인한다.
|
||||
- [x] **GREEN:** `P1-T1`, `P1-T2` 구현만으로 통합 테스트를 통과시킨다. 추가 production 코드를 만들지 않는다.
|
||||
- [x] **GREEN 확인:** 같은 통합 테스트를 다시 실행해 성공을 확인한다.
|
||||
- [x] **REFACTOR:** 통합 테스트 fixture 중 이번 Task가 만든 중복만 정리한다.
|
||||
|
||||
### 완료 조건
|
||||
|
||||
- [x] `P1-T1`, `P1-T2`, `P1-T3`의 체크박스와 완료 증거가 모두 충족됐다.
|
||||
- [x] PRD `DMROOM-001~007`이 구현 또는 명시적 제외로 추적된다.
|
||||
- [x] 기존 `creatorId` 요청과 신규 `recipientId` 요청의 차이가 문서와 테스트에 남아 있다.
|
||||
|
||||
### 검증 방법
|
||||
|
||||
#### Phase 1 Gate
|
||||
|
||||
**Goal 실행 `P1-GATE`:** `/create` request 호환성, 방 생성 정책, 메시지 비생성 정책을 최종 판정한다.
|
||||
|
||||
- **시작 조건:** Phase 1의 모든 Task goal 완료.
|
||||
- **완료 증거:** 아래 명령 통과와 Progress 기록.
|
||||
- **범위 밖:** 실패와 무관한 채팅방 목록, openRoom 응답, WebSocket 기능 수정.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest
|
||||
./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest
|
||||
./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
**Expected:** 모든 명령이 `BUILD SUCCESSFUL`이고, `/create` 호출만으로 메시지 저장/전달/푸시가 발생하지 않는 테스트가 통과한다.
|
||||
|
||||
## 실행 순서와 의존성
|
||||
|
||||
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | `P1-T1` | PRD 확정 | 아니요 | request 계약 재확인 |
|
||||
| 2 | `P1-T2` | `P1-T1` | 아니요 | 기존 service 정책 대조 |
|
||||
| 3 | `P1-T3` | `P1-T1`, `P1-T2` | 아니요 | 통합 fixture 보정 |
|
||||
| 4 | `P1-GATE` | Phase 1 Task 전체 | 아니요 | 실패 소유 Task로 회귀 수정 |
|
||||
|
||||
```text
|
||||
P1-T1 → P1-T2 → P1-T3 → P1-GATE
|
||||
```
|
||||
|
||||
## 변경 금지 항목
|
||||
|
||||
- `/create`에서 메시지를 저장하거나 발송하지 않는다.
|
||||
- 기존 `creatorId` request를 제거하지 않는다.
|
||||
- `CreateUserCreatorChatRoomResponse` 필드를 바꾸지 않는다.
|
||||
- DB schema, WebSocket message type, FCM event 계약을 변경하지 않는다.
|
||||
- test를 삭제·skip·완화하지 않는다.
|
||||
- 요청 범위 밖 리팩터링과 공용 abstraction을 추가하지 않는다.
|
||||
|
||||
## Progress
|
||||
|
||||
기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다.
|
||||
|
||||
### P1-T1 1차 실행 — 2026-09-14
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: `CreateUserCreatorChatRoomRequest`에 `recipientId` 표준 필드와 `creatorId` alias를 추가하고, controller가 `recipientMemberId()` 결과를 service에 전달하도록 구현했다.
|
||||
- 왜: `DMROOM-001~004`, `DEC-003` 기준으로 신규 클라이언트와 기존 클라이언트 request를 모두 지원하기 위해서다.
|
||||
- 어떻게:
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest` — RED 확인, `recipientId` 생성자 파라미터와 `recipientMemberId()` 부재로 `compileTestKotlin` 실패.
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest` — GREEN 확인, `BUILD SUCCESSFUL`.
|
||||
- 남은 항목: 없음.
|
||||
- 다음 행동: `P1-T2` 진행.
|
||||
|
||||
### P1-T2 1차 실행 — 2026-09-14
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: 크리에이터 시작 DM 방 생성, 기존 방 재사용, `/create` 메시지 미생성 정책을 `UserCreatorChatServiceTest`로 고정했다. production service는 파라미터명만 `recipientId`로 정리했다.
|
||||
- 왜: `DMROOM-005~007`, `DEC-001`, `DEC-002` 기준으로 기존 수신자 검증과 메시지 발송 분리 정책을 유지하기 위해서다.
|
||||
- 어떻게:
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest` — `BUILD SUCCESSFUL`.
|
||||
- 남은 항목: 없음.
|
||||
- 다음 행동: `P1-T3` 진행.
|
||||
|
||||
### P1-T3 1차 실행 — 2026-09-14
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: 리스너 시작 기존 흐름과 크리에이터 시작 신규 흐름을 `UserCreatorChatServiceIntegrationTest`에 추가했다.
|
||||
- 왜: 실제 JPA 통합 환경에서 양방향 방 생성과 메시지 미생성을 확인하기 위해서다.
|
||||
- 어떻게:
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest` — `BUILD SUCCESSFUL`.
|
||||
- 남은 항목: 없음.
|
||||
- 다음 행동: `P1-GATE` 진행.
|
||||
|
||||
### P1-GATE 1차 실행 — 2026-09-14
|
||||
|
||||
- 상태: 차단 감사 중
|
||||
- 무엇을: Phase 1 focused test와 lint를 검증하고 전체 build를 시도했다.
|
||||
- 왜: `/create` request 호환성, 방 생성 정책, 메시지 비생성 정책과 공통 품질 기준을 판정하기 위해서다.
|
||||
- 어떻게:
|
||||
- `./gradlew test --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`.
|
||||
- `./gradlew ktlintCheck` — 최초 import 정렬 오류로 실패 후 수정, 재실행 `BUILD SUCCESSFUL`.
|
||||
- `./gradlew build` — 120초 timeout, 재시도 600초 timeout. 두 번 모두 `:test` 단계에서 종료되지 않아 전체 build 성공 증거는 확보하지 못했다.
|
||||
- 남은 항목: 전체 build timeout 원인 분리 또는 별도 승인.
|
||||
- 다음 행동: 변경 범위 리뷰와 전체 suite hang 원인 보고.
|
||||
|
||||
## Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-09-14 | `DEC-001` | 확정 | `/create`는 방 생성/조회만 처리한다. | 사용자 선택 A | `P1-T2`, PRD `DEC-001` |
|
||||
| 2026-09-14 | `DEC-002` | 확정 | 수신자는 `MemberRole` 제한 없이 기존 수신자 검증 정책을 따른다. | 사용자 선택 B | `P1-T2`, PRD `DEC-002` |
|
||||
| 2026-09-14 | `DEC-003` | 확정 | `recipientId`를 표준 필드로 추가하고 `creatorId` alias를 유지한다. | 기존 클라이언트 호환 필요 | `P1-T1`, PRD `DEC-003` |
|
||||
|
||||
## 발견된 문제
|
||||
|
||||
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|
||||
|---|---|---|---|---|---|
|
||||
| 없음 | - | - | - | - | - |
|
||||
@@ -0,0 +1,159 @@
|
||||
# PRD: 크리에이터 시작 DM 방 생성
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 문서 상태 | 구현 기준 확정 |
|
||||
| 작성일 | 2026-09-14 |
|
||||
| 최종 수정일 | 2026-09-14 |
|
||||
| 대상 제품 | 유저-크리에이터 DM |
|
||||
| 작성자·결정권자 | 사용자 인터뷰 기준 |
|
||||
| 관련 구현 계획 | `docs/20260914_크리에이터_리스너_DM방_생성/plan-task.md` |
|
||||
| 관련 review | 없음 |
|
||||
|
||||
## 1. Overview
|
||||
|
||||
현재 `POST /api/v2/user-creator-chat/rooms/create`는 일반 유저가 크리에이터에게 DM을 시작할 때 방을 생성하거나 기존 방을 반환한다. 이번 요구사항은 같은 DM 도메인에서 크리에이터도 상대 회원에게 먼저 DM 방을 만들 수 있게 하는 것이다.
|
||||
|
||||
첫 메시지 저장, 실시간 전달, 푸시는 이번 API에서 처리하지 않는다. 방 생성 후 메시지는 기존 WebSocket 텍스트 발송 또는 기존 메시지 발송 흐름을 사용한다.
|
||||
|
||||
## 2. Problem Statement
|
||||
|
||||
- 현재 생성 요청 DTO는 `creatorId`만 받기 때문에 API 의미가 “일반 유저가 크리에이터에게 DM 시작”에 고정되어 있다.
|
||||
- 크리에이터가 먼저 상대 회원에게 연락하려면 같은 유저-크리에이터 DM 방을 만들 수 있는 서버 계약이 필요하다.
|
||||
- 기존 클라이언트는 이미 `creatorId`를 보내고 있으므로, 새 필드만 강제하면 기존 리스너 시작 DM 생성 흐름이 깨진다.
|
||||
|
||||
문제 해결 여부는 신규 클라이언트가 `recipientId`로 방을 만들 수 있고, 기존 클라이언트가 `creatorId`로 같은 API를 계속 사용할 수 있는지로 판단한다.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
- `POST /api/v2/user-creator-chat/rooms/create`가 발신자 역할과 무관하게 상대 회원 ID로 DM 방을 생성하거나 기존 방을 반환한다.
|
||||
- 신규 표준 request 필드는 `recipientId`로 한다.
|
||||
- 기존 클라이언트 호환을 위해 `creatorId` request 필드는 alias로 유지한다.
|
||||
- 기존 리스너 → 크리에이터 방 생성 흐름은 계속 동작한다.
|
||||
- 크리에이터 → 상대 회원 방 생성 흐름도 같은 참여자/중복 방 정책을 따른다.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- `/create`에서 첫 텍스트 메시지를 저장하거나 발송하지 않는다.
|
||||
- `/create`에서 WebSocket 메시지를 대신 보내지 않는다.
|
||||
- FCM 푸시 발송 정책은 변경하지 않는다.
|
||||
- DB 스키마를 변경하지 않는다.
|
||||
- 새 endpoint를 만들지 않는다.
|
||||
- 기존 `roomId` 응답 구조를 변경하지 않는다.
|
||||
- `creatorId` alias 제거 시점이나 클라이언트 마이그레이션 일정은 이번 범위에서 정하지 않는다.
|
||||
|
||||
## 5. Target Users and Permissions
|
||||
|
||||
| 사용자 | 목표 | 주요 작업 | 사용 환경 |
|
||||
|---|---|---|---|
|
||||
| 리스너/일반 유저 | 크리에이터에게 먼저 DM 시작 | 상대 회원 지정 후 방 생성 | 모바일 클라이언트 |
|
||||
| 크리에이터 | 상대 회원에게 먼저 DM 시작 | 상대 회원 지정 후 방 생성 | 모바일 클라이언트 |
|
||||
|
||||
권한과 거부 조건:
|
||||
|
||||
- 인증 주체: 로그인 `Member`
|
||||
- 허용 역할: 별도 `MemberRole` 제한 없음
|
||||
- 수신자 조건: 활성 회원, AI 캐릭터 아님, 자기 자신 아님, 차단 정책 통과
|
||||
- 미인증: 기존처럼 `common.error.bad_credentials`
|
||||
- 수신자 없음 또는 AI 캐릭터: 기존 정책에 맞춰 `message.error.recipient_not_found`
|
||||
- 비활성 수신자: 기존처럼 `message.error.recipient_inactive`
|
||||
- 자기 자신: 기존처럼 `common.error.invalid_request`
|
||||
- 상대가 발신자를 차단한 경우: 기존처럼 `message.error.blocked_by_recipient`
|
||||
|
||||
## 6. 핵심 사용자 흐름
|
||||
|
||||
1. 인증 회원이 DM을 시작할 상대 회원을 선택한다.
|
||||
2. 클라이언트는 `POST /api/v2/user-creator-chat/rooms/create`에 `recipientId`를 보낸다.
|
||||
3. 서버는 기존 alias인 `creatorId`만 온 요청도 허용한다.
|
||||
4. 서버는 발신자와 수신자 사이의 활성 DM 방이 있으면 기존 `roomId`를 반환한다.
|
||||
5. 활성 DM 방이 없으면 방과 두 참여자를 생성하고 새 `roomId`를 반환한다.
|
||||
6. 클라이언트는 반환된 `roomId`로 기존 방 입장 및 메시지 발송 흐름을 진행한다.
|
||||
|
||||
## 7. 기능 요구사항
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `DMROOM-001` | 확정 | `/create`는 `recipientId`를 표준 상대 회원 ID로 받아 방을 생성/조회한다. | `recipientId`만 보낸 요청이 기존 `roomId` 응답을 받는다. | `P1-T1` |
|
||||
| `DMROOM-002` | 확정 | 기존 클라이언트 호환을 위해 `creatorId`만 보낸 요청도 계속 허용한다. | 기존 `creatorId` request가 깨지지 않고 동일한 서비스 흐름을 탄다. | `P1-T1` |
|
||||
| `DMROOM-003` | 확정 | `recipientId`와 `creatorId`가 모두 없으면 잘못된 요청으로 거부한다. | `common.error.invalid_request`로 실패한다. | `P1-T1` |
|
||||
| `DMROOM-004` | 확정 | 두 필드가 모두 있고 값이 다르면 모호한 요청으로 거부한다. | `common.error.invalid_request`로 실패하고 방을 만들지 않는다. | `P1-T1` |
|
||||
| `DMROOM-005` | 확정 | 수신자 권한/상태 검증은 기존 `validateRecipient` 정책을 재사용한다. | 비활성, AI 캐릭터, 자기 자신, 차단 관계의 기존 오류 key가 유지된다. | `P1-T2` |
|
||||
| `DMROOM-006` | 확정 | 기존 활성 방이 있으면 새 방을 만들지 않고 기존 `roomId`를 반환한다. | 같은 두 회원으로 두 번 호출해도 활성 방은 1개다. | `P1-T2` |
|
||||
| `DMROOM-007` | 확정 | `/create`는 첫 메시지 저장/전달/푸시를 수행하지 않는다. | 방 생성 후 `UserCreatorChatMessage`가 생성되지 않는다. | `P1-T2` |
|
||||
|
||||
## 8. API 계약
|
||||
|
||||
### 8.1 Endpoint
|
||||
|
||||
- Method: `POST`
|
||||
- Path: `/api/v2/user-creator-chat/rooms/create`
|
||||
- Auth: 로그인 회원 필수
|
||||
- Success envelope: 기존 `ApiResponse.ok(...)`
|
||||
|
||||
### 8.2 Request
|
||||
|
||||
```json
|
||||
{
|
||||
"recipientId": 200,
|
||||
"creatorId": 200
|
||||
}
|
||||
```
|
||||
|
||||
- `recipientId`: 신규 표준 필드. 발신자 반대편 회원 ID다.
|
||||
- `creatorId`: 기존 클라이언트 호환용 alias. 신규 클라이언트는 `recipientId`를 사용한다.
|
||||
- 둘 중 하나만 보내는 요청을 허용한다.
|
||||
- 둘 다 보내는 경우 값이 같으면 허용한다.
|
||||
- 둘 다 보내는 경우 값이 다르면 `common.error.invalid_request`로 거부한다.
|
||||
- 둘 다 없으면 `common.error.invalid_request`로 거부한다.
|
||||
|
||||
### 8.3 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"roomId": 123
|
||||
}
|
||||
```
|
||||
|
||||
- 응답 DTO `CreateUserCreatorChatRoomResponse`의 필드는 변경하지 않는다.
|
||||
|
||||
## 9. 보안과 데이터 취급
|
||||
|
||||
- 수신자 ID는 요청 본문 외 로그에 별도 기록하지 않는다.
|
||||
- 차단 정책은 기존 `BlockMemberRepository.isBlocked(blockedMemberId = sender.id, memberId = recipient.id)` 기준을 유지한다.
|
||||
- AI 캐릭터용 `Member`와의 DM 방은 계속 생성하지 않는다.
|
||||
- 공개 API 스키마에서 기존 필드 제거는 금지한다.
|
||||
|
||||
## 10. 성공 기준
|
||||
|
||||
- [x] 신규 `recipientId` 요청으로 크리에이터가 상대 회원과 DM 방을 만들 수 있다.
|
||||
- [x] 기존 `creatorId` 요청으로 리스너가 크리에이터와 DM 방을 만들 수 있다.
|
||||
- [x] 같은 두 회원의 중복 요청은 같은 활성 `roomId`를 반환한다.
|
||||
- [x] `/create` 호출만으로 메시지, WebSocket 전달, FCM 푸시가 발생하지 않는다.
|
||||
- [x] 오류 key는 기존 정책과 호환된다.
|
||||
|
||||
## 11. Open Questions
|
||||
|
||||
없음. 인터뷰로 아래 결정을 확정했다.
|
||||
|
||||
## 12. 요구사항 추적표
|
||||
|
||||
| 요구사항 범위 | 계획 Phase | Goal | 자동 검증 | 수동 검증 |
|
||||
|---|---:|---|---|---|
|
||||
| `DMROOM-001~004` | 1 | `P1-T1` | `UserCreatorChatControllerMappingTest`, `UserCreatorChatServiceTest` | request alias 계약 대조 |
|
||||
| `DMROOM-005~007` | 1 | `P1-T2` | `UserCreatorChatServiceTest`, `UserCreatorChatServiceIntegrationTest` | 기존 메시지 발송 흐름 분리 확인 |
|
||||
|
||||
## 13. Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-09-14 | `DEC-001` | 확정 | `/create`는 방 생성/조회만 처리하고 첫 메시지는 발송하지 않는다. | 사용자 선택 A | `DMROOM-007`, `P1-T2` |
|
||||
| 2026-09-14 | `DEC-002` | 확정 | 수신자는 `MemberRole`로 제한하지 않고 활성/AI 아님/자기 자신 아님/차단 정책으로 제한한다. | 사용자 선택 B, 기존 `validateRecipient` 정책 | `DMROOM-005`, `P1-T2` |
|
||||
| 2026-09-14 | `DEC-003` | 확정 | 신규 표준 필드는 `recipientId`로 하고 기존 `creatorId`는 alias로 유지한다. | 기능 의미는 A가 맞지만 기존 클라이언트 동작 보존 필요 | `DMROOM-001~004`, `P1-T1` |
|
||||
|
||||
## 14. 변경 관리
|
||||
|
||||
- 코드 구현 전 `plan-task.md`의 체크박스와 Goal 단위를 따른다.
|
||||
- 구현 중 API 계약이 바뀌면 이 PRD의 Decision Log를 먼저 갱신한다.
|
||||
- 기존 `creatorId` alias를 제거하려면 별도 PRD와 클라이언트 마이그레이션 계획을 작성한다.
|
||||
Reference in New Issue
Block a user