diff --git a/docs/20260617_크리에이터_채널_라이브_API/plan-task.md b/docs/20260617_크리에이터_채널_라이브_API/plan-task.md index 1b1fb5ca..02134acc 100644 --- a/docs/20260617_크리에이터_채널_라이브_API/plan-task.md +++ b/docs/20260617_크리에이터_채널_라이브_API/plan-task.md @@ -35,11 +35,11 @@ - 성인 콘텐츠는 조회자의 성인 콘텐츠 노출 정책이 false이면 목록과 count에서 제외한다. - 현재 라이브 노출은 기존 홈 API의 `findCurrentLive` 정책을 재사용한다. - 정렬: - - `LATEST`: `releaseDate desc`, `price desc`, random - - `POPULAR`: 구매 매출 합계 desc, `releaseDate desc`, random - - `OWNED`: 조회자 소장 여부 desc, `releaseDate desc`, random - - `PRICE_HIGH`: `price desc`, `releaseDate desc`, random - - `PRICE_LOW`: `price asc`, `releaseDate desc`, random + - `LATEST`: `releaseDate desc`, `price desc`, `audioContent.id desc` + - `POPULAR`: 구매 매출 합계 desc, `releaseDate desc`, `audioContent.id desc` + - `OWNED`: 조회자 소장 여부 desc, `releaseDate desc`, `audioContent.id desc` + - `PRICE_HIGH`: `price desc`, `releaseDate desc`, `audioContent.id desc` + - `PRICE_LOW`: `price asc`, `releaseDate desc`, `audioContent.id desc` - 인기순 매출은 대여/소장 여부와 관계없이 순수하게 결제된 캔 매출인 `orders.can` 합계를 사용한다. `orders.point`는 포함하지 않고, `orders.is_active = true`인 주문만 포함한다. 환불/비활성 주문은 제외한다. - page/size validation은 service에서 명시적으로 수행한다. `page < 0`, `size < 20`, `size > 50`이면 기존 `common.error.invalid_request` 계열 오류를 사용한다. @@ -310,11 +310,13 @@ private fun LocalDateTime.toUtcIso(): String { - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt` - RED: `offset=20, limit=21` 인자를 전달하면 21개까지만 조회하고, 앞 page 콘텐츠가 제외되는지 검증한다. - RED: `LATEST` 정렬에서 공개일 최신순, 같은 공개일이면 높은 가격순이 먼저인지 검증한다. + - RED: `다시듣기`보다 오래된 다른 테마 공개 오디오 콘텐츠가 있으면 `다시듣기` 목록 item의 `isFirstContent`가 `false`인지 검증한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest` - GREEN: `findLiveReplayAudioContents(creatorId, viewerId, now, canViewAdultContent, sort, offset, limit)`를 구현한다. 우선 `LATEST`, `PRICE_HIGH`, `PRICE_LOW` 정렬을 QueryDSL order specifier로 처리한다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest` - REFACTOR: content row 조회, first content id 조회, series summary 조회, order status 조회를 작은 private 함수로 분리한다. - 검증 기록(2026-06-17): `findLiveReplayAudioContents`를 QueryDSL `orderBy`/`offset`/`limit` 기반으로 구현하고, `LATEST`의 공개일/가격 정렬, page offset/limit 적용, first content 및 series summary mapping을 `shouldFindLiveReplayAudioContentsWithPaginationAndLatestSort`로 확인했다. `PRICE_HIGH`, `PRICE_LOW` 정렬은 `shouldSortLiveReplayAudioContentsByPrice`로 확인했다. + - 보완 검증 기록(2026-06-17): `isFirstContent`는 `다시듣기` 테마 안의 첫 콘텐츠가 아니라 크리에이터의 전체 공개 오디오 콘텐츠 중 첫 콘텐츠 기준이어야 하므로 `shouldMarkFirstContentByAllPublicAudioContentsNotLiveReplayTheme`를 추가했다. RED 단계에서 기존 구현이 `isFirstContent == true`를 반환해 실패하는 것을 확인했고, GREEN 단계에서 first content id 조회 조건에서 `다시듣기` 테마 필터를 제거해 기존 홈 API와 같은 전체 공개 오디오 기준으로 보정했다. 이후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`, `./gradlew ktlintCheck`, `git diff --check` 성공을 확인했다. - [x] **Task 3.3: `POPULAR` 정렬 구현** - Files: @@ -491,3 +493,4 @@ private fun LocalDateTime.toUtcIso(): String { - 2026-06-17 문서 갱신 검증: 라이브 탭 신규 구현을 `v2.api.creator.channel.live` 조립 계층과 `v2.creator.channel.live` 도메인 조회 계층으로 진행하도록 PRD/plan-task를 갱신했다. 기존 홈 API 구조 정렬은 Phase 6 후속 프롬프트로 분리했다. `git diff --check`로 whitespace 오류가 없음을 확인했고, `./gradlew tasks --all`로 Gradle 명령 유효성 확인에 성공했다. - 2026-06-17 Phase 2 검증: `CreatorChannelLiveReplayQueryPolicyTest`와 `CreatorChannelLiveQueryServiceTest`를 먼저 추가해 RED 단계에서 Phase 2 production 클래스 미존재 컴파일 실패를 확인했다. GREEN 단계에서 라이브 탭 domain/page 정책, port, service 조립을 추가하고 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`, `./gradlew ktlintCheck`, `git diff --check` 성공을 확인했다. Phase 3 port 구현 전 Spring context가 깨지지 않도록 service는 `ObjectProvider`로 port를 지연 조회하게 했고, `./gradlew test --tests kr.co.vividnext.sodalive.support.SpringBootIntegrationSampleTest` 성공으로 대표 context 기동을 확인했다. 추가 리뷰 반영으로 `page >= 0`, `20 <= size <= 50` 검증과 invalid 요청 시 port 조회 전 중단을 보강했고, 동일 targeted 테스트와 `ktlintCheck`, `git diff --check` 성공을 확인했다. 전체 `./gradlew test`는 실행 중 기존 SpringBoot 통합 테스트들의 bean/OOM 계열 실패와 timeout이 발생해 완료하지 못했으며, Phase 2 직접 변경 범위는 위 targeted/context 검증으로 확인했다. - 2026-06-17 Phase 3 검증: `DefaultCreatorChannelLiveQueryRepositoryTest`를 먼저 추가해 RED 단계에서 `DefaultCreatorChannelLiveQueryRepository` 미존재 컴파일 실패를 확인했다. GREEN 단계에서 live tab repository interface/default 구현체를 추가하고 count/list/pagination/sort/order state/current live policy를 구현했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest --tests kr.co.vividnext.sodalive.support.SpringBootIntegrationSampleTest`, `./gradlew ktlintCheck`, `git diff --check` 성공을 확인했다. 리뷰 게이트에서 Phase 3 persistence adapter 범위와 시나리오 검증에 대한 unconditional approval을 받았다. +- 2026-06-17 Phase 3 리뷰 보완 검증: `isFirstContent` 의미를 PRD에 명시하고, 라이브 다시듣기 repository 테스트에 다른 테마의 더 오래된 공개 오디오가 있을 때 `다시듣기` item의 `isFirstContent`가 `false`인지 확인하는 회귀 테스트를 추가했다. RED 단계에서 기존 구현이 실패하는 것을 확인했고, first content id 조회를 기존 홈 API와 같은 전체 공개 오디오 기준으로 수정했다. 검증은 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`로 수행했고 모두 성공했다. 검증 중 Gradle 테스트 2개를 병렬 실행했을 때 한 세션에서 QueryDSL generated source compile race로 `compileJava`가 실패했으나, 단일 세션으로 재실행한 repository 전체 테스트는 성공했다. diff --git a/docs/20260617_크리에이터_채널_라이브_API/prd.md b/docs/20260617_크리에이터_채널_라이브_API/prd.md index b03068a5..2ce2d81f 100644 --- a/docs/20260617_크리에이터_채널_라이브_API/prd.md +++ b/docs/20260617_크리에이터_채널_라이브_API/prd.md @@ -198,6 +198,7 @@ enum class ContentSort { - 목록 조회와 개수 조회는 성인 콘텐츠 노출 정책, 차단 정책, 공개 여부 필터가 서로 어긋나지 않아야 한다. - 조회자의 성인 콘텐츠 노출 정책이 false이면 성인 콘텐츠는 목록과 개수에서 제외한다. - `isFirstContent`, `seriesName`, `isOriginalSeries`, 구매/대여/포인트 사용 가능 여부의 의미는 기존 오디오 콘텐츠/시리즈 콘텐츠 응답과 동일하게 유지한다. +- `isFirstContent`는 `다시듣기` 테마 안에서 첫 콘텐츠인지가 아니라, 크리에이터의 전체 공개 오디오 콘텐츠 중 첫 콘텐츠인지로 판단한다. #### Edge Cases - 시리즈에 속하지 않은 콘텐츠는 `seriesName`, `isOriginalSeries`를 `null`로 내려준다. @@ -220,22 +221,22 @@ enum class ContentSort { - `PRICE_LOW`: 낮은 가격순 - `LATEST`는 공개일 최신순을 1차 정렬로 사용한다. - `LATEST`의 2차 정렬은 높은 가격순이다. -- `LATEST`의 3차 정렬은 랜덤이다. +- `LATEST`의 3차 정렬은 `audioContent.id desc`다. - `POPULAR`은 매출이 많은 콘텐츠를 먼저 노출한다. - `OWNED`는 조회자가 소장한 콘텐츠를 먼저 노출한다. - `PRICE_HIGH`는 가격이 높은 콘텐츠를 먼저 노출한다. - `PRICE_LOW`는 가격이 낮은 콘텐츠를 먼저 노출한다. - `POPULAR`, `OWNED`, `PRICE_HIGH`, `PRICE_LOW`의 2차 정렬은 최신순이다. -- `POPULAR`, `OWNED`, `PRICE_HIGH`, `PRICE_LOW`의 3차 정렬은 랜덤이다. +- `POPULAR`, `OWNED`, `PRICE_HIGH`, `PRICE_LOW`의 3차 정렬은 `audioContent.id desc`다. - 최신순 기준에 사용하는 날짜는 기존 `CreatorChannelAudioContentResponse` 목록 정책과 동일하게 공개 시각(`releaseDate`)을 기준으로 한다. - 인기순의 매출은 대여/소장 여부와 관계없이 해당 콘텐츠에 순수하게 결제된 캔 매출 합계(`orders.can`)를 기준으로 하며, 포인트 사용액은 매출 기준에 포함하지 않는다. - 환불되었거나 비활성 처리된 구매 내역은 기존 콘텐츠 구매/매출 정책과 동일하게 제외한다. - 소장순은 조회자가 해당 콘텐츠를 유효하게 소장 또는 구매한 상태를 기준으로 한다. -- 랜덤 정렬은 같은 1차/2차 정렬 값을 가진 항목 사이의 순서만 흔들 수 있다. +- 같은 1차/2차 정렬 값을 가진 항목은 `audioContent.id desc`로 안정적으로 정렬한다. #### Edge Cases - 매출이 없는 콘텐츠의 인기순 매출값은 0으로 처리한다. -- 조회자가 소장한 콘텐츠가 없으면 `OWNED` 정렬은 최신순 + 랜덤 보조 정렬과 같은 결과가 될 수 있다. +- 조회자가 소장한 콘텐츠가 없으면 `OWNED` 정렬은 최신순 + `audioContent.id desc` 보조 정렬과 같은 결과가 될 수 있다. - 가격이 같은 콘텐츠는 각 정렬의 2차/3차 기준을 따른다. --- diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt index f258a5d6..f4ae944e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt @@ -120,7 +120,7 @@ class DefaultCreatorChannelLiveQueryRepository( ): List { val rows = findLiveReplayAudioRows(creatorId, viewerId, now, canViewAdultContent, sort, offset, limit) val contentIds = rows.map { itAudioId(it) } - val firstContentId = firstLiveReplayAudioContentId(creatorId, now, canViewAdultContent) + val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent) val seriesByContentId = audioSeriesByContentIds(contentIds) val orderStatesByContentId = orderStatesByContentIds(viewerId, contentIds, now) @@ -269,7 +269,7 @@ class DefaultCreatorChannelLiveQueryRepository( ) } - private fun firstLiveReplayAudioContentId( + private fun firstAudioContentId( creatorId: Long, now: LocalDateTime, canViewAdultContent: Boolean @@ -277,8 +277,15 @@ class DefaultCreatorChannelLiveQueryRepository( return queryFactory .select(audioContent.id) .from(audioContent) - .innerJoin(audioContent.theme, audioContentTheme) - .where(liveReplayAudioCondition(creatorId, now, canViewAdultContent)) + .where( + audioContent.member.id.eq(creatorId), + audioContent.member.isActive.isTrue, + audioContent.isActive.isTrue, + audioContent.duration.isNotNull, + audioContent.releaseDate.isNotNull, + audioContent.releaseDate.loe(now), + adultAudioCondition(canViewAdultContent) + ) .orderBy(audioContent.releaseDate.asc(), audioContent.id.asc()) .fetchFirst() } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt index 7457b0f4..8b3b546c 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt @@ -108,6 +108,39 @@ class DefaultCreatorChannelLiveQueryRepositoryTest @Autowired constructor( assertTrue(secondPage.last().isFirstContent) } + @Test + @DisplayName("라이브 다시듣기의 isFirstContent는 테마가 아니라 전체 공개 오디오 콘텐츠 첫 항목을 기준으로 한다") + fun shouldMarkFirstContentByAllPublicAudioContentsNotLiveReplayTheme() { + val now = LocalDateTime.of(2026, 6, 17, 12, 0) + val creator = saveMember("first-content-creator", MemberRole.CREATOR) + saveAudioContent( + creator = creator, + releaseDate = now.minusDays(10), + isAdult = false, + theme = saveTheme("수면") + ) + val liveReplay = saveAudioContent( + creator = creator, + releaseDate = now.minusDays(1), + isAdult = false, + theme = saveTheme("다시듣기") + ) + flushAndClear() + + val records = repository.findLiveReplayAudioContents( + creatorId = creator.id!!, + viewerId = null, + now = now, + canViewAdultContent = false, + sort = ContentSort.LATEST, + offset = 0, + limit = 20 + ) + + assertEquals(listOf(liveReplay.id), records.map { it.audioContentId }) + assertEquals(listOf(false), records.map { it.isFirstContent }) + } + @Test @DisplayName("라이브 다시듣기 목록은 가격 정렬을 적용한다") fun shouldSortLiveReplayAudioContentsByPrice() {