fix(creator): 라이브 다시듣기 첫 콘텐츠 기준을 보정한다
This commit is contained in:
@@ -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<CreatorChannelLiveQueryPort>`로 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 전체 테스트는 성공했다.
|
||||
|
||||
@@ -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차 기준을 따른다.
|
||||
|
||||
---
|
||||
|
||||
@@ -120,7 +120,7 @@ class DefaultCreatorChannelLiveQueryRepository(
|
||||
): List<CreatorChannelAudioContentRecord> {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user