Compare commits

..

3 Commits

7 changed files with 101 additions and 13 deletions

View File

@@ -26,7 +26,7 @@
- `paidAudioContentCount`: 적용된 필터 기준 `price > 0` 콘텐츠 개수
- `purchasedAudioContentCount`: 적용된 필터 기준 유료 콘텐츠 중 조회자가 유효하게 소장하거나 대여 중인 콘텐츠 개수
- `purchasedAudioContentRate`: `paidAudioContentCount == 0`이면 `0.0`, 아니면 `(purchasedAudioContentCount / paidAudioContentCount) * 100`
- `themes`: 활성 테마 전체 목록. 선택한 `themeId`와 무관하게 내려준다.
- `themes`: 활성 테마 중 해당 크리에이터의 조회 가능한 공개 오디오 콘텐츠가 1개 이상 있는 테마 목록. 선택한 `themeId`와 무관하게 내려준다.
- `audioContents`: 기존 `CreatorChannelAudioContentResponse`와 같은 필드/의미를 가진 item 목록
- `sort`: 실제 적용한 `ContentSort`
- `themeId`: 실제 적용한 활성 테마 id, 전체 조회 fallback이면 `null`
@@ -36,6 +36,7 @@
- 공개 콘텐츠 기준: `AudioContent.isActive == true`, `AudioContent.duration != null`, `AudioContent.releaseDate != null`, `AudioContent.releaseDate <= now`.
- 성인 콘텐츠는 조회자의 성인 콘텐츠 노출 정책이 false이면 목록과 count에서 제외한다.
- 테마명은 `LangContext.lang.code` 기준으로 `ContentThemeTranslation`을 우선하고, 없거나 빈 문자열이면 `AudioContentTheme.theme` 원문으로 fallback한다.
- 테마 목록 필터링은 콘텐츠 목록/count와 같은 공개 조건, 예약 공개 제외, 성인 콘텐츠 노출 정책을 적용한다.
- 시리즈명은 `LangContext.lang.code` 기준으로 `SeriesTranslation`을 우선하고, 없거나 빈 문자열이면 `Series.title` 원문으로 fallback한다.
- `isFirstContent`는 선택 테마 안의 첫 콘텐츠가 아니라 크리에이터의 전체 공개 오디오 콘텐츠 중 첫 콘텐츠인지로 판단한다.
- 정렬:
@@ -247,7 +248,12 @@ interface CreatorChannelAudioQueryPort {
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelAudioCreatorRecord?
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
fun findActiveThemeId(themeId: Long): Long?
fun findAudioThemes(locale: String): List<CreatorChannelAudioThemeRecord>
fun findAudioThemes(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean,
locale: String
): List<CreatorChannelAudioThemeRecord>
fun countAudioContents(creatorId: Long, themeId: Long?, now: LocalDateTime, canViewAdultContent: Boolean): Int
fun countPaidAudioContents(creatorId: Long, themeId: Long?, now: LocalDateTime, canViewAdultContent: Boolean): Int
fun countPurchasedAudioContents(
@@ -392,15 +398,24 @@ data class CreatorChannelAudioContentRecord(
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/CreatorChannelAudioQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt`
- RED: `@DataJpaTest(properties = ["spring.cache.type=none"])` 기반으로 `findCreator`, `existsBlockedBetween`, `findActiveThemeId`, `findAudioThemes(locale="en")` 테스트를 작성한다. `ContentThemeTranslation`이 있으면 번역명, 없으면 원문명을 반환해야 한다.
- RED: `@DataJpaTest(properties = ["spring.cache.type=none"])` 기반으로 `findCreator`, `existsBlockedBetween`, `findActiveThemeId`, `findAudioThemes(creatorId, now, canViewAdultContent, locale="en")` 테스트를 작성한다. `ContentThemeTranslation`이 있으면 번역명, 없으면 원문명을 반환해야 하며, 해당 크리에이터의 조회 가능한 공개 오디오 콘텐츠가 없는 테마는 제외해야 한다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest`
- Expected: repository 미존재 컴파일 실패
- GREEN: 라이브 탭 repository의 `findCreator`, `existsBlockedBetween`을 오디오 패키지로 필요한 만큼 복사하고, `findActiveThemeId`, `findAudioThemes`를 QueryDSL로 구현한다.
- GREEN: 라이브 탭 repository의 `findCreator`, `existsBlockedBetween`을 오디오 패키지로 필요한 만큼 복사하고, `findActiveThemeId`, `findAudioThemes`를 QueryDSL로 구현한다. `findAudioThemes``audioContentCondition(creatorId, themeId = null, now, canViewAdultContent)`를 공유해 콘텐츠 목록/count와 같은 공개 조건을 적용한다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: `ContentThemeTranslation.theme`이 blank인 경우 원문 fallback을 repository 또는 domain mapping 중 한 곳에서만 처리한다.
- 후속 수정 검증 기록:
- 무엇: 테마 목록에서 해당 크리에이터의 조회 가능한 공개 오디오 콘텐츠가 없는 테마를 제외하는 RED 테스트를 추가했다.
- 왜: 오디오 탭에서 선택 가능한 테마가 실제 콘텐츠가 없는 빈 필터로 노출되지 않아야 한다.
- 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest`를 실행했다.
- 결과: 현재 구현은 활성 테마 전체를 반환해 `DefaultCreatorChannelAudioQueryRepositoryTest.kt:71`, `DefaultCreatorChannelAudioQueryRepositoryTest.kt:96`에서 실패함을 확인했다.
- 무엇: `findAudioThemes``creatorId`, `now`, `canViewAdultContent`, `locale`를 받아 콘텐츠 목록/count와 같은 공개 조건으로 테마를 조회하도록 수정했다.
- 왜: 조회 가능한 아이템이 없는 테마와 성인 노출 정책상 볼 수 없는 테마를 응답에서 제외해야 한다.
- 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest`를 실행했다.
- 결과: `BUILD SUCCESSFUL`로 repository 필터링과 service 컨텍스트 전달을 확인했다.
- [x] **Task 3.2: 오디오 콘텐츠 count와 소장률 count 구현**
- Files:
@@ -605,3 +620,9 @@ data class CreatorChannelAudioContentRecord(
- 공백: `git diff --check` → 출력 없음.
- placeholder 확인: `rg -n "미완성 표시|후속 처리 표시" docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio` → 계획 문서의 검증 명령/기록 자체만 매칭되어 의도하지 않은 placeholder 없음.
- 코드 리뷰: controller/facade/service/repository/test 경로를 PRD 요구사항 기준으로 검토했고, 공개 조건, fallback, count, 정렬, 시리즈/테마 번역 fallback, 소장/대여 상태 관련 차단 이슈 없음.
- 2026-06-19: 후속 수정 완료.
- 요구사항: 오디오 탭 `themes` 응답에서 해당 크리에이터의 조회 가능한 공개 오디오 콘텐츠가 없는 테마를 제외한다.
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest` → 활성 테마 전체를 반환해 신규 assertion 실패 확인.
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest``BUILD SUCCESSFUL`.
- 문서 명령 확인: `./gradlew tasks --all``BUILD SUCCESSFUL`.
- 포맷: `./gradlew ktlintCheck``BUILD SUCCESSFUL`.

View File

@@ -177,10 +177,12 @@ enum class ContentSort {
- `ko``AudioContentTheme.theme` 원문을 기본으로 사용한다.
- `en`, `ja``ContentThemeTranslation.locale`에 해당하는 번역값이 있으면 해당 `theme`을 사용한다.
- 요청 언어의 번역값이 없으면 `AudioContentTheme.theme` 원문을 fallback으로 사용한다.
- 테마 목록은 선택한 `themeId`와 무관하게 활성 테마 전체를 내려준다.
- 테마 목록은 선택한 `themeId`와 무관하게 활성 테마 중 해당 크리에이터의 조회 가능한 공개 오디오 콘텐츠가 1개 이상 있는 테마만 내려준다.
#### Edge Cases
- 활성 테마가 없으면 `themes`는 빈 배열로 내려준다.
- 활성 테마가 있어도 해당 크리에이터의 조회 가능한 공개 오디오 콘텐츠가 없는 테마는 `themes`에서 제외한다.
- 조회자의 성인 콘텐츠 노출 정책이 false이고 특정 테마의 조회 가능한 콘텐츠가 성인 콘텐츠뿐이면 해당 테마는 `themes`에서 제외한다.
- 번역 데이터는 있지만 빈 문자열이면 원문 테마명을 fallback으로 사용한다.
### Feature D. 오디오 콘텐츠 목록과 개수

View File

@@ -69,17 +69,24 @@ class DefaultCreatorChannelAudioQueryRepository(
.fetchFirst()
}
override fun findAudioThemes(locale: String): List<CreatorChannelAudioThemeRecord> {
override fun findAudioThemes(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean,
locale: String
): List<CreatorChannelAudioThemeRecord> {
val themeTranslation = QContentThemeTranslation("audioThemeTranslation")
return queryFactory
.select(audioContentTheme.id, audioContentTheme.theme, themeTranslation.theme)
.from(audioContentTheme)
.select(audioContentTheme.id, audioContentTheme.theme, themeTranslation.theme, audioContentTheme.orders)
.distinct()
.from(audioContent)
.innerJoin(audioContent.theme, audioContentTheme)
.leftJoin(themeTranslation)
.on(
themeTranslation.contentThemeId.eq(audioContentTheme.id),
themeTranslation.locale.eq(locale)
)
.where(audioContentTheme.isActive.isTrue)
.where(audioContentCondition(creatorId, themeId = null, now, canViewAdultContent))
.orderBy(audioContentTheme.orders.asc(), audioContentTheme.id.asc())
.fetch()
.map {

View File

@@ -97,7 +97,12 @@ class CreatorChannelAudioQueryService(
paidAudioContentCount = paidAudioContentCount,
purchasedAudioContentCount = purchasedAudioContentCount,
purchasedAudioContentRate = queryPolicy.purchaseRate(paidAudioContentCount, purchasedAudioContentCount),
themes = queryPort.findAudioThemes(locale).map { it.toDomain() },
themes = queryPort.findAudioThemes(
creatorId = creatorId,
now = now,
canViewAdultContent = canViewAdultContent,
locale = locale
).map { it.toDomain() },
audioContents = queryPolicy.limitItems(fetchedContents, audioPage).map { it.toDomain() },
sort = resolvedSort,
themeId = resolvedThemeId,

View File

@@ -11,7 +11,12 @@ interface CreatorChannelAudioQueryPort {
fun findActiveThemeId(themeId: Long): Long?
fun findAudioThemes(locale: String): List<CreatorChannelAudioThemeRecord>
fun findAudioThemes(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean,
locale: String
): List<CreatorChannelAudioThemeRecord>
fun countAudioContents(
creatorId: Long,

View File

@@ -43,19 +43,25 @@ class DefaultCreatorChannelAudioQueryRepositoryTest @Autowired constructor(
@Test
@DisplayName("크리에이터, 차단 관계, 활성 테마, 테마 번역 fallback을 조회한다")
fun shouldFindCreatorBlockAndThemesWithTranslationFallback() {
val now = LocalDateTime.of(2026, 6, 19, 12, 0)
val viewer = saveMember("audio-viewer", MemberRole.USER)
val creator = saveMember("audio-creator", MemberRole.CREATOR)
val translatedTheme = saveTheme("수면", orders = 2)
val blankTranslatedTheme = saveTheme("집중", orders = 1)
val emptyTheme = saveTheme("빈테마", orders = 3)
val inactiveTheme = saveTheme("비활성", isActive = false)
saveThemeTranslation(translatedTheme, "en", "Sleep")
saveThemeTranslation(blankTranslatedTheme, "en", " ")
saveThemeTranslation(emptyTheme, "en", "Empty")
saveThemeTranslation(inactiveTheme, "en", "Inactive")
saveAudioContent(creator, now.minusDays(1), false, translatedTheme)
saveAudioContent(creator, now.minusDays(2), false, blankTranslatedTheme)
saveAudioContent(creator, now.minusDays(3), false, inactiveTheme)
saveBlock(creator, viewer)
flushAndClear()
val record = repository.findCreator(creator.id!!, viewer.id!!)
val themes = repository.findAudioThemes("en")
val themes = repository.findAudioThemes(creator.id!!, now, canViewAdultContent = false, "en")
assertEquals(creator.id, record!!.creatorId)
assertEquals(MemberRole.CREATOR, record.role)
@@ -66,6 +72,31 @@ class DefaultCreatorChannelAudioQueryRepositoryTest @Autowired constructor(
assertEquals(listOf("집중", "Sleep"), themes.map { it.themeName })
}
@Test
@DisplayName("테마 목록은 크리에이터의 조회 가능한 공개 오디오 콘텐츠가 있는 테마만 반환한다")
fun shouldFindAudioThemesOnlyWhenCreatorHasVisibleAudioContent() {
val now = LocalDateTime.of(2026, 6, 19, 12, 0)
val creator = saveMember("theme-filter-creator", MemberRole.CREATOR)
val otherCreator = saveMember("theme-filter-other-creator", MemberRole.CREATOR)
val publicTheme = saveTheme("공개", orders = 1)
val adultOnlyTheme = saveTheme("성인전용", orders = 2)
val otherCreatorTheme = saveTheme("다른크리에이터", orders = 3)
val futureOnlyTheme = saveTheme("예약전용", orders = 4)
val durationMissingTheme = saveTheme("길이없음", orders = 5)
saveAudioContent(creator, now.minusDays(1), false, publicTheme)
saveAudioContent(creator, now.minusDays(1), true, adultOnlyTheme)
saveAudioContent(otherCreator, now.minusDays(1), false, otherCreatorTheme)
saveAudioContent(creator, now.plusDays(1), false, futureOnlyTheme)
saveAudioContent(creator, now.minusDays(1), false, durationMissingTheme).duration = null
flushAndClear()
val nonAdultThemes = repository.findAudioThemes(creator.id!!, now, canViewAdultContent = false, "ko")
val adultVisibleThemes = repository.findAudioThemes(creator.id!!, now, canViewAdultContent = true, "ko")
assertEquals(listOf(publicTheme.id), nonAdultThemes.map { it.themeId })
assertEquals(listOf(publicTheme.id, adultOnlyTheme.id), adultVisibleThemes.map { it.themeId })
}
@Test
@DisplayName("오디오 콘텐츠 count는 공개 조건, 성인 노출 정책, 활성 themeId 필터를 공유한다")
fun shouldCountPublicAudioContentsWithFilters() {

View File

@@ -60,6 +60,10 @@ class CreatorChannelAudioQueryServiceTest {
assertNull(port.listThemeId)
assertEquals("en", port.listLocale)
assertEquals(false, port.listCanViewAdultContent)
assertEquals(1L, port.themeCreatorId)
assertEquals(now, port.themeNow)
assertEquals(false, port.themeCanViewAdultContent)
assertEquals("en", port.themeLocale)
assertEquals(75.0, tab.purchasedAudioContentRate)
assertEquals(50, tab.audioContents.size)
assertTrue(tab.hasNext)
@@ -191,6 +195,10 @@ private class FakeCreatorChannelAudioQueryPort : CreatorChannelAudioQueryPort {
var listOffset: Long? = null
var listLimit: Int? = null
var listCanViewAdultContent: Boolean? = null
var themeCreatorId: Long? = null
var themeNow: LocalDateTime? = null
var themeCanViewAdultContent: Boolean? = null
var themeLocale: String? = null
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelAudioCreatorRecord? = creator
@@ -198,7 +206,16 @@ private class FakeCreatorChannelAudioQueryPort : CreatorChannelAudioQueryPort {
override fun findActiveThemeId(themeId: Long): Long? = activeThemeId
override fun findAudioThemes(locale: String): List<CreatorChannelAudioThemeRecord> {
override fun findAudioThemes(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean,
locale: String
): List<CreatorChannelAudioThemeRecord> {
themeCreatorId = creatorId
themeNow = now
themeCanViewAdultContent = canViewAdultContent
themeLocale = locale
return listOf(CreatorChannelAudioThemeRecord(themeId = 10L, themeName = locale))
}