diff --git a/docs/20260622_크리에이터_채널_FanTalk_탭_API/plan-task.md b/docs/20260622_크리에이터_채널_FanTalk_탭_API/plan-task.md new file mode 100644 index 00000000..6c5c6877 --- /dev/null +++ b/docs/20260622_크리에이터_채널_FanTalk_탭_API/plan-task.md @@ -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, + 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 +) { + 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, + 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 +) + +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 + + fun findCreatorReplies( + creatorId: Long, + parentFanTalkIds: List + ): List +} + +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 limitItems(fetched: List, page: CreatorChannelPage): List { + 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`을 확인했다. diff --git a/docs/20260622_크리에이터_채널_FanTalk_탭_API/prd.md b/docs/20260622_크리에이터_채널_FanTalk_탭_API/prd.md new file mode 100644 index 00000000..5398d341 --- /dev/null +++ b/docs/20260622_크리에이터_채널_FanTalk_탭_API/prd.md @@ -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, + 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 +) + +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로 처리한다.