# 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개 저장한다.