docs(creator-channel): FanTalk 탭 API 계획을 기록한다
This commit is contained in:
545
docs/20260622_크리에이터_채널_FanTalk_탭_API/plan-task.md
Normal file
545
docs/20260622_크리에이터_채널_FanTalk_탭_API/plan-task.md
Normal file
@@ -0,0 +1,545 @@
|
||||
# 크리에이터 채널 FanTalk 탭 API Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
||||
|
||||
**Goal:** 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/fan-talks`로 크리에이터 채널 FanTalk 탭의 전체 FanTalk 개수와 페이징된 FanTalk 글 목록, 크리에이터 답글을 조회할 수 있게 한다.
|
||||
|
||||
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk` 조립 계층에 둔다. FanTalk 조회 service, page 정책, tab domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.fantalk` 하위에 두고 `v2.api.*`에 의존하지 않는다. 저장 엔티티는 legacy `CreatorCheers`를 그대로 사용하되, legacy timezone 기반 cheers 응답은 재사용하지 않고 V2 탭 전용 UTC 응답을 만든다.
|
||||
|
||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
|
||||
|
||||
---
|
||||
|
||||
## 0. 구현 전 확정 사항
|
||||
|
||||
- API endpoint: `GET /api/v2/creator-channels/{creatorId}/fan-talks`
|
||||
- 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다.
|
||||
- request:
|
||||
- path variable: `creatorId`
|
||||
- query parameter: `page`, `required = false`, 기본값 `0`, `page < 0`이면 `0`으로 fallback
|
||||
- query parameter: `size`, `required = false`, 기본값 `20`, `size < 20`이면 `20`, `size > 50`이면 `50`으로 fallback
|
||||
- page 기준: 기존 크리에이터 채널 V2 탭 API와 동일한 0 기반 page index
|
||||
- response:
|
||||
- `fanTalkCount`: 조회자가 조회 가능한 최상위 FanTalk 전체 개수
|
||||
- `fanTalks`: FanTalk 글 목록
|
||||
- `page`: fallback 보정 후 실제 적용된 page index
|
||||
- `size`: fallback 보정 후 실제 적용된 page size
|
||||
- `hasNext`: 다음 page 존재 여부
|
||||
- FanTalk item:
|
||||
- `fanTalkId`, `writerId`, `writerNickname`, `writerProfileImageUrl`, `content`, `createdAtUtc`, `creatorReplies`
|
||||
- creator reply item:
|
||||
- `fanTalkId`, `writerId`, `writerNickname`, `writerProfileImageUrl`, `content`, `createdAtUtc`
|
||||
- 저장 엔티티: `kr.co.vividnext.sodalive.explorer.profile.CreatorCheers`
|
||||
- 최상위 FanTalk 기준: `creatorCheers.creator.id == creatorId`, `creatorCheers.isActive == true`, `creatorCheers.parent is null`
|
||||
- 크리에이터 답글 기준: `creatorCheers.parent.id in parentFanTalkIds`, `creatorCheers.creator.id == creatorId`, `creatorCheers.member.id == creatorId`, `creatorCheers.isActive == true`
|
||||
- 팬끼리 답글 작성은 현재 불가능하므로 응답 대상에 포함하지 않는다. 과거 데이터나 비정상 데이터로 크리에이터가 아닌 회원의 답글이 있어도 제외한다.
|
||||
- 목록 정렬:
|
||||
- 최상위 FanTalk: `createdAt desc`, `id desc`
|
||||
- 크리에이터 답글: `createdAt asc`, `id asc`
|
||||
- `fanTalkCount`는 최상위 FanTalk만 계산한다. 답글은 count에 포함하지 않는다.
|
||||
- `hasNext`는 `size + 1`개 조회 또는 동등한 방식으로 판단하고, 응답 목록에는 최대 `size`개만 내려준다.
|
||||
- 차단 필터:
|
||||
- 조회자와 FanTalk 작성자가 서로 차단 관계이면 해당 최상위 FanTalk는 목록과 count에서 제외한다.
|
||||
- 차단으로 제외된 최상위 FanTalk의 답글도 응답에 포함하지 않는다.
|
||||
- 조회자와 조회 대상 크리에이터 사이 차단 관계는 기존 크리에이터 채널 접근 정책과 동일하게 API 접근 자체를 거부한다.
|
||||
- creator 검증:
|
||||
- 조회 대상 회원이 없으면 `member.validation.user_not_found`
|
||||
- 조회 대상 회원이 크리에이터가 아니면 `member.validation.creator_not_found`
|
||||
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 차단 오류
|
||||
- `createdAtUtc`는 `CreatorCheers.createdAt`을 `kr.co.vividnext.sodalive.extensions.toUtcIso`로 변환한다.
|
||||
- 프로필 이미지 URL은 `String?.toCdnUrl(cloudFrontHost)`를 사용하고, 없으면 기존 홈 API와 같은 `"$cloudFrontHost/profile/default-profile.png"`를 내려준다.
|
||||
- 탈퇴 회원 닉네임 prefix 제거는 기존 legacy FanTalk 조회와 홈 FanTalk 요약 응답처럼 `removeDeletedNicknamePrefix()`를 적용한다.
|
||||
- `languageCode`는 FanTalk 탭 응답에 포함하지 않는다.
|
||||
- legacy `/profile/{id}/cheers` 공개 endpoint와 응답 스키마는 변경하지 않는다.
|
||||
- 크리에이터 채널 홈 API의 `fanTalk.totalCount`, `fanTalk.latestFanTalk` 공개 응답 의미는 변경하지 않는다.
|
||||
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 파일 구조 계획
|
||||
|
||||
### FanTalk 탭 신규 API 조립 계층
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkController.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkEndToEndTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt`
|
||||
|
||||
### FanTalk 도메인 조회 계층
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkTab.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicy.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/port/out/CreatorChannelFanTalkQueryPort.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/CreatorChannelFanTalkQueryRepository.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicyTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt`
|
||||
|
||||
### 기존 파일 확인/재사용
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRole.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheers.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt`
|
||||
|
||||
### 문서 산출물
|
||||
- Create: `docs/20260622_크리에이터_채널_FanTalk_탭_API/plan-task.md`
|
||||
- Verify: `docs/20260622_크리에이터_채널_FanTalk_탭_API/prd.md`
|
||||
|
||||
---
|
||||
|
||||
## 2. Response data class 초안
|
||||
|
||||
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
|
||||
|
||||
```kotlin
|
||||
package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import kr.co.vividnext.sodalive.extensions.toUtcIso
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalk
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkReply
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkTab
|
||||
|
||||
data class CreatorChannelFanTalkTabResponse(
|
||||
val fanTalkCount: Int,
|
||||
val fanTalks: List<CreatorChannelFanTalkResponse>,
|
||||
val page: Int,
|
||||
val size: Int,
|
||||
@JsonProperty("hasNext")
|
||||
val hasNext: Boolean
|
||||
) {
|
||||
companion object {
|
||||
fun from(tab: CreatorChannelFanTalkTab): CreatorChannelFanTalkTabResponse {
|
||||
return CreatorChannelFanTalkTabResponse(
|
||||
fanTalkCount = tab.fanTalkCount,
|
||||
fanTalks = tab.fanTalks.map(CreatorChannelFanTalkResponse::from),
|
||||
page = tab.page.page,
|
||||
size = tab.page.size,
|
||||
hasNext = tab.hasNext
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class CreatorChannelFanTalkResponse(
|
||||
val fanTalkId: Long,
|
||||
val writerId: Long,
|
||||
val writerNickname: String,
|
||||
val writerProfileImageUrl: String,
|
||||
val content: String,
|
||||
val createdAtUtc: String,
|
||||
val creatorReplies: List<CreatorChannelFanTalkReplyResponse>
|
||||
) {
|
||||
companion object {
|
||||
fun from(fanTalk: CreatorChannelFanTalk): CreatorChannelFanTalkResponse {
|
||||
return CreatorChannelFanTalkResponse(
|
||||
fanTalkId = fanTalk.fanTalkId,
|
||||
writerId = fanTalk.writerId,
|
||||
writerNickname = fanTalk.writerNickname,
|
||||
writerProfileImageUrl = fanTalk.writerProfileImageUrl,
|
||||
content = fanTalk.content,
|
||||
createdAtUtc = fanTalk.createdAt.toUtcIso(),
|
||||
creatorReplies = fanTalk.creatorReplies.map(CreatorChannelFanTalkReplyResponse::from)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class CreatorChannelFanTalkReplyResponse(
|
||||
val fanTalkId: Long,
|
||||
val writerId: Long,
|
||||
val writerNickname: String,
|
||||
val writerProfileImageUrl: String,
|
||||
val content: String,
|
||||
val createdAtUtc: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(reply: CreatorChannelFanTalkReply): CreatorChannelFanTalkReplyResponse {
|
||||
return CreatorChannelFanTalkReplyResponse(
|
||||
fanTalkId = reply.fanTalkId,
|
||||
writerId = reply.writerId,
|
||||
writerNickname = reply.writerNickname,
|
||||
writerProfileImageUrl = reply.writerProfileImageUrl,
|
||||
content = reply.content,
|
||||
createdAtUtc = reply.createdAt.toUtcIso()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Domain / Port 초안
|
||||
|
||||
구현 시 아래 형태를 기준으로 추가한다. API DTO가 domain model을 참조하지만 domain/port는 API DTO를 참조하지 않는다.
|
||||
|
||||
```kotlin
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class CreatorChannelFanTalkTab(
|
||||
val fanTalkCount: Int,
|
||||
val fanTalks: List<CreatorChannelFanTalk>,
|
||||
val page: CreatorChannelPage,
|
||||
val hasNext: Boolean
|
||||
)
|
||||
|
||||
data class CreatorChannelFanTalk(
|
||||
val fanTalkId: Long,
|
||||
val writerId: Long,
|
||||
val writerNickname: String,
|
||||
val writerProfileImageUrl: String,
|
||||
val content: String,
|
||||
val createdAt: LocalDateTime,
|
||||
val creatorReplies: List<CreatorChannelFanTalkReply>
|
||||
)
|
||||
|
||||
data class CreatorChannelFanTalkReply(
|
||||
val fanTalkId: Long,
|
||||
val writerId: Long,
|
||||
val writerNickname: String,
|
||||
val writerProfileImageUrl: String,
|
||||
val content: String,
|
||||
val createdAt: LocalDateTime
|
||||
)
|
||||
```
|
||||
|
||||
```kotlin
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out
|
||||
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface CreatorChannelFanTalkQueryPort {
|
||||
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkCreatorRecord?
|
||||
|
||||
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
|
||||
|
||||
fun countFanTalks(creatorId: Long, viewerId: Long): Int
|
||||
|
||||
fun findFanTalks(
|
||||
creatorId: Long,
|
||||
viewerId: Long,
|
||||
offset: Long,
|
||||
limit: Int
|
||||
): List<CreatorChannelFanTalkRecord>
|
||||
|
||||
fun findCreatorReplies(
|
||||
creatorId: Long,
|
||||
parentFanTalkIds: List<Long>
|
||||
): List<CreatorChannelFanTalkReplyRecord>
|
||||
}
|
||||
|
||||
data class CreatorChannelFanTalkCreatorRecord(
|
||||
val creatorId: Long,
|
||||
val role: MemberRole,
|
||||
val nickname: String
|
||||
)
|
||||
|
||||
data class CreatorChannelFanTalkRecord(
|
||||
val fanTalkId: Long,
|
||||
val writerId: Long,
|
||||
val writerNickname: String,
|
||||
val writerProfileImagePath: String?,
|
||||
val content: String,
|
||||
val createdAt: LocalDateTime
|
||||
)
|
||||
|
||||
data class CreatorChannelFanTalkReplyRecord(
|
||||
val fanTalkId: Long,
|
||||
val parentFanTalkId: Long,
|
||||
val writerId: Long,
|
||||
val writerNickname: String,
|
||||
val writerProfileImagePath: String?,
|
||||
val content: String,
|
||||
val createdAt: LocalDateTime
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Query policy 초안
|
||||
|
||||
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicy.kt`에 아래 정책을 둔다.
|
||||
|
||||
```kotlin
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Component
|
||||
class CreatorChannelFanTalkQueryPolicy {
|
||||
fun createPage(page: Int?, size: Int?): CreatorChannelPage {
|
||||
return CreatorChannelPage(
|
||||
page = page?.coerceAtLeast(MIN_PAGE) ?: DEFAULT_PAGE,
|
||||
size = size?.coerceIn(MIN_PAGE_SIZE, MAX_PAGE_SIZE) ?: DEFAULT_PAGE_SIZE
|
||||
)
|
||||
}
|
||||
|
||||
fun <T> limitItems(fetched: List<T>, page: CreatorChannelPage): List<T> {
|
||||
return fetched.take(page.size)
|
||||
}
|
||||
|
||||
fun hasNext(fetched: List<*>, page: CreatorChannelPage): Boolean {
|
||||
return fetched.size > page.size
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_PAGE = 0
|
||||
private const val DEFAULT_PAGE_SIZE = 20
|
||||
private const val MIN_PAGE = 0
|
||||
private const val MIN_PAGE_SIZE = 20
|
||||
private const val MAX_PAGE_SIZE = 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 구현 TASK
|
||||
|
||||
### Phase 1: FanTalk 도메인 모델과 페이징 정책
|
||||
|
||||
- [ ] **Task 1.1: FanTalk 페이징 정책 테스트와 구현**
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicyTest.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicy.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
|
||||
- RED: `page`, `size` 보정과 `hasNext`, `limitItems` 동작 테스트를 먼저 작성한다.
|
||||
- 테스트 케이스:
|
||||
- `page == null`, `size == null`이면 `page=0`, `size=20`
|
||||
- `page < 0`이면 `0`
|
||||
- `size < 20`이면 `20`
|
||||
- `size > 50`이면 `50`
|
||||
- fetched size가 `size + 1`이면 `hasNext == true`
|
||||
- fetched size가 `size` 이하이면 `hasNext == false`
|
||||
- `limitItems`는 최대 `size`개만 반환
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest`
|
||||
- GREEN: `CreatorChannelFanTalkQueryPolicy`를 `CreatorChannelCommunityQueryPolicy`와 같은 보정 규칙으로 최소 구현한다.
|
||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest`
|
||||
- REFACTOR: 상수와 메서드명이 커뮤니티/시리즈 탭 정책과 일관되는지 확인한다.
|
||||
|
||||
- [ ] **Task 1.2: FanTalk domain model과 port 계약 추가**
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkTab.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/port/out/CreatorChannelFanTalkQueryPort.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicyTest.kt`
|
||||
- RED: Task 1.1 테스트에 domain/port 타입 import를 추가해 타입 부재 컴파일 실패를 확인한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest`
|
||||
- GREEN: `CreatorChannelFanTalkTab`, `CreatorChannelFanTalk`, `CreatorChannelFanTalkReply`, `CreatorChannelFanTalkQueryPort`, record data class를 추가한다.
|
||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest`
|
||||
- REFACTOR: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain`과 `port/out`에서 `v2.api` import가 없는지 확인한다.
|
||||
- 확인 명령: `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
|
||||
|
||||
### Phase 2: API 응답 DTO와 조립 계층
|
||||
|
||||
- [ ] **Task 2.1: FanTalk 응답 DTO와 UTC 변환 테스트**
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt`
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
|
||||
- RED: facade 테스트에서 domain tab을 response로 변환했을 때 필드명과 UTC 문자열이 PRD와 일치하는지 검증한다.
|
||||
- 검증 값:
|
||||
- `fanTalkCount`
|
||||
- `fanTalks[0].writerId`
|
||||
- `fanTalks[0].writerNickname`
|
||||
- `fanTalks[0].writerProfileImageUrl`
|
||||
- `fanTalks[0].content`
|
||||
- `fanTalks[0].createdAtUtc`
|
||||
- `fanTalks[0].creatorReplies[0].writerId`
|
||||
- `page`
|
||||
- `size`
|
||||
- JSON 직렬화 필드명 `hasNext`
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest`
|
||||
- GREEN: DTO를 추가하고 `createdAt.toUtcIso()`를 사용해 UTC ISO 문자열을 내려준다.
|
||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest`
|
||||
- REFACTOR: `languageCode`가 응답 DTO에 포함되지 않았는지 확인한다.
|
||||
- 확인 명령: `rg -n "languageCode" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk`
|
||||
|
||||
- [ ] **Task 2.2: FanTalk facade 추가**
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt`
|
||||
- RED: facade가 query service의 `getFanTalkTab(creatorId, viewer, page, size, now)` 결과를 `CreatorChannelFanTalkTabResponse`로 변환하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest`
|
||||
- GREEN: `CreatorChannelFanTalkFacade`를 `@Service`, `@Transactional(readOnly = true)`로 추가한다.
|
||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest`
|
||||
- REFACTOR: facade가 API DTO와 domain query service 조립 외 책임을 갖지 않는지 확인한다.
|
||||
|
||||
- [ ] **Task 2.3: FanTalk controller 추가**
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkController.kt`
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt`
|
||||
- RED: MockMvc 테스트를 작성한다.
|
||||
- `GET /api/v2/creator-channels/{creatorId}/fan-talks?page=1&size=20` 요청이 facade에 `creatorId`, `page=1`, `size=20`을 전달한다.
|
||||
- 비회원 요청은 `common.error.bad_credentials` 계열 오류를 반환한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest`
|
||||
- GREEN: `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/fan-talks")`, `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member")` 구조로 controller를 추가한다.
|
||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest`
|
||||
- REFACTOR: controller가 `ApiResponse.ok(...)`와 `requireMember` 외 응답 가공 책임을 갖지 않는지 확인한다.
|
||||
|
||||
### Phase 3: FanTalk 조회 서비스
|
||||
|
||||
- [ ] **Task 3.1: query service의 creator 검증과 접근 차단 처리**
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
|
||||
- RED: query service 테스트를 작성한다.
|
||||
- creator가 없으면 `SodaException(messageKey = "member.validation.user_not_found")`
|
||||
- creator role이 `MemberRole.CREATOR`가 아니면 `SodaException(messageKey = "member.validation.creator_not_found")`
|
||||
- 조회자와 크리에이터 사이 차단 관계가 있으면 기존 채널 접근 차단 오류
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
|
||||
- GREEN: `CreatorChannelFanTalkQueryService`를 추가하고 `findCreator`, `existsBlockedBetween`, role 검증을 구현한다.
|
||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
|
||||
- REFACTOR: 에러 키와 차단 메시지 흐름이 커뮤니티/홈 query service와 같은지 확인한다.
|
||||
|
||||
- [ ] **Task 3.2: query service의 page/count/list/reply 조립**
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt`
|
||||
- RED: query service 테스트를 추가한다.
|
||||
- `page=-1`, `size=10` 요청 시 port에는 `offset=0`, `limit=21`이 전달되고 응답 `page=0`, `size=20`
|
||||
- fetched FanTalk가 `size + 1`개이면 응답 목록은 `size`개이고 `hasNext=true`
|
||||
- fetched FanTalk가 비어 있으면 `fanTalks=[]`, `hasNext=false`
|
||||
- `countFanTalks` 결과가 `fanTalkCount`로 내려간다.
|
||||
- `findCreatorReplies` 결과는 parent id 기준으로 각 FanTalk의 `creatorReplies`에 묶인다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
|
||||
- GREEN: `CreatorChannelFanTalkQueryPolicy`로 page를 만들고, `countFanTalks`, `findFanTalks`, `findCreatorReplies`를 호출해 `CreatorChannelFanTalkTab`을 조립한다.
|
||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
|
||||
- REFACTOR: reply 조회는 page에 포함된 parent FanTalk id만 대상으로 호출하는지 확인한다.
|
||||
|
||||
- [ ] **Task 3.3: query service의 URL/닉네임 변환**
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`
|
||||
- RED: query service 테스트를 추가한다.
|
||||
- writer profile path가 있으면 CDN URL로 변환한다.
|
||||
- writer profile path가 없으면 `"$cloudFrontHost/profile/default-profile.png"`를 내려준다.
|
||||
- writer nickname은 `removeDeletedNicknamePrefix()` 결과를 내려준다.
|
||||
- reply writer도 같은 URL/닉네임 변환을 적용한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
|
||||
- GREEN: `String?.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl()`와 `removeDeletedNicknamePrefix()`를 적용한다.
|
||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
|
||||
- REFACTOR: default profile URL 생성 방식이 홈/커뮤니티 query service와 일관되는지 확인한다.
|
||||
|
||||
### Phase 4: QueryDSL repository
|
||||
|
||||
- [ ] **Task 4.1: FanTalk repository 기본 creator/차단 조회**
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/CreatorChannelFanTalkQueryRepository.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt`
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
||||
- RED: repository 테스트를 작성한다.
|
||||
- `findCreator`가 creator id, role, nickname을 조회한다.
|
||||
- `existsBlockedBetween`가 양방향 활성 차단 관계를 감지한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
|
||||
- GREEN: `JPAQueryFactory` 기반 repository를 추가하고 홈/커뮤니티 repository와 같은 creator/차단 조건을 구현한다.
|
||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
|
||||
- REFACTOR: repository class 이름은 `Default...Repository` 접두사 규칙을 따른다.
|
||||
|
||||
- [ ] **Task 4.2: 최상위 FanTalk count/list 조회**
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt`
|
||||
- RED: repository 테스트를 추가한다.
|
||||
- `countFanTalks`는 `creator.id`, `isActive=true`, `parent is null` 조건만 count한다.
|
||||
- 비활성 FanTalk는 count/list에서 제외한다.
|
||||
- 답글 FanTalk는 count/list에서 제외한다.
|
||||
- 조회자와 작성자 사이 차단 관계가 있으면 count/list에서 제외한다.
|
||||
- 목록 정렬은 `createdAt desc`, `id desc`다.
|
||||
- `offset`, `limit`이 적용된다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
|
||||
- GREEN: `countFanTalks`, `findFanTalks`를 구현한다. projection은 `CreatorChannelFanTalkRecord`를 사용한다.
|
||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
|
||||
- REFACTOR: 홈 API의 `fanTalkSummaryCondition`과 조건 의미가 일치하는지 확인한다.
|
||||
|
||||
- [ ] **Task 4.3: 크리에이터 답글 조회**
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt`
|
||||
- RED: repository 테스트를 추가한다.
|
||||
- `findCreatorReplies`는 parent id 목록에 속한 활성 답글만 조회한다.
|
||||
- 답글 작성자가 조회 대상 크리에이터인 데이터만 조회한다.
|
||||
- 크리에이터가 아닌 회원의 답글은 제외한다.
|
||||
- 비활성 답글은 제외한다.
|
||||
- 답글 정렬은 `createdAt asc`, `id asc`다.
|
||||
- `parentFanTalkIds`가 빈 목록이면 빈 목록을 반환하고 DB 조회 결과가 없어야 한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
|
||||
- GREEN: `findCreatorReplies`를 구현한다. projection은 `CreatorChannelFanTalkReplyRecord`를 사용한다.
|
||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
|
||||
- REFACTOR: reply 조회가 최상위 FanTalk page 결과 외 parent를 가져오지 않는지 확인한다.
|
||||
|
||||
### Phase 5: API 통합과 회귀 검증
|
||||
|
||||
- [ ] **Task 5.1: FanTalk End-to-End 테스트**
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkEndToEndTest.kt`
|
||||
- RED: E2E 테스트를 작성한다.
|
||||
- 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/fan-talks?page=0&size=20` 호출 시 200 OK
|
||||
- 응답 JSON에 `fanTalkCount`, `fanTalks`, `page`, `size`, `hasNext`가 포함된다.
|
||||
- 최상위 FanTalk의 `createdAtUtc`는 UTC ISO 문자열이다.
|
||||
- 크리에이터 답글은 `creatorReplies`에 포함된다.
|
||||
- 팬이 작성한 비정상 답글 데이터는 응답에 포함되지 않는다.
|
||||
- page 범위를 벗어나면 빈 목록과 `hasNext=false`를 반환하되 count는 유지한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest`
|
||||
- GREEN: Phase 1~4 구현을 연결해 E2E 테스트를 통과시킨다.
|
||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest`
|
||||
- REFACTOR: 테스트 데이터가 다른 크리에이터 채널 탭 테스트와 충돌하지 않도록 독립 fixture를 사용한다.
|
||||
|
||||
- [ ] **Task 5.2: 패키지 의존 방향과 기존 API 회귀 확인**
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt`
|
||||
- RED: 신규 테스트 추가는 없다. 이 task는 문서화된 구조 검증 task다.
|
||||
- TDD 예외 사유: 패키지 의존 방향과 기존 endpoint 비변경 여부는 정적 검색과 기존 회귀 테스트가 더 직접적인 검증이다.
|
||||
- 대체 검증 방법:
|
||||
- `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
|
||||
- `rg -n "fan-talks|/profile/\\{id\\}/cheers|latestFanTalk" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/main/kotlin/kr/co/vividnext/sodalive/explorer`
|
||||
- GREEN: `v2.creator.channel.fantalk` 하위에서 `v2.api.*` import 검색 결과가 0건인지 확인한다.
|
||||
- 통과 확인:
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest`
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest`
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest`
|
||||
- REFACTOR: 홈 API와 legacy cheers endpoint의 공개 응답 스키마를 변경한 파일 diff가 없는지 확인한다.
|
||||
|
||||
- [ ] **Task 5.3: 전체 FanTalk 관련 테스트와 ktlint 검증**
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
|
||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk`
|
||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
|
||||
- RED: 신규 테스트 추가는 없다. 이 task는 구현 완료 후 회귀 검증 task다.
|
||||
- TDD 예외 사유: 전체 회귀와 ktlint는 구현 완료 상태를 검증하는 명령 실행 task다.
|
||||
- 대체 검증 방법:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"`
|
||||
- `./gradlew ktlintCheck`
|
||||
- GREEN: 실패하는 FanTalk 관련 테스트나 ktlint 오류가 있으면 해당 task의 구현 단계로 돌아가 수정한다.
|
||||
- 통과 확인:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"`
|
||||
- `./gradlew ktlintCheck`
|
||||
- REFACTOR: 필요한 경우 `./gradlew test`를 추가 실행하고 결과를 이 문서 하단 검증 기록에 누적한다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 구현 시 주의사항
|
||||
|
||||
- 구현 전에 이 문서와 `docs/20260622_크리에이터_채널_FanTalk_탭_API/prd.md`가 같은 endpoint, page 기준, response field를 말하는지 다시 확인한다.
|
||||
- 신규 공개 API 스키마 변경이 필요하면 구현 전에 PRD와 이 문서를 먼저 수정한다.
|
||||
- `CreatorCheers` 엔티티 자체 구조는 변경하지 않는다.
|
||||
- legacy `ExplorerQueryRepository.getCheersList`는 timezone 표시 문자열을 만들기 때문에 신규 V2 응답 DTO에 재사용하지 않는다.
|
||||
- FanTalk 탭 query service는 홈 API query service에 의존하지 않는다.
|
||||
- 홈 API의 `findFanTalkSummary`는 이번 작업에서 수정하지 않는 것을 기본으로 한다. 수정이 필요해지면 PRD와 이 문서를 먼저 갱신한다.
|
||||
- controller/facade/DTO 조립 계층은 `v2.api.creator.channel.fantalk`에만 둔다.
|
||||
- domain/application/port/repository 조회 계층은 `v2.creator.channel.fantalk`에만 둔다.
|
||||
- 테스트 작성 시 Redis가 필요 없는 JPA/QueryDSL slice 테스트는 `@DataJpaTest(properties = ["spring.cache.type=none"])` 관례를 따른다.
|
||||
- 테스트 완료 후 각 task 아래에 실행 명령과 결과를 한국어로 누적 기록한다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 검증 기록
|
||||
|
||||
- 문서 생성 시점에는 구현 코드를 작성하지 않았으므로 신규 테스트는 실행하지 않았다.
|
||||
- 문서 변경 검증으로 `./gradlew tasks --all`을 실행했다.
|
||||
- sandbox 일반 실행은 Gradle wrapper가 `/Users/klaus/.gradle/wrapper/dists/gradle-8.1.1-bin/9wiye5v2saajue4irfo8ybqfp/gradle-8.1.1-bin.zip.lck`에 접근하지 못해 `Operation not permitted`로 실패했다.
|
||||
- 권한 승인 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
||||
216
docs/20260622_크리에이터_채널_FanTalk_탭_API/prd.md
Normal file
216
docs/20260622_크리에이터_채널_FanTalk_탭_API/prd.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# PRD: 크리에이터 채널 FanTalk 탭 API
|
||||
|
||||
## 1. Overview
|
||||
크리에이터 채널의 FanTalk 탭에서 전체 FanTalk 개수와 FanTalk 글 목록을 페이징 조회하는 API를 제공한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. Problem
|
||||
- 크리에이터 채널 홈 API는 FanTalk 전체 개수와 최신 FanTalk 1건만 요약으로 제공한다.
|
||||
- FanTalk 탭은 전체 개수, 페이징된 글 목록, 각 글에 달린 크리에이터 답글을 함께 표시해야 한다.
|
||||
- legacy `/profile/{id}/cheers` API는 FanTalk를 조회하지만 날짜를 timezone 기반 표시 문자열로 내려주므로, V2 크리에이터 채널 탭 API에서 요구하는 UTC 기반 응답 계약과 맞지 않는다.
|
||||
- FanTalk 엔티티는 legacy `CreatorCheers`를 사용하되, 신규 API 조립 계층과 도메인 조회 계층은 기존 V2 크리에이터 채널 탭 패턴처럼 분리해야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals
|
||||
- 크리에이터 채널 FanTalk 탭 조회 API를 제공한다.
|
||||
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/fan-talks`로 한다.
|
||||
- 클라이언트에서 호출하는 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk` 하위 조립 계층에 둔다.
|
||||
- FanTalk 목록, 전체 개수, 답글 조회, 페이징 보정, 차단 필터링 같은 조회 책임은 API 패키지 밖의 `kr.co.vividnext.sodalive.v2.creator.channel.fantalk` 도메인 조회 계층에 둔다.
|
||||
- FanTalk 저장 엔티티는 기존 `kr.co.vividnext.sodalive.explorer.profile.CreatorCheers`를 사용한다.
|
||||
- 응답에는 조회 가능한 전체 FanTalk 개수, FanTalk 글 목록, page, size, hasNext를 포함한다.
|
||||
- FanTalk 글 item에는 글쓴이 닉네임, 글쓴이 ID, 글쓴이 프로필 이미지, 글쓴이가 쓴 글, 글 쓴 시간 UTC, 크리에이터가 쓴 답글 목록을 포함한다.
|
||||
- 크리에이터 답글 item도 FanTalk 글과 동일한 작성자/본문/시간 필드 구조를 사용한다.
|
||||
- 페이징 요청값은 기존 V2 크리에이터 채널 커뮤니티/시리즈 탭 API와 같은 보정 규칙을 따른다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Non-Goals
|
||||
- FanTalk 작성, 수정, 삭제 API는 포함하지 않는다.
|
||||
- FanTalk 답글 작성, 수정, 삭제 API는 포함하지 않는다.
|
||||
- 팬 회원 간 답글 작성/조회 기능은 포함하지 않는다. 현재 팬끼리 답글을 작성할 수 없으므로 FanTalk 탭 응답에서도 팬 간 답글을 고려하지 않는다.
|
||||
- legacy `/profile/{id}/cheers` API의 공개 endpoint나 응답 스키마 변경은 포함하지 않는다.
|
||||
- 크리에이터 채널 홈 API의 공개 응답 스키마 변경은 포함하지 않는다.
|
||||
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
|
||||
- 앱 표시용 상대 시간 문구나 timezone 변환 문자열은 서버에서 새로 조합하지 않는다.
|
||||
- 신고, 언어 감지, 푸시 알림 정책 변경은 포함하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 5. Target Users
|
||||
- 회원: 크리에이터 채널 FanTalk 탭에서 다른 팬들의 FanTalk 글과 크리에이터 답글을 탐색하는 사용자
|
||||
- 앱 클라이언트: FanTalk 탭 구성에 필요한 전체 개수와 페이징 목록을 단일 API 응답으로 표시하려는 클라이언트
|
||||
- 서버 개발자: 기존 `CreatorCheers` 저장 구조를 유지하면서 V2 조회 계층을 분리하려는 개발자
|
||||
|
||||
---
|
||||
|
||||
## 6. User Stories
|
||||
- 사용자는 크리에이터 채널 FanTalk 탭에 들어가면 전체 FanTalk 개수를 확인하고 싶다.
|
||||
- 사용자는 FanTalk 글을 최신순으로 추가 로딩하고 싶다.
|
||||
- 사용자는 각 FanTalk 글에 크리에이터가 남긴 답글을 같은 화면에서 확인하고 싶다.
|
||||
- 사용자는 글쓴이 닉네임, ID, 프로필 이미지, 본문, 작성 시간을 목록 item에서 바로 확인하고 싶다.
|
||||
- 앱 클라이언트는 page, size, hasNext를 이용해 추가 로딩 상태를 안정적으로 제어하고 싶다.
|
||||
- 서버 개발자는 API DTO가 도메인 조회 계층으로 새어 들어가지 않는 패키지 의존 방향을 유지하고 싶다.
|
||||
|
||||
---
|
||||
|
||||
## 7. Core Features
|
||||
|
||||
### Feature A. 크리에이터 채널 FanTalk 탭 조회 API
|
||||
|
||||
#### Requirements
|
||||
- 신규 API는 크리에이터 채널 전용 V2 API로 작성한다.
|
||||
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/fan-talks`로 한다.
|
||||
- `creatorId`는 path variable로 받는다.
|
||||
- FanTalk 추가 로딩을 위해 `page`, `size` query parameter를 받는다.
|
||||
- `page`는 기존 V2 탭 API와 동일하게 0부터 시작하는 page index로 처리한다.
|
||||
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
|
||||
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
|
||||
- `page`가 0보다 작으면 `0`으로 보정한다.
|
||||
- `size`가 20보다 작으면 `20`으로 보정한다.
|
||||
- `size`가 50보다 크면 `50`으로 보정한다.
|
||||
- API는 인증 회원만 조회할 수 있어야 한다.
|
||||
- 비회원이 조회하면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
|
||||
- 조회 대상 회원이 존재하지 않으면 기존 정책과 동일하게 `member.validation.user_not_found` 계열 오류를 반환한다.
|
||||
- 조회 대상 회원이 크리에이터가 아니면 기존 정책과 동일하게 `member.validation.creator_not_found` 계열 오류를 반환한다.
|
||||
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
|
||||
- 조회 가능한 FanTalk가 없어도 전체 API는 성공 처리한다.
|
||||
|
||||
#### Edge Cases
|
||||
- `page`가 0보다 작거나 `size`가 허용 범위를 벗어나도 400 오류를 반환하지 않고 실제 적용값으로 보정한다.
|
||||
- 요청한 page 범위에 FanTalk가 없으면 `fanTalks`는 빈 배열, `hasNext`는 `false`로 내려주되 `fanTalkCount`는 전체 개수를 유지한다.
|
||||
- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다.
|
||||
|
||||
### Feature B. 응답 스키마
|
||||
|
||||
#### Requirements
|
||||
- 응답 DTO는 구현 전에 명시하고 공개 API 계약으로 관리한다.
|
||||
- 응답 최상위 DTO 이름은 `CreatorChannelFanTalkTabResponse`로 한다.
|
||||
- 응답에는 다음 값을 포함한다.
|
||||
- `fanTalkCount`: 조회자가 조회 가능한 전체 FanTalk 개수
|
||||
- `fanTalks`: FanTalk 글 목록
|
||||
- `page`: 현재 응답의 page index
|
||||
- `size`: 현재 응답의 page size
|
||||
- `hasNext`: 다음 page 존재 여부
|
||||
- `fanTalkCount`는 최상위 FanTalk 글만 계산한다.
|
||||
- `fanTalkCount`에는 현재 page에 포함되지 않은 FanTalk 글도 포함한다.
|
||||
- `page`, `size`는 fallback 보정 이후 실제 적용된 값을 내려준다.
|
||||
- `hasNext`는 같은 조건에서 다음 page에 노출할 FanTalk 글이 있으면 `true`로 내려준다.
|
||||
- 응답 스키마 예시는 다음과 같다.
|
||||
|
||||
```kotlin
|
||||
data class CreatorChannelFanTalkTabResponse(
|
||||
val fanTalkCount: Int,
|
||||
val fanTalks: List<CreatorChannelFanTalkResponse>,
|
||||
val page: Int,
|
||||
val size: Int,
|
||||
@JsonProperty("hasNext")
|
||||
val hasNext: Boolean
|
||||
)
|
||||
|
||||
data class CreatorChannelFanTalkResponse(
|
||||
val fanTalkId: Long,
|
||||
val writerId: Long,
|
||||
val writerNickname: String,
|
||||
val writerProfileImageUrl: String,
|
||||
val content: String,
|
||||
val createdAtUtc: String,
|
||||
val creatorReplies: List<CreatorChannelFanTalkReplyResponse>
|
||||
)
|
||||
|
||||
data class CreatorChannelFanTalkReplyResponse(
|
||||
val fanTalkId: Long,
|
||||
val writerId: Long,
|
||||
val writerNickname: String,
|
||||
val writerProfileImageUrl: String,
|
||||
val content: String,
|
||||
val createdAtUtc: String
|
||||
)
|
||||
```
|
||||
|
||||
#### Edge Cases
|
||||
- 조회 가능한 FanTalk가 없으면 `fanTalkCount`는 `0`, `fanTalks`는 빈 배열, `hasNext`는 `false`로 내려준다.
|
||||
- FanTalk 글에 크리에이터 답글이 없으면 `creatorReplies`는 빈 배열로 내려준다.
|
||||
- 작성자 프로필 이미지가 없으면 기존 V2 크리에이터 채널 API와 동일하게 기본 프로필 이미지 URL을 내려준다.
|
||||
- 탈퇴 회원 닉네임 prefix 제거는 기존 legacy FanTalk 조회와 홈 FanTalk 요약 응답 정책을 따른다.
|
||||
- `createdAtUtc`는 `CreatorCheers.createdAt`을 UTC 기준 ISO-8601 문자열로 내려준다.
|
||||
- Boolean 응답 필드는 현재 스키마에 없지만, 추후 추가 시 Jackson 직렬화 필드명을 명시해야 한다.
|
||||
|
||||
### Feature C. FanTalk 목록과 개수
|
||||
|
||||
#### Requirements
|
||||
- 조회 대상은 지정한 `creatorId`의 FanTalk로 제한한다.
|
||||
- 저장 엔티티는 `CreatorCheers`를 사용한다.
|
||||
- 최상위 FanTalk 글은 `CreatorCheers.parent is null`인 활성 데이터로 정의한다.
|
||||
- 활성 데이터는 `CreatorCheers.isActive == true`인 데이터로 정의한다.
|
||||
- 목록은 최상위 FanTalk 글만 페이징한다.
|
||||
- 목록 정렬은 최신순을 기본으로 하며 `createdAt desc`, `id desc`를 따른다.
|
||||
- 전체 개수는 목록과 같은 creator, active, parent, 차단 필터 조건을 적용해 계산한다.
|
||||
- 다음 page 존재 여부는 `size + 1`개를 조회하거나 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
|
||||
- 글쓴이 ID는 `CreatorCheers.member.id`를 사용한다.
|
||||
- 글쓴이 닉네임은 `CreatorCheers.member.nickname`을 사용하고 기존 삭제 회원 prefix 제거 정책을 적용한다.
|
||||
- 글쓴이 프로필 이미지는 `CreatorCheers.member.profileImage`를 기존 CDN URL 조합 정책으로 변환한다.
|
||||
- 글쓴이가 쓴 글은 `CreatorCheers.cheers`를 사용한다.
|
||||
- 글 쓴 시간은 `CreatorCheers.createdAt`을 UTC 기준 ISO-8601 문자열로 변환한다.
|
||||
- `languageCode`는 이번 FanTalk 탭 응답에 포함하지 않는다.
|
||||
|
||||
#### Edge Cases
|
||||
- `CreatorCheers.createdAt`이 nullable 기반 엔티티 필드에서 온 경우에도 조회 결과 응답에는 null이 나오지 않아야 한다.
|
||||
- FanTalk 작성자가 조회자와 차단 관계이면 해당 최상위 글은 목록과 개수에서 제외한다.
|
||||
- 차단으로 제외된 최상위 글의 답글도 응답에 포함하지 않는다.
|
||||
- 같은 작성자의 FanTalk가 여러 건 있어도 각각 별도 item으로 내려준다.
|
||||
|
||||
### Feature D. 크리에이터 답글 포함
|
||||
|
||||
#### Requirements
|
||||
- 각 FanTalk 글에는 크리에이터가 쓴 활성 답글 목록을 `creatorReplies`로 포함한다.
|
||||
- 답글은 `CreatorCheers.parent`가 해당 최상위 FanTalk 글인 데이터로 조회한다.
|
||||
- 답글 작성자가 조회 대상 크리에이터인 데이터만 포함한다.
|
||||
- 답글도 `CreatorCheers.isActive == true`인 데이터만 포함한다.
|
||||
- 답글 item의 필드 구조는 최상위 FanTalk 글과 동일한 작성자 ID, 닉네임, 프로필 이미지, 본문, UTC 작성 시간을 사용한다.
|
||||
- 답글 정렬은 오래된 답글부터 확인할 수 있도록 `createdAt asc`, `id asc`를 따른다.
|
||||
- 현재 팬끼리 답글을 작성할 수 없으므로 크리에이터가 아닌 회원의 답글은 정상 응답 대상이 아니다.
|
||||
- 과거 데이터나 비정상 데이터로 크리에이터가 아닌 회원의 답글이 존재하더라도 응답에 포함하지 않는다.
|
||||
|
||||
#### Edge Cases
|
||||
- 크리에이터 답글이 여러 개면 모두 `creatorReplies`에 포함한다.
|
||||
- 크리에이터가 작성했지만 비활성 처리된 답글은 포함하지 않는다.
|
||||
- 답글 작성자인 크리에이터 프로필 이미지가 없으면 기본 프로필 이미지 URL을 내려준다.
|
||||
- 답글 작성자인 크리에이터가 조회자와 차단 관계인 경우는 이미 채널 접근 차단 조건에서 처리된다.
|
||||
|
||||
### Feature E. V2 재사용 범위와 계층 분리
|
||||
|
||||
#### Requirements
|
||||
- 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk` 하위에 둔다.
|
||||
- FanTalk 조회 service, 순수 정책, domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.fantalk` 하위에 둔다.
|
||||
- 도메인 조회 계층은 API response DTO를 import하지 않는다.
|
||||
- 도메인 조회 계층은 API facade나 controller를 import하지 않는다.
|
||||
- 의존 방향은 항상 `v2.api.creator.channel.fantalk -> v2.creator.channel.fantalk`이다.
|
||||
- 페이징 값 보정과 `offset`, `fetchLimit` 계산은 기존 `CreatorChannelPage` 패턴을 재사용한다.
|
||||
- 인증 회원 확인, creator role 검증, 채널 차단 접근 오류는 기존 V2 크리에이터 채널 탭 API와 같은 흐름을 따른다.
|
||||
- 프로필 이미지 CDN URL 변환과 기본 프로필 이미지 URL은 기존 V2 크리에이터 채널 API 정책을 따른다.
|
||||
- UTC ISO 변환은 기존 `toUtcIso` 확장 함수 또는 같은 의미의 기존 V2 변환 방식을 재사용한다.
|
||||
- 기존 홈 API의 FanTalk 요약 조회 로직은 참고하되, 홈 도메인 repository에 신규 탭 페이징 책임을 추가하지 않는다.
|
||||
- legacy `ExplorerQueryRepository.getCheersList`의 timezone 기반 날짜 포맷 응답은 신규 V2 API에서 재사용하지 않는다.
|
||||
|
||||
#### Edge Cases
|
||||
- 신규 `fantalk` 도메인 패키지에서 `v2.api.*` import 검색 결과가 0건이어야 한다.
|
||||
- 홈 API의 `fanTalk.totalCount`, `fanTalk.latestFanTalk` 공개 응답 의미는 변경하지 않는다.
|
||||
- legacy FanTalk 작성/수정/삭제 기능은 기존 패키지에 남겨두고 이번 조회 계층 분리 대상에 포함하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 8. Technical Constraints
|
||||
- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다.
|
||||
- 언어/런타임은 Kotlin + Java 17을 따른다.
|
||||
- 프레임워크는 Spring Boot 2.7.14를 따른다.
|
||||
- 기존 Kotlin/Spring 스타일과 ktlint 규칙을 따른다.
|
||||
- QueryDSL 조회는 기존 V2 크리에이터 채널 탭 repository 패턴을 따른다.
|
||||
- 공개 API 스키마는 구현 중 임의 변경하지 않고, 변경이 필요하면 PRD와 구현 계획/TASK 문서를 먼저 갱신한다.
|
||||
|
||||
---
|
||||
|
||||
## 9. Decisions
|
||||
- endpoint 이름은 `GET /api/v2/creator-channels/{creatorId}/fan-talks`로 확정한다.
|
||||
- `page`는 기존 크리에이터 채널 V2 탭 API와 동일하게 0 기반 page index로 처리한다.
|
||||
Reference in New Issue
Block a user