diff --git a/docs/20260627_콘텐츠_전체보기_API/plan-task.md b/docs/20260627_콘텐츠_전체보기_API/plan-task.md new file mode 100644 index 00000000..883b0b34 --- /dev/null +++ b/docs/20260627_콘텐츠_전체보기_API/plan-task.md @@ -0,0 +1,737 @@ +# 콘텐츠 전체보기 API Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다. + +**Goal:** `GET /api/v2/contents`로 인증 회원이 `NEW_AND_HOT_AUDIO`, `FIRST_AUDIO_CONTENT` 콘텐츠 전체보기 목록을 동일한 페이징 계약으로 조회할 수 있게 한다. + +**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.content.overview` 조립 계층에 둔다. New & Hot 조회는 기존 `v2.content.recommendation` 도메인 조회 계층을 확장해 재사용하고, 첫 번째 오디오 콘텐츠 조회는 기존 `v2.recommendation.application.HomeRecommendationQueryService.findFirstAudioContents(...)`를 재사용한다. 기존 홈 하위 전체보기 endpoint는 배포 전 기능이므로 제거하고, 새 콘텐츠 전체보기 API로 책임을 이동한다. + +**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Security, Spring Data JPA, QueryDSL/native SQL, Redisson, JUnit 5, MockMvc, Gradle Wrapper + +--- + +## 0. 구현 전 확정 사항 + +- API endpoint: `GET /api/v2/contents` +- 인증 정책: 비회원 조회 불가. 인증 회원만 호출할 수 있다. +- 응답 wrapper: `ApiResponse.ok(...)` +- 요청 query parameter: + - `type`: `NEW_AND_HOT_AUDIO`, `FIRST_AUDIO_CONTENT`; 기본값 `NEW_AND_HOT_AUDIO` + - `page`: 0부터 시작. 기본값 `0` + - `size`: 기본값 `20`, 최소값보다 작으면 `20`, 최대 `50` +- invalid `type`은 400 오류 대신 `NEW_AND_HOT_AUDIO`로 fallback한다. +- `hasNext`는 `size + 1`개 조회 후 응답 item은 최대 `size`개만 내려주는 방식으로 계산한다. +- `NEW_AND_HOT_AUDIO`는 `AudioRecommendationQueryService`에 페이징 조회 메서드를 추가해 조회한다. +- New & Hot 첫 화면 노출 수는 `12`로 유지한다. +- New & Hot 스냅샷 저장 수는 `SAFE`, `ALL` 각각 `100`으로 확장한다. +- `FIRST_AUDIO_CONTENT`는 `HomeRecommendationQueryService.findFirstAudioContents(...)`를 새 콘텐츠 전체보기 Facade에서 직접 호출한다. +- `GET /api/v2/home/recommendations/first-audio-contents`는 제거한다. +- 신규 DB 테이블과 DDL은 작성하지 않는다. New & Hot 전체보기용 스냅샷은 기존 `recommendation_snapshot` 테이블을 재사용하고, 저장 개수만 visibility별 100개로 확장한다. + +--- + +## 1. 파일 구조 계획 + +### 신규 API 조립 계층 +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewController.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacade.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewQueryPolicy.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewControllerTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacadeTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewQueryPolicyTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt` + +### 기존 도메인 조회 계층 확장 +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt` + +### 미배포 홈 하위 endpoint 제거 +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt` + +### 통합 검증 +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/RecommendationSnapshotPort.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt` + +--- + +## 2. 공개 응답 및 정책 초안 + +`src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt` + +```kotlin +package kr.co.vividnext.sodalive.v2.api.content.overview.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard +import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeFirstAudioContentRecord + +data class ContentOverviewPageResponse( + val type: ContentOverviewType, + val items: List, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) + +enum class ContentOverviewType { + NEW_AND_HOT_AUDIO, + FIRST_AUDIO_CONTENT; + + companion object { + fun from(value: String?): ContentOverviewType { + return values().firstOrNull { it.name == value } ?: NEW_AND_HOT_AUDIO + } + } +} + +data class ContentOverviewItemResponse( + val contentId: Long, + val title: String, + val coverImage: String?, + val price: Int, + @JsonProperty("isPointAvailable") + val isPointAvailable: Boolean, + val creatorNickname: String, + @JsonProperty("isAdult") + val isAdult: Boolean, + @JsonProperty("isFirstContent") + val isFirstContent: Boolean, + @JsonProperty("isOriginalSeries") + val isOriginalSeries: Boolean +) { + companion object { + fun fromNewAndHot(audio: AudioCard): ContentOverviewItemResponse { + return ContentOverviewItemResponse( + contentId = audio.audioContentId, + title = audio.title, + coverImage = audio.imageUrl, + price = audio.price, + isPointAvailable = audio.isPointAvailable, + creatorNickname = audio.creatorNickname, + isAdult = audio.isAdult, + isFirstContent = audio.isFirstContent, + isOriginalSeries = audio.isOriginalSeries + ) + } + + fun fromFirstAudioContent( + audio: HomeFirstAudioContentRecord, + coverImage: String? + ): ContentOverviewItemResponse { + return ContentOverviewItemResponse( + contentId = audio.contentId, + title = audio.title, + coverImage = coverImage, + price = audio.price, + isPointAvailable = audio.isPointAvailable, + creatorNickname = audio.creatorNickname, + isAdult = audio.isAdult, + isFirstContent = true, + isOriginalSeries = audio.isOriginalSeries + ) + } + } +} +``` + +`src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewQueryPolicy.kt` + +```kotlin +package kr.co.vividnext.sodalive.v2.api.content.overview.application + +import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewType + +data class ContentOverviewPage( + val page: Int, + val size: Int +) { + val offset: Int = page * size +} + +class ContentOverviewQueryPolicy { + fun resolveType(type: String?): ContentOverviewType { + return ContentOverviewType.from(type) + } + + fun createPage(page: Int?, size: Int?): ContentOverviewPage { + val resolvedPage = (page ?: DEFAULT_PAGE).coerceAtLeast(DEFAULT_PAGE) + val requestedSize = size ?: DEFAULT_SIZE + val resolvedSize = if (requestedSize < 1) DEFAULT_SIZE else minOf(requestedSize, MAX_SIZE) + return ContentOverviewPage(page = resolvedPage, size = resolvedSize) + } + + fun pageItems(items: List, page: ContentOverviewPage): List { + return items.take(page.size) + } + + fun hasNext(items: List, page: ContentOverviewPage): Boolean { + return items.size > page.size + } + + companion object { + const val DEFAULT_PAGE = 0 + const val DEFAULT_SIZE = 20 + const val MAX_SIZE = 50 + } +} +``` + +--- + +## 3. 테스트 helper 기준 + +아래 helper는 각 테스트 파일에서 필요한 범위만 복사해 사용한다. Kotlin 1.6.21을 사용하므로 enum 변환 구현에는 `entries`가 아니라 `values()`를 사용한다. + +```kotlin +private fun member(id: Long): Member { + return Member( + email = "viewer$id@test.com", + password = "password", + nickname = "viewer$id", + role = MemberRole.USER + ).apply { + this.id = id + } +} + +private fun audioCard(id: Long): AudioCard { + return AudioCard( + audioContentId = id, + title = "audio$id", + duration = "00:01", + imageUrl = "https://cdn.test/audio$id.png", + price = id.toInt(), + isAdult = false, + isPointAvailable = true, + isFirstContent = true, + isOriginalSeries = false, + creatorNickname = "creator$id" + ) +} + +private fun firstAudio(id: Long): HomeFirstAudioContentRecord { + return HomeFirstAudioContentRecord( + contentId = id, + creatorId = id + 100, + creatorNickname = "creator$id", + creatorProfileImage = null, + title = "first audio$id", + price = id.toInt(), + coverImage = "cover/audio$id.png", + isPointAvailable = true, + isAdult = false, + isOriginalSeries = false + ) +} + +private fun snapshot( + sectionType: RecommendedSectionType, + targetId: Long, + score: Double = 100.0 - targetId, + snapshotAt: LocalDateTime = LocalDateTime.of(2026, 6, 26, 23, 59, 59) +): RecommendationSnapshotRecord { + return RecommendationSnapshotRecord( + sectionType = sectionType, + targetId = targetId, + score = score, + snapshotAt = snapshotAt, + randomTieBreaker = targetId.toDouble() / 1000 + ) +} + +private fun anyLocalDateTime(): LocalDateTime { + return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.of(2026, 6, 27, 0, 0) +} + +private fun emptyResponse(type: ContentOverviewType): ContentOverviewPageResponse { + return ContentOverviewPageResponse( + type = type, + items = emptyList(), + page = 0, + size = 20, + hasNext = false + ) +} +``` + +--- + +### Phase 1: 콘텐츠 전체보기 응답/요청 정책 작성 + +- [ ] **Task 1.1: ContentOverview DTO 직렬화 테스트 작성** + - Files: + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt` + - RED: `ContentOverviewPageResponse`와 `ContentOverviewItemResponse`의 `JsonProperty` 필드명을 검증하는 실패 테스트를 작성한다. + - 테스트 코드 기준: + ```kotlin + class ContentOverviewPageResponseTest { + private val objectMapper = jacksonObjectMapper() + + @Test + fun shouldSerializeContentOverviewPageResponse() { + val response = ContentOverviewPageResponse( + type = ContentOverviewType.NEW_AND_HOT_AUDIO, + items = listOf( + ContentOverviewItemResponse( + contentId = 1L, + title = "audio", + coverImage = "https://cdn.test/audio.png", + price = 10, + isPointAvailable = true, + creatorNickname = "creator", + isAdult = false, + isFirstContent = true, + isOriginalSeries = false + ) + ), + page = 0, + size = 20, + hasNext = true + ) + + val json = objectMapper.readTree(objectMapper.writeValueAsString(response)) + + assertEquals("NEW_AND_HOT_AUDIO", json["type"].asText()) + assertEquals(true, json["hasNext"].asBoolean()) + assertEquals(1L, json["items"][0]["contentId"].asLong()) + assertEquals("https://cdn.test/audio.png", json["items"][0]["coverImage"].asText()) + assertEquals(true, json["items"][0]["isPointAvailable"].asBoolean()) + assertEquals(false, json["items"][0]["isAdult"].asBoolean()) + assertEquals(true, json["items"][0]["isFirstContent"].asBoolean()) + assertEquals(false, json["items"][0]["isOriginalSeries"].asBoolean()) + assertEquals(false, json["items"][0].has("audioContentId")) + assertEquals(false, json["items"][0].has("imageUrl")) + assertEquals(false, json["items"][0].has("duration")) + assertEquals(false, json["items"][0].has("creatorId")) + assertEquals(false, json["items"][0].has("creatorProfileImage")) + assertEquals(false, json["items"][0].has("pointAvailable")) + assertEquals(false, json["items"][0].has("adult")) + assertEquals(false, json["items"][0].has("firstContent")) + assertEquals(false, json["items"][0].has("originalSeries")) + } + } + ``` + - 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponseTest` + - 기대 결과: DTO 파일이 없어서 `compileTestKotlin` 실패. + - GREEN: 위 DTO 초안을 추가하고 테스트를 통과시킨다. + - 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponseTest` + - REFACTOR: import 정리 후 같은 테스트를 재실행한다. + +- [ ] **Task 1.2: ContentOverviewQueryPolicy 테스트와 구현 작성** + - Files: + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewQueryPolicyTest.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewQueryPolicy.kt` + - RED: type/page/size 보정 정책 실패 테스트를 작성한다. + - 테스트 코드 기준: + ```kotlin + class ContentOverviewQueryPolicyTest { + private val policy = ContentOverviewQueryPolicy() + + @Test + fun shouldResolveTypeWithDefaultFallback() { + assertEquals(ContentOverviewType.NEW_AND_HOT_AUDIO, policy.resolveType(null)) + assertEquals(ContentOverviewType.NEW_AND_HOT_AUDIO, policy.resolveType("UNKNOWN")) + assertEquals(ContentOverviewType.FIRST_AUDIO_CONTENT, policy.resolveType("FIRST_AUDIO_CONTENT")) + } + + @Test + fun shouldNormalizePageAndSize() { + assertEquals(ContentOverviewPage(page = 0, size = 20), policy.createPage(null, null)) + assertEquals(ContentOverviewPage(page = 0, size = 20), policy.createPage(-1, 0)) + assertEquals(ContentOverviewPage(page = 2, size = 50), policy.createPage(2, 100)) + } + + @Test + fun shouldCalculatePageItemsAndHasNext() { + val page = ContentOverviewPage(page = 0, size = 2) + val items = listOf(1, 2, 3) + + assertEquals(listOf(1, 2), policy.pageItems(items, page)) + assertEquals(true, policy.hasNext(items, page)) + } + } + ``` + - 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewQueryPolicyTest` + - 기대 결과: policy 파일이 없어서 `compileTestKotlin` 실패. + - GREEN: 위 policy 초안을 추가하고 테스트를 통과시킨다. + - 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewQueryPolicyTest` + - REFACTOR: `ContentOverviewType.from(...)`와 page 보정 로직이 DTO/Facade에 중복되지 않게 유지하고 같은 테스트를 재실행한다. + +--- + +### Phase 2: New & Hot 스냅샷 저장 수와 페이징 조회 분리 + +- [ ] **Task 2.1: New & Hot 스냅샷 저장 limit 100 테스트 작성** + - Files: + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt` + - RED: `refreshDailySnapshots(now)`가 New & Hot 후보 조회 시 `limit = 100`을 전달하는 실패 테스트를 추가한다. + - 테스트 코드 기준: + ```kotlin + @Test + @DisplayName("New & Hot 스냅샷은 visibility별 100개 후보를 저장한다") + fun shouldRequestOneHundredNewAndHotSnapshotsPerVisibility() { + val snapshotPort = FakeRecommendationSnapshotPort() + val queryPort = Mockito.mock(AudioRecommendationQueryPort::class.java) + val service = AudioRecommendationSnapshotRefreshService(snapshotPort, queryPort) + val now = LocalDateTime.of(2026, 6, 27, 0, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 6, 26, 23, 59, 59) + val windowStart = LocalDateTime.of(2026, 6, 24, 0, 0, 0) + + service.refreshDailySnapshots(now) + + Mockito.verify(queryPort).findNewAndHotSnapshots(windowStart, snapshotAt, AudioRecommendationVisibility.SAFE, 100) + Mockito.verify(queryPort).findNewAndHotSnapshots(windowStart, snapshotAt, AudioRecommendationVisibility.ALL, 100) + } + ``` + - 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest` + - 기대 결과: 현재 구현이 `NEW_AND_HOT_LIMIT = 12`를 사용하므로 verify가 실패. + - GREEN: `AudioRecommendationSnapshotRefreshService`에서 `NEW_AND_HOT_SNAPSHOT_LIMIT = 100`을 추가하고 New & Hot 저장 조회에 사용한다. + - 구현 기준: + ```kotlin + companion object { + const val NEW_AND_HOT_SNAPSHOT_LIMIT = 100 + const val MOST_COMMENTED_LIMIT = 5 + const val RECOMMENDED_AUDIO_LIMIT = 10 + private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul") + } + ``` + - 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest` + - REFACTOR: 기존 `NEW_AND_HOT_LIMIT` 이름이 남아 있으면 저장 limit 의미가 드러나는 `NEW_AND_HOT_SNAPSHOT_LIMIT`으로 정리하고 같은 테스트를 재실행한다. + +- [ ] **Task 2.2: AudioRecommendationQueryService의 첫 화면 12개 조회 회귀 테스트 작성** + - Files: + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt` + - RED: `getRecommendations(member)`는 New & Hot 첫 화면 조회 시 여전히 12개만 요청하는 회귀 테스트를 추가한다. + - 테스트 코드 기준: + ```kotlin + @Test + @DisplayName("추천 탭 첫 화면은 New & Hot 스냅샷을 12개만 조회한다") + fun shouldKeepNewAndHotHomeLimitAtTwelve() { + val member = member(id = 10L) + Mockito.doReturn(true).`when`(memberContentPreferenceService).canViewAdultContent(member) + Mockito.doReturn(emptyList()).`when`(snapshotPort) + .findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, limit = 12) + + queryService.getRecommendations(member) + + Mockito.verify(snapshotPort).findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, limit = 12) + } + ``` + - 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest` + - 기대 결과: 상수명을 아직 분리하지 않았거나 test helper가 없으면 컴파일 또는 verify 실패. + - GREEN: `AudioRecommendationQueryService`에 `NEW_AND_HOT_HOME_LIMIT = 12`를 추가하고 첫 화면 조회와 lazy refresh 재조회에 사용한다. + - 구현 기준: + ```kotlin + companion object { + const val NEW_AND_HOT_HOME_LIMIT = 12 + // 기존 NEW_AND_HOT_AUDIO_LIMIT 사용처는 첫 화면 의미이면 NEW_AND_HOT_HOME_LIMIT로 교체한다. + } + ``` + - 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest` + - REFACTOR: 첫 화면 limit과 스냅샷 저장 limit 이름이 섞이지 않게 import/상수명을 정리하고 같은 테스트를 재실행한다. + +- [ ] **Task 2.3: New & Hot 전체보기 페이징 조회 테스트 작성** + - Files: + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt` + - RED: `findNewAndHotAudios(member, offset, limit)`가 visibility, offset, limit을 반영하고 상세 조회 순서를 유지하는 실패 테스트를 작성한다. + - 테스트 코드 기준: + ```kotlin + @Test + @DisplayName("New & Hot 전체보기는 스냅샷 offset과 limit으로 오디오 카드를 조회한다") + fun shouldFindNewAndHotAudiosWithOffsetAndLimit() { + val member = member(id = 10L) + val nowSnapshots = listOf( + snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, targetId = 3L), + snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, targetId = 4L), + snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, targetId = 5L) + ) + Mockito.doReturn(true).`when`(memberContentPreferenceService).canViewAdultContent(member) + Mockito.doReturn(nowSnapshots).`when`(snapshotPort) + .findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, offset = 20, limit = 21) + Mockito.doReturn(listOf(audioCard(3L), audioCard(4L), audioCard(5L))).`when`(queryPort) + .findAudioCardsByIds(listOf(3L, 4L, 5L), member.id, true, anyLocalDateTime()) + + val result = queryService.findNewAndHotAudios(member, offset = 20, limit = 21) + + assertEquals(listOf(3L, 4L, 5L), result.map { it.audioContentId }) + Mockito.verify(snapshotPort).findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, offset = 20, limit = 21) + } + ``` + - 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest` + - 기대 결과: `findNewAndHotAudios` 메서드가 없어 `compileTestKotlin` 실패. + - GREEN: `AudioRecommendationQueryService.findNewAndHotAudios(member, offset, limit)`를 추가한다. + - 구현 기준: + ```kotlin + fun findNewAndHotAudios(member: Member, offset: Int, limit: Int): List { + val now = LocalDateTime.now() + val canViewAdultContent = canViewAdultContent(member) + val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE + val sectionType = newAndHotSectionType(visibility) + val snapshots = findNewAndHotSnapshotsWithLazyRefresh(sectionType, offset, limit) + + return queryPort.findAudioCardsByIds( + snapshots.map { it.targetId }, + member.id, + canViewAdultContent, + now + ) + } + ``` + - 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest` + - REFACTOR: 기존 `refreshMissingNewAndHotSnapshots(...)`는 첫 화면과 전체보기에서 공통 사용 가능한 private 함수로 정리하고 같은 테스트를 재실행한다. + +--- + +### Phase 3: 콘텐츠 전체보기 API 조립 계층 작성 + +- [ ] **Task 3.1: ContentOverviewFacade 테스트 작성** + - Files: + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacadeTest.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacade.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt` + - RED: `NEW_AND_HOT_AUDIO`와 `FIRST_AUDIO_CONTENT`를 각각 조회해 `ContentOverviewPageResponse`로 변환하는 실패 테스트를 작성한다. + - 테스트 코드 기준: + ```kotlin + class ContentOverviewFacadeTest { + private val audioRecommendationQueryService = Mockito.mock(AudioRecommendationQueryService::class.java) + private val homeRecommendationQueryService = Mockito.mock(HomeRecommendationQueryService::class.java) + private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java) + private val facade = ContentOverviewFacade( + audioRecommendationQueryService = audioRecommendationQueryService, + homeRecommendationQueryService = homeRecommendationQueryService, + memberContentPreferenceService = memberContentPreferenceService, + cloudFrontHost = "https://cdn.test", + queryPolicy = ContentOverviewQueryPolicy() + ) + + @Test + fun shouldReturnNewAndHotPage() { + val member = member(id = 10L) + Mockito.doReturn(listOf(audioCard(1L), audioCard(2L), audioCard(3L))).`when`(audioRecommendationQueryService) + .findNewAndHotAudios(member, offset = 0, limit = 3) + + val response = facade.getContents("NEW_AND_HOT_AUDIO", page = 0, size = 2, member = member) + + assertEquals(ContentOverviewType.NEW_AND_HOT_AUDIO, response.type) + assertEquals(listOf(1L, 2L), response.items.map { it.contentId }) + assertEquals(listOf("https://cdn.test/audio1.png", "https://cdn.test/audio2.png"), response.items.map { it.coverImage }) + assertEquals(true, response.hasNext) + } + + @Test + fun shouldReturnFirstAudioContentPage() { + val member = member(id = 10L) + Mockito.doReturn(true).`when`(memberContentPreferenceService).canViewAdultContent(member) + Mockito.doReturn(listOf(firstAudio(1L), firstAudio(2L))).`when`(homeRecommendationQueryService) + .findFirstAudioContents(anyLocalDateTime(), offset = 20, limit = 21, memberId = member.id, includeAdultContents = true) + + val response = facade.getContents("FIRST_AUDIO_CONTENT", page = 1, size = 20, member = member) + + assertEquals(ContentOverviewType.FIRST_AUDIO_CONTENT, response.type) + assertEquals(listOf(1L, 2L), response.items.map { it.contentId }) + assertEquals("https://cdn.test/cover/audio1.png", response.items[0].coverImage) + assertEquals(true, response.items[0].isFirstContent) + } + } + ``` + - 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewFacadeTest` + - 기대 결과: Facade 파일이 없어 `compileTestKotlin` 실패. + - GREEN: `ContentOverviewFacade`를 추가하고 `size + 1` 조회, item `take(size)`, `hasNext` 계산을 구현한다. + - GREEN: `HomeFirstAudioContentRecord`에 `isAdult: Boolean`, `isOriginalSeries: Boolean` 필드를 추가하고, `DefaultHomeRecommendationQueryRepository.findFirstAudioContents(...)`가 해당 값을 조회해 채우도록 보강한다. + - 구현 기준: + ```kotlin + fun getContents(type: String?, page: Int?, size: Int?, member: Member): ContentOverviewPageResponse { + val resolvedType = queryPolicy.resolveType(type) + val resolvedPage = queryPolicy.createPage(page, size) + + return when (resolvedType) { + ContentOverviewType.NEW_AND_HOT_AUDIO -> getNewAndHotContents(member, resolvedPage) + ContentOverviewType.FIRST_AUDIO_CONTENT -> getFirstAudioContents(member, resolvedPage) + } + } + ``` + - 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewFacadeTest` + - REFACTOR: `coverImage` CDN URL 변환은 `String?.toCdnUrl(cloudFrontHost)`를 사용하고, 타입별 전용 필드 없이 `ContentOverviewItemResponse`의 동일 필드만 채우는지 확인한 뒤 같은 테스트를 재실행한다. + +- [ ] **Task 3.2: ContentOverviewController 인증/파라미터 테스트 작성** + - Files: + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewControllerTest.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewController.kt` + - RED: 비회원 요청은 401, 인증 회원 요청은 facade에 type/page/size/member를 전달하는 실패 테스트를 작성한다. + - 테스트 코드 기준: + ```kotlin + @WebMvcTest(ContentOverviewController::class) + @Import(SecurityConfig::class) + class ContentOverviewControllerTest @Autowired constructor( + private val mockMvc: MockMvc + ) { + @MockBean + private lateinit var facade: ContentOverviewFacade + + @Test + fun shouldRejectAnonymousRequest() { + mockMvc.perform(get("/api/v2/contents")) + .andExpect(status().isUnauthorized) + } + + @Test + fun shouldPassAuthenticatedMemberAndQueryParameters() { + val member = member(id = 10L) + Mockito.doReturn(emptyResponse(ContentOverviewType.FIRST_AUDIO_CONTENT)).`when`(facade) + .getContents("FIRST_AUDIO_CONTENT", 1, 30, member) + + mockMvc.perform( + get("/api/v2/contents") + .param("type", "FIRST_AUDIO_CONTENT") + .param("page", "1") + .param("size", "30") + .with(user(MemberAdapter(member))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.type").value("FIRST_AUDIO_CONTENT")) + + Mockito.verify(facade).getContents("FIRST_AUDIO_CONTENT", 1, 30, member) + } + } + ``` + - 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewControllerTest` + - 기대 결과: Controller 파일이 없어 `compileTestKotlin` 실패. + - GREEN: `ContentOverviewController`를 추가한다. + - 구현 기준: + ```kotlin + @RestController + @RequestMapping("/api/v2/contents") + class ContentOverviewController( + private val facade: ContentOverviewFacade + ) { + @GetMapping + fun getContents( + @RequestParam(required = false) type: String?, + @RequestParam(required = false) page: Int?, + @RequestParam(required = false) size: Int?, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + ApiResponse.ok(facade.getContents(type, page, size, requireMember(member))) + } + + private fun requireMember(member: Member?): Member { + return member ?: throw SodaException(messageKey = "common.error.bad_credentials") + } + } + ``` + - 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewControllerTest` + - REFACTOR: `SecurityConfig`에 `/api/v2/contents` permitAll을 추가하지 않았는지 확인하고 같은 테스트를 재실행한다. + +--- + +### Phase 4: 미배포 홈 하위 전체보기 endpoint 제거 + +- [ ] **Task 4.1: 홈 하위 first-audio-contents 제거 테스트 갱신** + - Files: + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt` + - RED: `/api/v2/home/recommendations/first-audio-contents`가 더 이상 성공 endpoint가 아님을 확인하는 테스트로 갱신한다. + - 테스트 코드 기준: + ```kotlin + @Test + @DisplayName("미배포 first-audio-contents 홈 하위 endpoint는 제거된다") + fun shouldNotExposeDeprecatedFirstAudioContentsEndpoint() { + val member = saveMember("home-viewer", MemberRole.USER) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/v2/home/recommendations/first-audio-contents") + .with(user(MemberAdapter(member))) + ) + .andExpect(status().isNotFound) + } + ``` + - 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest` + - 기대 결과: 기존 endpoint가 남아 있으면 200 OK로 응답해 테스트 실패. + - GREEN: `HomeRecommendationController.getFirstAudioContents(...)`를 제거하고, `HomeRecommendationFacade.getFirstAudioContents(...)`와 관련 로그 section 처리만 제거한다. + - 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest` + - REFACTOR: `HomeRecommendationQueryService.findFirstAudioContents(...)`는 새 API에서 재사용하므로 제거하지 않았는지 확인하고 같은 테스트를 재실행한다. + +- [ ] **Task 4.2: 홈 전체보기 인증 경로 목록 회귀 테스트 정리** + - Files: + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt` + - RED: 기존 테스트의 경로 목록에서 `/first-audio-contents`를 제거하고 `/lives`, `/debut-creators`, `/ai-characters`만 홈 하위 전체보기 endpoint로 검증하도록 갱신한다. + - 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest` + - 기대 결과: controller 제거와 테스트 기대값이 어긋나면 실패. + - GREEN: 홈 추천 controller 테스트에서 first-audio-contents 성공 응답, facade 실패 로그 검증, 경로 반복 목록을 모두 새 정책에 맞춰 정리한다. + - 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest` + - REFACTOR: 홈 추천 첫 화면의 `firstAudioContents` 필드와 `HOME_FIRST_AUDIO_CONTENT_LIMIT`는 유지되어야 하므로 삭제하지 않았는지 확인한다. + +--- + +### Phase 5: End-to-End 검증 + +- [ ] **Task 5.1: 콘텐츠 전체보기 E2E 테스트 작성** + - Files: + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt` + - RED: 인증 회원 기준 `NEW_AND_HOT_AUDIO`, `FIRST_AUDIO_CONTENT`가 `ApiResponse.ok`와 `items/page/size/hasNext`를 반환하는 E2E 실패 테스트를 작성한다. + - 테스트 범위: + - 비회원 `GET /api/v2/contents`는 401 + - 인증 회원 `GET /api/v2/contents?type=NEW_AND_HOT_AUDIO`는 200, `data.type = NEW_AND_HOT_AUDIO` + - 인증 회원 `GET /api/v2/contents?type=FIRST_AUDIO_CONTENT`는 200, `data.type = FIRST_AUDIO_CONTENT` + - invalid type은 `NEW_AND_HOT_AUDIO`로 fallback + - 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewEndToEndTest` + - 기대 결과: API 구현 전에는 endpoint 미존재 또는 bean 미구성으로 실패. + - GREEN: Phase 1~4 구현을 통합해 E2E 테스트를 통과시킨다. + - 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewEndToEndTest` + - REFACTOR: 테스트 데이터가 다른 추천 테스트와 충돌하지 않도록 각 테스트에서 저장한 데이터만 사용하고, 같은 테스트를 재실행한다. + +- [ ] **Task 5.2: 전체 관련 테스트와 ktlint 검증** + - Files: + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/**` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/**` + - RED: 신규/수정 테스트를 한 번에 실행해 남은 컴파일 오류나 회귀를 확인한다. + - 실행 명령: + - `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.*'` + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest` + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest` + - 기대 결과: 모든 명령 `BUILD SUCCESSFUL`. + - GREEN: 실패가 있으면 해당 task 문서 체크박스를 되돌리고 RED/GREEN 단계로 돌아가 수정한다. + - REFACTOR: `./gradlew ktlintCheck`를 실행하고 `BUILD SUCCESSFUL`을 확인한다. + +--- + +## 검증 기록 + +- 구현 전 문서 생성 단계에서는 코드 변경이 없으므로 단위 테스트를 실행하지 않는다. +- 문서 변경 후 명령 유효성은 `./gradlew tasks --all`로 확인한다. +- 구현 중 각 task 완료 즉시 해당 task 아래에 실행 명령, 결과, 실패 원인과 수정 내용을 한국어로 누적 기록한다. + +--- + +## Self-Review Checklist + +- PRD의 endpoint `GET /api/v2/contents`는 Phase 3과 Phase 5에서 구현/검증한다. +- 비회원 조회 불가는 Phase 3 controller 테스트와 Phase 5 E2E 테스트에서 검증한다. +- `NEW_AND_HOT_AUDIO` 스냅샷 저장 수 100개는 Phase 2에서 검증한다. +- New & Hot 첫 화면 12개 유지 회귀는 Phase 2에서 검증한다. +- `FIRST_AUDIO_CONTENT` 조회 재사용은 Phase 3 Facade 테스트와 Phase 5 E2E 테스트에서 검증한다. +- 미배포 홈 하위 endpoint 제거는 Phase 4에서 검증한다. +- 신규 DB 테이블이 없다는 제약은 파일 구조 계획과 Phase 5 검증 범위에 반영했다. diff --git a/docs/20260627_콘텐츠_전체보기_API/prd.md b/docs/20260627_콘텐츠_전체보기_API/prd.md new file mode 100644 index 00000000..462b2330 --- /dev/null +++ b/docs/20260627_콘텐츠_전체보기_API/prd.md @@ -0,0 +1,242 @@ +# PRD: 콘텐츠 전체보기 API + +## 1. Overview +콘텐츠 섹션에서 노출되는 오디오 목록의 전체보기 화면을 위해 `NEW_AND_HOT_AUDIO`, `FIRST_AUDIO_CONTENT` 두 타입을 페이징으로 조회하는 v2 API를 제공한다. + +--- + +## 2. Problem +- 기존 `GET /api/v2/audio/recommendations`는 추천 탭 첫 화면의 섹션별 기본 개수만 내려주며, New & Hot 섹션 전체보기/페이징 API가 없다. +- 기존 `GET /api/v2/home/recommendations/first-audio-contents`는 아직 배포되지 않은 홈 추천 하위 개별 endpoint이며, 콘텐츠 전체보기 API가 추가되면 별도 유지할 이유가 없다. +- `GET /api/v2/audio/recommendations/contents`는 추천 API 하위 리소스처럼 보이므로, 콘텐츠 전체보기 API라는 의미와 맞지 않는다. +- `GET /api/v2/audio/contents`는 이미 메인 콘텐츠 전체 탭 API가 사용 중이므로, 새 섹션 전체보기 API 경로로 재사용하지 않는다. +- 클라이언트는 전체보기 화면에서 동일한 페이징 응답 형태로 섹션 타입만 바꿔 조회할 수 있어야 한다. +- V2 패키지에는 `AudioRecommendationQueryService`, `HomeRecommendationQueryService`, `AudioCardResponse`, `HomeFirstAudioContentItem`, `HomeRecommendationPageResponse` 등 재사용 가능한 조회/응답 패턴이 있으므로, 새 API는 기존 패턴을 우선 재사용해야 한다. + +--- + +## 3. Goals +- 콘텐츠 전체보기 API를 `kr.co.vividnext.sodalive.v2` 하위 코드로 제공한다. +- 기존 패턴과 동일하게 API 조립 계층과 도메인 조회 계층을 분리한다. +- 조회 타입은 `NEW_AND_HOT_AUDIO`, `FIRST_AUDIO_CONTENT`를 지원한다. +- `NEW_AND_HOT_AUDIO`는 `AudioRecommendationQueryService`의 New & Hot 스냅샷 조회 흐름을 재사용한다. +- `FIRST_AUDIO_CONTENT`는 `HomeRecommendationQueryService.findFirstAudioContents` 조회 흐름을 재사용한다. +- 하나의 endpoint에서 `type` query parameter로 두 타입을 분리한다. +- 비회원 조회를 허용하지 않는다. +- 인증 회원의 차단/성인 콘텐츠 노출 가능 여부 등 기존 사용자 조건을 반영한다. +- 아직 배포되지 않은 `GET /api/v2/home/recommendations/first-audio-contents`는 제거한다. +- PRD에 API endpoint와 Response data class 초안을 포함한다. + +--- + +## 4. Non-Goals +- 기존 `GET /api/v2/audio/recommendations` 공개 응답 스키마를 변경하지 않는다. +- 기존 `GET /api/v2/home/recommendations` 공개 응답 스키마를 변경하지 않는다. +- 기존 `GET /api/v2/home/recommendations/first-audio-contents` endpoint는 배포 전 기능이므로 하위 호환 대상으로 보지 않는다. +- New & Hot 점수 산식, 스냅샷 생성 주기, lazy refresh 정책을 변경하지 않는다. +- 첫 번째 오디오 콘텐츠 판정 기준과 정렬 정책을 변경하지 않는다. +- `RECENT_DEBUT_CREATOR`, `AI_CHARACTER` 등 다른 홈 추천 전체보기 타입은 이번 범위에 포함하지 않는다. +- 새로운 DB 테이블, 배치 작업, 관리자 기능은 이번 범위에 포함하지 않는다. + +--- + +## 5. Target Users +- 회원: 콘텐츠 섹션의 전체보기에서 더 많은 오디오 콘텐츠를 탐색하는 사용자 +- 앱 클라이언트: 동일한 전체보기 화면에서 타입, page, size, hasNext를 기반으로 목록을 구성하려는 클라이언트 + +--- + +## 6. User Stories +- 사용자는 New & Hot 섹션에서 첫 화면에 보이는 개수보다 더 많은 오디오를 보고 싶다. +- 사용자는 처음부터 함께 성장 섹션의 첫 번째 오디오 콘텐츠를 전체보기로 더 탐색하고 싶다. +- 앱 클라이언트는 전체보기 화면에서 `type`만 바꿔 동일한 페이징 응답을 처리하고 싶다. +- 앱 클라이언트는 인증 회원 기준으로 서버가 기존 성인 콘텐츠/차단 정책을 반영한 결과를 받길 원한다. + +--- + +## 7. Core Features + +### Feature A. 콘텐츠 전체보기 통합 조회 API + +#### Requirements +- 신규 API endpoint는 `GET /api/v2/contents`로 정의한다. +- 응답 wrapper는 기존 패턴과 동일하게 `ApiResponse.ok(...)`를 사용한다. +- 비회원 조회를 허용하지 않는다. +- Security 설정은 `GET /api/v2/contents`를 인증 필요 endpoint로 둔다. +- 회원 조회 시 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴과 `requireMember(...)` 가드절을 사용한다. +- 요청 query parameter는 `type`, `page`, `size`를 사용한다. +- `type` 값은 아래 enum으로 정의한다. + - `NEW_AND_HOT_AUDIO`: 콘텐츠 추천 탭 New & Hot 오디오 전체보기 + - `FIRST_AUDIO_CONTENT`: 메인 홈 처음부터 함께 성장 오디오 전체보기 +- `type`을 보내지 않으면 `NEW_AND_HOT_AUDIO`를 기본값으로 사용한다. +- 지원하지 않는 `type` 값이 들어오면 400 오류 대신 `NEW_AND_HOT_AUDIO`로 fallback한다. +- `page`는 0부터 시작하는 page index로 처리한다. +- `page`를 보내지 않으면 기본값 `0`을 사용한다. +- `size`를 보내지 않으면 기본값 `20`을 사용한다. +- `page`가 0보다 작으면 `0`으로 fallback한다. +- `size`가 1보다 작으면 기본값 `20`으로 fallback한다. +- `size`가 50보다 크면 `50`으로 fallback한다. +- 다음 page 존재 여부는 `size + 1`개 조회 또는 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다. + +#### Edge Cases +- 조회 결과가 없으면 `items`는 빈 배열, `hasNext`는 `false`로 내려준다. +- 요청한 page 범위에 콘텐츠가 없으면 `items`는 빈 배열, `hasNext`는 `false`로 내려준다. +- 특정 타입 조회 중 필터링으로 스냅샷 대상 상세 데이터가 제거될 수 있으며, 이 경우 가능한 항목만 내려준다. + +### Feature B. NEW_AND_HOT_AUDIO 전체보기 + +#### Requirements +- `type=NEW_AND_HOT_AUDIO`는 `AudioRecommendationQueryService`의 New & Hot 조회 정책을 재사용한다. +- 인증 회원의 성인 콘텐츠 노출 가능 여부에 따라 `AudioRecommendationVisibility.SAFE` 또는 `AudioRecommendationVisibility.ALL`을 결정한다. +- `SAFE`는 `RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE`, `ALL`은 `RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL` 스냅샷을 조회한다. +- New & Hot 첫 화면 노출 수는 기존과 동일하게 12개로 유지한다. +- New & Hot 스냅샷 저장 수는 전체보기 페이징을 위해 visibility별 100개로 확장한다. +- 스냅샷 저장 수 100개는 `SAFE`와 `ALL` 각각에 적용한다. +- `RecommendationSnapshotPort.findLatestSnapshots(sectionType, offset, limit)`로 page offset과 `size + 1` limit을 적용한다. +- 스냅샷이 없으면 기존 `AudioRecommendationQueryService`의 New & Hot lazy refresh 정책을 재사용한다. +- 스냅샷 target id 목록을 `AudioRecommendationQueryPort.findAudioCardsByIds(...)`로 상세 조회한다. +- 응답 item은 기존 `AudioCardResponse` 필드 의미를 유지한다. + +#### Edge Cases +- lazy refresh 후에도 스냅샷이 없으면 빈 배열로 내려준다. +- 스냅샷에는 있지만 비활성/예약 공개/차단/성인 콘텐츠 정책으로 상세 조회에서 제외된 항목은 응답하지 않는다. + +### Feature C. FIRST_AUDIO_CONTENT 전체보기 + +#### Requirements +- `type=FIRST_AUDIO_CONTENT`는 `HomeRecommendationQueryService.findFirstAudioContents(...)`를 재사용한다. +- `offset = page * size`, `limit = size + 1`로 조회한다. +- `member.id`와 `MemberContentPreferenceService.canViewAdultContent(member)` 결과를 전달한다. +- 응답 item은 `NEW_AND_HOT_AUDIO`와 동일한 `ContentOverviewItemResponse` 필드를 모두 채운다. +- 기존 `HomeFirstAudioContentRecord`에 공통 응답 구성을 위해 필요한 `isAdult`, `isOriginalSeries` 값을 보강한다. +- `FIRST_AUDIO_CONTENT` 응답의 `isFirstContent`는 첫 번째 콘텐츠 섹션 특성상 `true`로 내려준다. + +#### Edge Cases +- 첫 번째 오디오 콘텐츠 판정은 기존 홈 추천 PRD와 현재 `HomeRecommendationQueryService.findFirstAudioContents` 구현을 따른다. +- 예약 공개 콘텐츠는 기존 조회 서비스 정책에 따라 공개 전에는 노출하지 않는다. + +### Feature D. 공통 콘텐츠 정책 + +#### Requirements +- 모든 타입은 공개 가능한 콘텐츠만 조회한다. +- 회원이 차단했거나 회원을 차단한 크리에이터의 콘텐츠는 노출하지 않는다. +- 인증 회원은 기존 콘텐츠 조회 설정에 따라 19금 콘텐츠 노출 가능 여부를 반영한다. +- 이미지 경로와 기본 프로필 이미지는 기존 각 조회 서비스/Facade 변환 정책을 따른다. + +### Feature E. 미배포 홈 하위 전체보기 API 제거 + +#### Requirements +- `HomeRecommendationController`의 `GET /api/v2/home/recommendations/first-audio-contents` endpoint를 제거한다. +- 해당 endpoint만을 위한 `HomeRecommendationFacade.getFirstAudioContents(...)` 조립 메서드는 새 콘텐츠 전체보기 Facade로 책임을 옮긴 뒤 제거한다. +- 관련 Controller/Facade 테스트는 새 `GET /api/v2/contents?type=FIRST_AUDIO_CONTENT` 테스트로 대체한다. +- `SecurityConfig`에 홈 하위 전체보기 endpoint를 위한 별도 설정이 있다면 제거하거나 더 이상 영향이 없게 정리한다. + +#### Edge Cases +- `HomeRecommendationQueryService.findFirstAudioContents(...)`는 새 API에서 재사용하므로 제거하지 않는다. + +--- + +## 8. API Endpoint + +```http +GET /api/v2/contents?type=NEW_AND_HOT_AUDIO&page=0&size=20 +Authorization: Bearer {accessToken} +``` + +- 비회원 조회를 허용하지 않는다. +- `SecurityConfig`에서 `GET /api/v2/contents`는 인증 필요 endpoint로 둔다. +- `type` 미지정 또는 invalid 값은 `NEW_AND_HOT_AUDIO`로 fallback한다. +- `FIRST_AUDIO_CONTENT` 조회 예시는 아래와 같다. + +```http +GET /api/v2/contents?type=FIRST_AUDIO_CONTENT&page=0&size=20 +Authorization: Bearer {accessToken} +``` + +--- + +## 9. Response Data Class + +```kotlin +data class ContentOverviewPageResponse( + val type: ContentOverviewType, + val items: List, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) + +enum class ContentOverviewType { + NEW_AND_HOT_AUDIO, + FIRST_AUDIO_CONTENT +} + +data class ContentOverviewItemResponse( + val contentId: Long, + val title: String, + val coverImage: String?, + val price: Int, + @JsonProperty("isPointAvailable") + val isPointAvailable: Boolean, + val creatorNickname: String, + @JsonProperty("isAdult") + val isAdult: Boolean, + @JsonProperty("isFirstContent") + val isFirstContent: Boolean, + @JsonProperty("isOriginalSeries") + val isOriginalSeries: Boolean +) +``` + +- `NEW_AND_HOT_AUDIO`, `FIRST_AUDIO_CONTENT` 모두 동일한 item 필드를 채우며 타입별 nullable 전용 필드를 두지 않는다. +- 기존 `audioContentId`, `imageUrl` 공개 필드명은 각각 `contentId`, `coverImage`로 사용한다. +- `duration`, `creatorId`, `creatorProfileImage`는 콘텐츠 전체보기 응답에 포함하지 않는다. + +--- + +## 10. Technical Constraints + +### 패키지 구조 +- 공개 API 조립 계층은 콘텐츠 전체보기 API 의미가 드러나도록 `kr.co.vividnext.sodalive.v2.api.content.overview` 하위에 둔다. + - Controller: `...adapter.in.web.ContentOverviewController` + - Facade: `...application.ContentOverviewFacade` + - Response DTO: `...dto.ContentOverviewPageResponse` +- 도메인 조회 계층은 기존 서비스 재사용을 우선한다. + - New & Hot: `kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryService` + - 첫 번째 오디오 콘텐츠: `kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService` +- 신규 도메인 모델/정책이 필요하면 `kr.co.vividnext.sodalive.v2.content.recommendation.domain`에 최소 범위로 추가한다. +- 의존 방향은 `v2.api.content.overview -> v2.content.recommendation`, `v2.api.content.overview -> v2.recommendation`만 허용한다. + +### V2 공통화/재사용 대상 +- `AudioRecommendationQueryService.resolveVisibility(...)` +- `AudioRecommendationQueryService.newAndHotSectionType(...)` +- `RecommendationSnapshotPort.findLatestSnapshots(...)` +- `AudioRecommendationQueryPort.findAudioCardsByIds(...)` +- `HomeRecommendationQueryService.findFirstAudioContents(...)` +- `AudioCardResponse`의 응답 필드 의미와 `JsonProperty` 네이밍 패턴 +- `HomeFirstAudioContentItem`의 응답 필드 의미와 이미지 URL 변환 패턴 +- `HomeRecommendationFacade`의 page/size 보정, `size + 1` 기반 `hasNext` 계산 패턴 + +### 스냅샷 저장 정책 +- New & Hot은 첫 화면 조회 limit과 스냅샷 저장 limit을 분리한다. +- 첫 화면 조회 limit은 `NEW_AND_HOT_HOME_LIMIT = 12`로 유지한다. +- 스냅샷 저장 limit은 `NEW_AND_HOT_SNAPSHOT_LIMIT = 100`으로 정의한다. +- `AudioRecommendationSnapshotRefreshService`는 `findNewAndHotSnapshots(..., limit = NEW_AND_HOT_SNAPSHOT_LIMIT)`로 `SAFE`, `ALL` 각각 최대 100개를 저장한다. +- `AudioRecommendationQueryService.getRecommendations(...)`는 첫 화면 응답 조립 시 최신 스냅샷에서 12개만 조회한다. +- 콘텐츠 전체보기 API는 저장된 100개 스냅샷 범위 안에서 `offset`, `size + 1`로 페이징한다. + +### 구현 판단 +- 별도 endpoint 2개보다 typed endpoint 1개를 기본안으로 한다. +- 이유는 두 요구가 모두 “오디오 콘텐츠 목록 전체보기”이고, page/size/hasNext 응답 계약이 동일하며, `MainContentAllController`도 `type` 기반 단일 endpoint 패턴을 이미 사용하기 때문이다. +- endpoint는 `GET /api/v2/contents`를 사용한다. +- 이유는 `GET /api/v2/audio/recommendations/contents`가 추천 하위 리소스처럼 읽혀 콘텐츠 전체보기 API 의미와 맞지 않고, `GET /api/v2/audio/contents`는 이미 메인 콘텐츠 전체 탭 API가 사용 중이기 때문이다. +- 기존 `GET /api/v2/home/recommendations/first-audio-contents`는 배포 전 endpoint이므로 제거하고, 새 API의 `type=FIRST_AUDIO_CONTENT`로 대체한다. + +--- + +## 11. Decisions + +- `GET /api/v2/contents`는 인증 회원만 호출할 수 있다. +- 기존 홈 하위 전체보기 endpoint는 배포 전 기능이므로 제거한다. +- New & Hot 스냅샷은 전체보기 지원을 위해 visibility별 100개 저장한다.