Compare commits

..

17 Commits

Author SHA1 Message Date
e5006d6334 docs(creator-channel): 오디오 탭 Phase 5 기록을 갱신한다 2026-06-19 20:45:05 +09:00
ababd9a962 docs(creator-channel): 오디오 탭 Phase 4 기록을 갱신한다 2026-06-19 19:05:52 +09:00
357d207fcc feat(creator-channel): 오디오 탭 controller를 추가한다 2026-06-19 19:05:41 +09:00
405bb12713 docs(creator-channel): 오디오 탭 Phase 3 기록을 갱신한다 2026-06-19 18:07:25 +09:00
76cc6e6557 feat(creator-channel): 오디오 탭 repository를 추가한다 2026-06-19 18:07:11 +09:00
cffd50c33f refactor(ranking): CDN URL 변환 공통 함수를 사용한다 2026-06-19 16:32:48 +09:00
98241e16b0 refactor(creator-channel): CDN URL 변환 공통 함수를 사용한다 2026-06-19 16:32:24 +09:00
d1fb87556e refactor(cdn): CDN URL 변환 함수를 공통화한다 2026-06-19 16:32:16 +09:00
63c28f8504 docs(cdn): CDN URL 공통화 계획을 기록한다 2026-06-19 16:32:05 +09:00
4ba0116f55 docs(creator-channel): 오디오 탭 Phase 2 기록을 갱신한다 2026-06-19 16:07:28 +09:00
c71f1ed17c feat(creator-channel): 오디오 탭 응답 변환을 추가한다 2026-06-19 16:06:56 +09:00
4fdb9bcb26 feat(creator-channel): 오디오 탭 조회 서비스를 추가한다 2026-06-19 16:06:45 +09:00
80a06ad63d docs(creator-channel): 오디오 탭 Phase 1 기록을 갱신한다 2026-06-19 15:19:31 +09:00
f743d090c3 refactor(creator-channel): 오디오 콘텐츠 응답을 공통화한다 2026-06-19 15:18:48 +09:00
9a1bfed6a4 feat(creator-channel): 오디오 탭 조회 계약을 추가한다 2026-06-19 15:17:18 +09:00
f3a574a54a feat(creator-channel): 오디오 탭 조회 정책을 추가한다 2026-06-19 15:16:36 +09:00
c6b6c16e12 docs(creator-channel): 오디오 탭 API 계획을 기록한다 2026-06-19 14:02:42 +09:00
42 changed files with 3091 additions and 198 deletions

View File

@@ -0,0 +1,41 @@
# CDN URL 변환 공통화 구현 계획
### Phase 1: 공통 함수 동작 고정
- [x] **Task 1.1: 공통 CDN URL 변환 테스트 작성**
- 파일: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensionsTest.kt`
- RED: `null`, blank, 절대 URL, 상대 path 입력의 기대 동작을 검증하는 실패 테스트를 작성하고 실패를 확인한다.
- GREEN: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`에 최소 구현을 추가하고 통과를 확인한다.
- REFACTOR: 함수명/패키지/import를 정리하고 단일 테스트를 다시 실행한다.
- 검증 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CdnUrlExtensionsTest`
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CdnUrlExtensionsTest` 실행 결과,
`Unresolved reference: toCdnUrl`로 실패해 공통 함수 미구현 상태를 확인했다.
- GREEN: 같은 명령 재실행 결과 `BUILD SUCCESSFUL`로 통과했다.
### Phase 2: 서비스 중복 함수 제거
- [x] **Task 2.1: 4개 서비스가 공통 함수를 사용하도록 변경**
- 파일:
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
- RED: Task 1.1 테스트로 절대 URL 유지 동작을 먼저 고정한다.
- GREEN: private `toCdnUrl` 중복 선언을 제거하고 공통 함수를 import해 사용한다.
- REFACTOR: 변경 파일의 불필요한 import/중복 코드를 제거하고 회귀 테스트를 실행한다.
- 검증 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CdnUrlExtensionsTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest`
- 검증 기록:
- `rg "fun String\\?\\.toCdnUrl|toCdnUrl\\(\\)" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2 -n`
실행 결과, 공통 함수 선언 1곳만 남은 것을 확인했다.
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest` 실행 결과
`BUILD SUCCESSFUL`로 ranking 서비스 회귀 테스트가 통과했다.
## 검증 기록
- `./gradlew ktlintCheck` 첫 실행은 private 함수 제거 후 남은 클래스 종료 전 빈 줄로 실패했다.
- 지적된 `CreatorChannelAudioQueryService.kt`, `CreatorChannelLiveQueryService.kt`의 빈 줄만 제거한 뒤
`./gradlew ktlintCheck`를 재실행했고 `BUILD SUCCESSFUL`로 통과했다.
- 문서 변경 규칙 확인을 위해 `./gradlew tasks --all`을 실행했고 `BUILD SUCCESSFUL`로 통과했다.
- 최종 관련 테스트로
`./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CdnUrlExtensionsTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest`
를 실행했고 `BUILD SUCCESSFUL`로 통과했다.

View File

@@ -0,0 +1,36 @@
# PRD: CDN URL 변환 공통화
## 1. Overview
v2 서비스에서 중복 선언된 `String?.toCdnUrl()` 확장 함수를 공통 유틸로 분리한다.
## 2. Problem
- `CreatorChannelHomeQueryService`, `CreatorChannelLiveQueryService`, `CreatorChannelAudioQueryService`,
`CreatorRankingQueryService`에 유사한 CDN URL 변환 로직이 private 함수로 중복되어 있다.
- ranking 구현은 절대 URL을 그대로 유지하지 않아 다른 3곳과 동작이 다르다.
## 3. Goals
- 4개 서비스가 하나의 공통 `toCdnUrl` 함수를 사용한다.
- `null` 또는 blank 입력은 `null`을 반환한다.
- `http://`, `https://` 절대 URL은 그대로 반환한다.
- 상대 path는 `cloudFrontHost/path` 형식으로 반환한다.
## 4. Non-Goals
- QueryDSL 조회 로직이나 공개 API 스키마는 변경하지 않는다.
- 기존 CDN host 설정 방식은 변경하지 않는다.
- 다른 레거시 CDN URL 조합 코드는 이번 범위에서 정리하지 않는다.
## 5. Core Features
### Feature A: 공통 CDN URL 변환
#### Requirements
- `kr.co.vividnext.sodalive.v2` 하위 공통 패키지에 재사용 가능한 함수를 둔다.
- 기존 서비스 매핑 흐름은 유지하고 private 중복 함수만 제거한다.
#### Edge Cases
- `null`, `""`, `" "` 입력은 `null`이어야 한다.
- `https://...`, `http://...` 입력은 host를 덧붙이지 않아야 한다.
- `"profile/a.png"` 입력은 `"https://cdn.test/profile/a.png"`가 되어야 한다.
## 6. Technical Constraints
- Kotlin 확장 함수로 구현한다.
- 테스트는 JUnit 5로 작성한다.

View File

@@ -0,0 +1,607 @@
# 크리에이터 채널 오디오 탭 API Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/audio`로 크리에이터 채널 오디오 탭의 테마 목록, 콘텐츠 개수, 소장률, 오디오 콘텐츠 목록을 조회할 수 있게 한다.
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.audio` 조립 계층에 둔다. 오디오 탭 조회 service, 순수 fallback/page 정책, tab domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.audio` 하위에 두고 `v2.api.*`에 의존하지 않는다. 크리에이터 채널 오디오 콘텐츠 item domain/response는 홈/라이브/오디오 탭에서 동일하게 쓰도록 채널 공통 패키지에 둔다. 라이브 탭에서 만든 `ContentSort`와 오디오 콘텐츠 응답 의미는 재사용하되, `sort/page/size/themeId` fallback 정책은 오디오 탭 전용 정책으로 명시한다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
---
## 0. 구현 전 확정 사항
- API endpoint: `GET /api/v2/creator-channels/{creatorId}/audio`
- 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다.
- request:
- path variable: `creatorId`
- query parameter: `sort`, `required = false`, 기본값/fallback `LATEST`
- query parameter: `themeId`, `required = false`, 없거나 비활성/미존재이면 전체 활성 테마 조회
- query parameter: `page`, `required = false`, 기본값 `0`, `page < 0`이면 `0`으로 fallback
- query parameter: `size`, `required = false`, 기본값 `20`, `size < 20`이면 `20`, `size > 50`이면 `50`으로 fallback
- controller는 invalid `sort` fallback을 위해 `sort: String?`으로 받고 service/facade 경계에서 `ContentSort`로 보정한다.
- response:
- `audioContentCount`: 적용된 필터 기준 오디오 콘텐츠 전체 개수
- `paidAudioContentCount`: 적용된 필터 기준 `price > 0` 콘텐츠 개수
- `purchasedAudioContentCount`: 적용된 필터 기준 유료 콘텐츠 중 조회자가 유효하게 소장하거나 대여 중인 콘텐츠 개수
- `purchasedAudioContentRate`: `paidAudioContentCount == 0`이면 `0.0`, 아니면 `(purchasedAudioContentCount / paidAudioContentCount) * 100`
- `themes`: 활성 테마 전체 목록. 선택한 `themeId`와 무관하게 내려준다.
- `audioContents`: 기존 `CreatorChannelAudioContentResponse`와 같은 필드/의미를 가진 item 목록
- `sort`: 실제 적용한 `ContentSort`
- `themeId`: 실제 적용한 활성 테마 id, 전체 조회 fallback이면 `null`
- `page`: fallback 보정 후 실제 적용된 page index
- `size`: fallback 보정 후 실제 적용된 page size
- `hasNext`: 다음 page 존재 여부
- 공개 콘텐츠 기준: `AudioContent.isActive == true`, `AudioContent.duration != null`, `AudioContent.releaseDate != null`, `AudioContent.releaseDate <= now`.
- 성인 콘텐츠는 조회자의 성인 콘텐츠 노출 정책이 false이면 목록과 count에서 제외한다.
- 테마명은 `LangContext.lang.code` 기준으로 `ContentThemeTranslation`을 우선하고, 없거나 빈 문자열이면 `AudioContentTheme.theme` 원문으로 fallback한다.
- 시리즈명은 `LangContext.lang.code` 기준으로 `SeriesTranslation`을 우선하고, 없거나 빈 문자열이면 `Series.title` 원문으로 fallback한다.
- `isFirstContent`는 선택 테마 안의 첫 콘텐츠가 아니라 크리에이터의 전체 공개 오디오 콘텐츠 중 첫 콘텐츠인지로 판단한다.
- 정렬:
- `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`인 주문만 포함한다.
---
## 1. 파일 구조 계획
### 오디오 탭 신규 API 조립 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/application/CreatorChannelAudioFacade.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/dto/CreatorChannelAudioTabResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/application/CreatorChannelAudioFacadeTest.kt`
### 오디오 탭 도메인 조회 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioTab.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioQueryPolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/port/out/CreatorChannelAudioQueryPort.kt`
- 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/domain/CreatorChannelAudioQueryPolicyTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt`
### 크리에이터 채널 공통 오디오 콘텐츠 item
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/common/domain/CreatorChannelAudioContent.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelLiveTab.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/dto/CreatorChannelLiveTabResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt`
### 기존 파일 확인/재사용
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSort.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/dto/CreatorChannelLiveTabResponse.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/content/theme/translation/ContentThemeTranslationRepository.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt`
### 문서 산출물
- Create: `docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md`
- Verify: `docs/20260619_크리에이터_채널_오디오_탭_API/prd.md`
---
## 2. Response data class 초안
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/dto/CreatorChannelAudioTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
```kotlin
package kr.co.vividnext.sodalive.v2.api.creator.channel.audio.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTab
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTheme
data class CreatorChannelAudioTabResponse(
val audioContentCount: Int,
val paidAudioContentCount: Int,
val purchasedAudioContentCount: Int,
val purchasedAudioContentRate: Double,
val themes: List<CreatorChannelAudioThemeResponse>,
val audioContents: List<CreatorChannelAudioContentResponse>,
val sort: ContentSort,
val themeId: Long?,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(tab: CreatorChannelAudioTab): CreatorChannelAudioTabResponse {
return CreatorChannelAudioTabResponse(
audioContentCount = tab.audioContentCount,
paidAudioContentCount = tab.paidAudioContentCount,
purchasedAudioContentCount = tab.purchasedAudioContentCount,
purchasedAudioContentRate = tab.purchasedAudioContentRate,
themes = tab.themes.map(CreatorChannelAudioThemeResponse::from),
audioContents = tab.audioContents.map(CreatorChannelAudioContentResponse::from),
sort = tab.sort,
themeId = tab.themeId,
page = tab.page.page,
size = tab.page.size,
hasNext = tab.hasNext
)
}
}
}
data class CreatorChannelAudioThemeResponse(
val themeId: Long,
val themeName: String
) {
companion object {
fun from(theme: CreatorChannelAudioTheme): CreatorChannelAudioThemeResponse {
return CreatorChannelAudioThemeResponse(
themeId = theme.themeId,
themeName = theme.themeName
)
}
}
}
data class CreatorChannelAudioContentResponse(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
val seriesName: String?,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean?,
@JsonProperty("isOwned")
val isOwned: Boolean,
@JsonProperty("isRented")
val isRented: Boolean
) {
companion object {
fun from(content: CreatorChannelAudioContent): CreatorChannelAudioContentResponse {
return CreatorChannelAudioContentResponse(
audioContentId = content.audioContentId,
title = content.title,
duration = content.duration,
imageUrl = content.imageUrl,
price = content.price,
isAdult = content.isAdult,
isPointAvailable = content.isPointAvailable,
isFirstContent = content.isFirstContent,
seriesName = content.seriesName,
isOriginalSeries = content.isOriginalSeries,
isOwned = content.isOwned,
isRented = content.isRented
)
}
}
}
```
---
## 3. Domain / Port 초안
구현 시 아래 형태를 기준으로 추가한다. API DTO가 domain model을 참조하지만 domain/port는 API DTO를 참조하지 않는다.
```kotlin
package kr.co.vividnext.sodalive.v2.creator.channel.audio.domain
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
data class CreatorChannelAudioTab(
val audioContentCount: Int,
val paidAudioContentCount: Int,
val purchasedAudioContentCount: Int,
val purchasedAudioContentRate: Double,
val themes: List<CreatorChannelAudioTheme>,
val audioContents: List<CreatorChannelAudioContent>,
val sort: ContentSort,
val themeId: Long?,
val page: CreatorChannelPage,
val hasNext: Boolean
)
data class CreatorChannelAudioTheme(
val themeId: Long,
val themeName: String
)
data class CreatorChannelAudioContent(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
val isAdult: Boolean,
val isPointAvailable: Boolean,
val isFirstContent: Boolean,
val seriesName: String?,
val isOriginalSeries: Boolean?,
val isOwned: Boolean,
val isRented: Boolean
)
```
```kotlin
package kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import java.time.LocalDateTime
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 countAudioContents(creatorId: Long, themeId: Long?, now: LocalDateTime, canViewAdultContent: Boolean): Int
fun countPaidAudioContents(creatorId: Long, themeId: Long?, now: LocalDateTime, canViewAdultContent: Boolean): Int
fun countPurchasedAudioContents(
creatorId: Long,
viewerId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int
fun findAudioContents(
creatorId: Long,
viewerId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean,
sort: ContentSort,
locale: String,
offset: Long,
limit: Int
): List<CreatorChannelAudioContentRecord>
}
data class CreatorChannelAudioCreatorRecord(
val creatorId: Long,
val role: MemberRole,
val nickname: String
)
data class CreatorChannelAudioThemeRecord(
val themeId: Long,
val themeName: String
)
data class CreatorChannelAudioContentRecord(
val audioContentId: Long,
val title: String,
val duration: String?,
val imagePath: String?,
val price: Int,
val isAdult: Boolean,
val isPointAvailable: Boolean,
val isFirstContent: Boolean,
val seriesName: String?,
val isOriginalSeries: Boolean?,
val isOwned: Boolean,
val isRented: Boolean
)
```
---
### Phase 1: 오디오 탭 정책과 domain 계약
- [x] **Task 1.1: `CreatorChannelAudioQueryPolicy` fallback 정책 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioQueryPolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioQueryPolicyTest.kt`
- RED: `createPage(-1, 10)``page=0`, `size=20`을 반환하고, `createPage(2, 100)``page=2`, `size=50`을 반환하며, `resolveSort(null)``resolveSort("UNKNOWN")``ContentSort.LATEST`를 반환하는 테스트를 작성한다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest`
- Expected: `CreatorChannelAudioQueryPolicy` 미존재 컴파일 실패
- GREEN: `resolveSort(sort: String?): ContentSort`, `createPage(page: Int?, size: Int?): CreatorChannelPage`, `limitItems`, `hasNext`, `purchaseRate`를 구현한다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: 라이브 탭의 `CreatorChannelLiveReplayQueryPolicy`는 변경하지 않는다. 오디오 탭만 fallback 정책을 가진다.
- 회귀 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicyTest`
- Expected: 기존 라이브 탭 정책 테스트가 있으면 통과한다. 테스트가 없으면 `No tests found`가 아닌 컴파일 실패가 없는지 확인한다.
- [x] **Task 1.2: 오디오 탭 domain model과 port 계약 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioTab.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/port/out/CreatorChannelAudioQueryPort.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryServiceTest.kt`
- RED: service 테스트 파일에 `CreatorChannelAudioTab`, `CreatorChannelAudioTheme`, `CreatorChannelAudioContent`, `CreatorChannelAudioQueryPort` import를 추가하고 아직 service가 없어서 컴파일 실패하는 상태를 만든다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest`
- Expected: `CreatorChannelAudioQueryService` 또는 domain/port 미존재 컴파일 실패
- GREEN: 위 "Domain / Port 초안"의 타입을 추가한다. `CreatorChannelPage`는 기존 `kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage`를 재사용한다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: `rg -n "v2\\.api\\.creator\\.channel\\.audio" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio`로 domain/port가 API 조립 계층에 의존하지 않는지 확인한다.
- [x] **Task 1.3: 크리에이터 채널 오디오 콘텐츠 item 공통화**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/common/domain/CreatorChannelAudioContent.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponse.kt`
- Modify: live/home/audio domain과 DTO import
- RED: live/home/audio 테스트 import를 공통 `CreatorChannelAudioContent``CreatorChannelAudioContentResponse` 기준으로 변경하고 기존 타입 미존재/필드 불일치 컴파일 실패를 확인한다.
- GREEN: 기존 live/home/audio의 중복 `CreatorChannelAudioContent`와 중복 Response를 제거하고 공통 타입을 사용한다. 실질 사용처가 없는 `publishedAt`은 공통 domain과 live/home mapping에서 제거한다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest`
- REFACTOR: `rg -n "data class CreatorChannelAudioContent|data class CreatorChannelAudioContentResponse|publishedAt =|v2\\.creator\\.channel\\.(live|home|audio)\\.domain\\.CreatorChannelAudioContent|v2\\.api\\.creator\\.channel\\.(live|home)\\.dto\\.CreatorChannelAudioContentResponse" src/main/kotlin src/test/kotlin`로 중복 타입, 기존 패키지 import, 불필요한 domain field mapping이 남지 않았는지 확인한다.
### Phase 2: 오디오 탭 service와 API DTO 변환
- [x] **Task 2.1: `CreatorChannelAudioQueryService` orchestration 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryServiceTest.kt`
- RED: fake port 기반 service 테스트를 작성한다.
- `getAudioTab(creatorId=1, viewer, sort="UNKNOWN", themeId=999, page=-1, size=100)` 호출 시 실제 `sort=LATEST`, `themeId=null`, `page=0`, `size=50`, `offset=0`, `limit=51`이 port에 전달되어야 한다.
- `paidAudioContentCount=4`, `purchasedAudioContentCount=3`이면 `purchasedAudioContentRate=75.0`이어야 한다.
- `paidAudioContentCount=0`이면 `purchasedAudioContentRate=0.0`이어야 한다.
- `creator`가 없으면 `member.validation.user_not_found`, role이 `CREATOR`가 아니면 `member.validation.creator_not_found`, 차단 관계면 기존 차단 메시지 예외를 던져야 한다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest`
- Expected: `CreatorChannelAudioQueryService` 미존재 컴파일 실패
- GREEN: 라이브 탭 service의 인증/차단/성인 콘텐츠 정책을 참고해 최소 구현한다. `LangContext.lang.code`를 theme/series 번역 조회 locale로 전달하고, `String?.toCdnUrl()`은 라이브 탭 service와 같은 규칙으로 구현한다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: service가 QueryDSL/Q타입을 직접 import하지 않는지 확인한다.
- Run: `rg -n "Q[A-Z]|queryFactory|javax\\.persistence" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application`
- Expected: 검색 결과 없음
- [x] **Task 2.2: 오디오 탭 API response DTO와 facade 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/dto/CreatorChannelAudioTabResponse.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/application/CreatorChannelAudioFacade.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/application/CreatorChannelAudioFacadeTest.kt`
- RED: facade가 service 결과를 `CreatorChannelAudioTabResponse`로 변환하고 `isOwned`, `isRented`, `hasNext`의 JSON property 의미를 보존하는 테스트를 작성한다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest`
- Expected: facade/DTO 미존재 컴파일 실패
- GREEN: 위 "Response data class 초안"에 맞춰 DTO를 추가하고 facade에서 `CreatorChannelAudioQueryService.getAudioTab(creatorId, viewer, sort, themeId, page, size, now)` 결과를 `CreatorChannelAudioTabResponse.from(tab)`으로 변환한다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: 기존 라이브 탭 DTO를 이동하거나 수정하지 않는다.
- Run: `rg -n "package kr\\.co\\.vividnext\\.sodalive\\.v2\\.api\\.creator\\.channel\\.live\\.dto" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/dto/CreatorChannelLiveTabResponse.kt`
- Expected: 기존 라이브 탭 DTO package 유지
### Phase 3: QueryDSL repository 구현
- [x] **Task 3.1: repository skeleton과 creator/block/theme 조회 추가**
- Files:
- 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`이 있으면 번역명, 없으면 원문명을 반환해야 한다.
- 실패 확인:
- 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로 구현한다.
- 통과 확인:
- 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 중 한 곳에서만 처리한다.
- [x] **Task 3.2: 오디오 콘텐츠 count와 소장률 count 구현**
- Files:
- Modify: `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: 아래 조건을 검증하는 repository 테스트를 추가한다.
- `countAudioContents`는 공개/활성/예약 공개/성인 콘텐츠 정책과 활성 `themeId` 필터를 적용한다.
- `countPaidAudioContents`는 같은 필터에서 `price > 0`만 계산한다.
- `countPurchasedAudioContents`는 유료 콘텐츠 중 `OrderType.KEEP` 또는 유효한 `OrderType.RENTAL` 주문을 가진 콘텐츠만 계산한다.
- 무료 콘텐츠는 구매 count와 소장률 count에서 제외한다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest`
- Expected: 신규 count method 미구현 실패
- GREEN: 공통 `audioContentCondition(creatorId, themeId, now, canViewAdultContent)` private helper를 만들고 count query들이 같은 조건을 공유하게 구현한다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: 목록 query와 count query의 조건이 어긋나지 않도록 helper 사용 여부를 확인한다.
- Run: `rg -n "audioContentCondition|countAudioContents|countPaidAudioContents|countPurchasedAudioContents" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt`
- Expected: 세 count method가 공통 조건 helper를 사용한다.
- [x] **Task 3.3: 오디오 콘텐츠 목록, 정렬, 시리즈 번역, 소장/대여 상태 구현**
- Files:
- Modify: `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: 아래 조건을 검증하는 repository 테스트를 추가한다.
- `findAudioContents``size + 1`개 조회가 가능하도록 전달받은 `limit`을 그대로 사용한다.
- `LATEST`, `PRICE_HIGH`, `PRICE_LOW` 정렬이 PRD 기준으로 동작한다.
- `POPULAR``orders.can` 합계 기준으로 정렬하고 비활성 주문은 제외한다.
- `OWNED`는 소장 또는 유효 대여 중인 콘텐츠를 먼저 노출한다.
- 시리즈에 속한 콘텐츠는 `SeriesTranslation(locale)`이 있으면 번역명을, 없으면 원문명을 `seriesName`으로 반환한다.
- `isFirstContent`는 테마 필터와 무관하게 크리에이터의 전체 공개 오디오 콘텐츠 중 첫 콘텐츠 기준이다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest`
- Expected: 목록/정렬 method 미구현 실패
- GREEN: 라이브 탭 repository의 `findLiveReplayAudioRows`, `audioSeriesByContentIds`, `orderStatesByContentIds`, `firstAudioContentId` 구조를 오디오 탭 범위에 맞춰 구현한다. `themeId == null`이면 전체 활성 테마를 대상으로 한다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: QueryDSL 중복이 커지면 오디오 탭 repository 내부 private helper로만 정리하고, 라이브 탭 repository까지 건드리는 공용화는 이번 범위에서 하지 않는다.
### Phase 4: Controller와 공개 API 계약
- [x] **Task 4.1: `CreatorChannelAudioController` 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioController.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioControllerTest.kt`
- RED: MockMvc 테스트를 작성한다.
- 비회원 `GET /api/v2/creator-channels/1/audio`는 401을 반환한다.
- 인증 회원 기본 요청은 facade에 `sort=null`, `themeId=null`, `page=null`, `size=null`을 전달하고 성공 응답을 반환한다.
- `sort=INVALID&page=-1&size=100&themeId=999` 요청은 controller에서 400을 내지 않고 facade까지 원 요청값을 전달한다.
- 응답 JSON에는 `audioContentCount`, `paidAudioContentCount`, `purchasedAudioContentCount`, `purchasedAudioContentRate`, `themes`, `audioContents`, `sort`, `themeId`, `page`, `size`, `hasNext`, `audioContents[0].isOwned`, `audioContents[0].isRented`가 있어야 한다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioControllerTest`
- Expected: controller 미존재 컴파일 실패
- GREEN: `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/audio")` controller를 추가한다. query parameter는 `@RequestParam(required = false) sort: String?`, `themeId: Long?`, `page: Int?`, `size: Int?`로 받는다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioControllerTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: 기존 `/live`, `/home` mapping과 충돌하지 않는지 확인한다.
- Run: `rg -n "@GetMapping\\(\"/\\{creatorId\\}/(home|live|audio)\"\\)" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel`
- Expected: home/live/audio 각각 1건
- [x] **Task 4.2: 오디오 탭 통합 흐름 테스트 추가**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioEndToEndTest.kt`
- RED: `@SpringBootTest + MockMvc` 기반으로 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/audio?sort=INVALID&page=-1&size=100&themeId=999`를 호출했을 때 200 성공과 fallback 적용 응답(`sort=LATEST`, `themeId=null`, `page=0`, `size=50`)을 받는 테스트를 작성한다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioEndToEndTest`
- Expected: endpoint 또는 fixture 미구현으로 실패
- GREEN: 필요한 최소 fixture만 추가하고 controller, facade, service, repository wiring이 동작하도록 구현을 보완한다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioEndToEndTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: E2E fixture가 기존 테스트 데이터를 과도하게 공유하지 않는지 확인하고, 불필요한 데이터 생성 helper는 추가하지 않는다.
### Phase 5: 회귀 검증과 문서 기록
- [x] **Task 5.1: 관련 단위/슬라이스 테스트 회귀 실행**
- Files:
- Verify: `docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md`
- TDD 예외 사유: 코드 구현이 아니라 구현 완료 후 검증 기록을 누적하는 문서 작업이다.
- 대체 검증 방법:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioControllerTest`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest`
- Expected: 모두 `BUILD SUCCESSFUL`
- 검증 기록: 구현 완료 후 실행 명령, 결과, 실패 시 원인과 수정 내역을 이 task 아래에 누적한다.
- 2026-06-19 실행: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest``BUILD SUCCESSFUL`.
- [x] **Task 5.2: 전체 회귀와 포맷 검증**
- Files:
- Verify: `docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md`
- TDD 예외 사유: 전체 회귀/포맷 검증 기록 task다.
- 대체 검증 방법:
- Run: `./gradlew test`
- Run: `./gradlew ktlintCheck`
- Run: `git diff --check`
- Run: `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`
- Expected: Gradle 명령은 `BUILD SUCCESSFUL`, `git diff --check`는 출력 없음, placeholder 검색은 의도하지 않은 결과 없음
- 검증 기록: 구현 완료 후 전체 검증 결과를 이 task 아래와 문서 하단의 검증 기록에 누적한다.
- 2026-06-19 실행: `./gradlew --no-daemon --max-workers=1 -Dkotlin.compiler.execution.strategy=in-process test``BUILD SUCCESSFUL`.
- 2026-06-19 실행: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process ktlintCheck``BUILD SUCCESSFUL`.
- 2026-06-19 실행: `git diff --check` → 출력 없음.
- 2026-06-19 실행: `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 없음.
- 2026-06-19 코드 리뷰: controller/facade/service/repository/test 경로를 PRD 요구사항 기준으로 검토했고, 공개 조건, fallback, count, 정렬, 시리즈/테마 번역 fallback, 소장/대여 상태 관련 차단 이슈 없음.
---
## 4. 구현 순서 요약
1. 오디오 탭 fallback/page/sort/rate 정책을 테스트로 고정한다.
2. domain model과 port 계약을 추가한다.
3. service orchestration을 fake port 테스트로 고정한다.
4. API DTO와 facade 변환을 고정한다.
5. QueryDSL repository를 creator/block/theme/count/list 순서로 구현한다.
6. controller 공개 계약을 MockMvc로 고정한다.
7. E2E 테스트와 전체 회귀 검증을 실행하고 결과를 이 문서에 누적한다.
---
## 5. PRD 요구사항 추적
- API endpoint와 공개 API 패키지: Phase 4 Task 4.1
- 재사용 가능한 조회 책임을 API 밖 도메인 패키지에 배치: Phase 1, Phase 2, Phase 3
- `creatorId`, `sort`, `themeId`, `page`, `size` 요청 처리: Phase 1 Task 1.1, Phase 4 Task 4.1
- invalid `sort` -> `LATEST` fallback: Phase 1 Task 1.1, Phase 4 Task 4.1, Phase 4 Task 4.2
- page/size fallback: Phase 1 Task 1.1, Phase 2 Task 2.1, Phase 4 Task 4.2
- 비활성/미존재 `themeId` 전체 조회 fallback: Phase 2 Task 2.1, Phase 3 Task 3.1, Phase 4 Task 4.2
- 테마 다국어 목록: Phase 3 Task 3.1
- 오디오/유료/구매 count와 퍼센트 소장률: Phase 2 Task 2.1, Phase 3 Task 3.2
- 오디오 콘텐츠 목록과 `CreatorChannelAudioContentResponse` 의미 보존: Phase 2 Task 2.2, Phase 3 Task 3.3
- 시리즈 이름 다국어 표시: Phase 3 Task 3.3
- 정렬 정책: Phase 3 Task 3.3
- 기존 API endpoint/응답 의미 보존: Phase 4 Task 4.1, Phase 5 Task 5.2
---
## 6. 검증 기록
- 2026-06-19: plan-task 문서 작성 단계. 구현 코드는 아직 변경하지 않았다.
- 2026-06-19: Phase 1 완료.
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest` 실행 시 `CreatorChannelAudioQueryPolicy`, `CreatorChannelAudioTab`, `CreatorChannelAudioQueryPort` 미존재 컴파일 실패 확인.
- GREEN: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest``BUILD SUCCESSFUL`.
- GREEN: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest``BUILD SUCCESSFUL`.
- 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicyTest``BUILD SUCCESSFUL`.
- 의존성 확인: `rg -n "v2\\.api\\.creator\\.channel\\.audio" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio` → 출력 없음.
- 2026-06-19: Phase 1 보강 범위 추가.
- 크리에이터 채널 오디오 콘텐츠 item은 홈/라이브/오디오 탭에서 공통 domain/response를 사용한다.
- live/home domain model의 `publishedAt`은 공개 응답에 사용하지 않고 오디오 item 공통 계약에도 필요하지 않아 제거 대상으로 확정했다.
- 2026-06-19: Task 1.3 완료.
- RED: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest` → 공통 `CreatorChannelAudioContent`, `CreatorChannelAudioContentResponse` 미존재와 `publishedAt` 필드 불일치 컴파일 실패 확인.
- GREEN: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest``BUILD SUCCESSFUL`.
- 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.application.CreatorChannelLiveFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest``BUILD SUCCESSFUL`.
- 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHomeQueryPolicyTest` → 단독 재실행 시 `BUILD SUCCESSFUL`.
- 참고: live/home 회귀 테스트를 동시에 실행했을 때 home 테스트 결과 XML 파일 쓰기 실패가 1회 발생했다. 단독 재실행에서 통과해 Gradle 병렬 실행 중 `build/test-results/test` 쓰기 충돌로 판단했다.
- 포맷: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process ktlintCheck``BUILD SUCCESSFUL`.
- 공백: `git diff --check` → 출력 없음.
- 중복 확인: `rg -n "data class CreatorChannelAudioContent|data class CreatorChannelAudioContentResponse|publishedAt =|v2\\.creator\\.channel\\.(live|home|audio)\\.domain\\.CreatorChannelAudioContent|v2\\.api\\.creator\\.channel\\.(live|home)\\.dto\\.CreatorChannelAudioContentResponse" src/main/kotlin src/test/kotlin` → 공통 domain/response 1건씩, 각 탭 port record, 홈 시리즈 집계 local 변수만 확인.
- 2026-06-19: Phase 2 완료.
- Task 2.1 RED: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest``CreatorChannelAudioQueryService` 미존재 컴파일 실패 확인.
- Task 2.2 RED: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest``CreatorChannelAudioFacade` 미존재 컴파일 실패 확인.
- GREEN: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest``BUILD SUCCESSFUL`.
- 리뷰 보강: Phase 3 port 구현 전 Spring bean 생성 실패를 피하기 위해 live 탭과 동일하게 `ObjectProvider<CreatorChannelAudioQueryPort>` 주입으로 조정했다.
- 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest``BUILD SUCCESSFUL`.
- 포맷: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process ktlintCheck``BUILD SUCCESSFUL`.
- 의존성 확인: `rg -n "Q[A-Z]|queryFactory|javax\\.persistence" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application` → 출력 없음.
- 공백: `git diff --check` → 출력 없음.
- 2026-06-19: Phase 3 완료.
- Task 3.1~3.3 RED: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest` → 테스트 미존재/구현 전 실패 확인.
- GREEN: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --rerun-tasks --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest``BUILD SUCCESSFUL`.
- 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest``BUILD SUCCESSFUL`.
- 포맷: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process ktlintCheck``BUILD SUCCESSFUL`.
- 공백: `git diff --check` → 출력 없음.
- 참고: 검증 중 Gradle 명령을 병렬 실행했을 때 QueryDSL generated source 참조 오류가 1회 발생했다. 단독 순차 재실행에서 컴파일과 테스트가 통과해 병렬 Gradle 실행 중 generated source 작업 충돌로 판단했다.
- 리뷰 보강: `OWNED` 정렬이 주문 수가 아니라 소장 또는 유효 대여 여부 boolean 기준이 되도록 `CaseBuilder` 정렬로 수정했다.
- 보강 RED: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest.shouldSortAudioContentsByOwnedAndReturnOrderStatesWithSeriesFallback` → 중복 주문 콘텐츠가 더 최신 소장 콘텐츠보다 앞서는 assertion 실패 확인.
- 보강 GREEN: 같은 테스트 재실행 → `BUILD SUCCESSFUL`.
- 보강 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --rerun-tasks --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest``BUILD SUCCESSFUL`.
- 보강 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest``BUILD SUCCESSFUL`.
- 보강 포맷: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process ktlintCheck``BUILD SUCCESSFUL`.
- 보강 공백: `git diff --check` → 출력 없음.
- 2026-06-19: Phase 4 완료.
- Task 4.1 RED: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioControllerTest``CreatorChannelAudioController` 미존재 컴파일 실패 확인.
- Task 4.1 GREEN: 같은 테스트 재실행 → `BUILD SUCCESSFUL`.
- Task 4.2: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioEndToEndTest``BUILD SUCCESSFUL`.
- 보강: 전체 suite 실행 중 SpringBootTest context 추가로 heap 사용량이 증가해 `OutOfMemoryError`가 발생했다. 오디오 E2E가 기존 라이브 E2E와 Spring TestContext cache를 재사용하도록 datasource property를 동일하게 맞추고, 공유 DB에서 theme 정렬에 의존하지 않도록 assertion을 조정했다.
- 보강 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveEndToEndTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioEndToEndTest``BUILD SUCCESSFUL`.
- 보강 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioEndToEndTest``BUILD SUCCESSFUL`.
- 보강 포맷: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process ktlintCheck``BUILD SUCCESSFUL`.
- 보강 전체 회귀: `./gradlew --no-daemon --max-workers=1 -Dkotlin.compiler.execution.strategy=in-process test``BUILD SUCCESSFUL`.
- 보강 매핑 확인: `rg -n "@GetMapping\\(\\\"/\\{creatorId\\}/(home|live|audio)\\\"\\)" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel` → home/live/audio 각각 1건.
- 보강 공백: `git diff --check` → 출력 없음.
- 2026-06-19: Phase 5 완료.
- 대상 회귀: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest``BUILD SUCCESSFUL`.
- 전체 회귀: `./gradlew --no-daemon --max-workers=1 -Dkotlin.compiler.execution.strategy=in-process test``BUILD SUCCESSFUL`.
- 포맷: `./gradlew --no-daemon -Dkotlin.compiler.execution.strategy=in-process ktlintCheck``BUILD SUCCESSFUL`.
- 공백: `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, 소장/대여 상태 관련 차단 이슈 없음.

View File

@@ -0,0 +1,265 @@
# PRD: 크리에이터 채널 오디오 탭 API
## 1. Overview
크리에이터 채널의 오디오 탭에서 테마 목록, 정렬별 오디오 콘텐츠 개수, 소장률, 오디오 콘텐츠 목록을 한 번에 조회하는 API를 제공한다.
---
## 2. Problem
- 크리에이터 채널 오디오 탭은 테마 필터, 정렬 상태, 콘텐츠 개수, 소장률, 콘텐츠 목록을 함께 표시해야 한다.
- 기존 라이브 탭 API는 `다시듣기` 콘텐츠에 한정되어 있고, 오디오 탭은 전체 오디오 콘텐츠와 선택한 테마별 콘텐츠를 조회해야 한다.
- 클라이언트는 오디오 탭 진입 시 테마 리스트와 콘텐츠 목록을 별도 API 조합 없이 일관된 계약으로 받아야 한다.
- 테마명은 호출하는 유저의 언어코드에 따라 한글, 영문, 일본어로 표시되어야 한다.
- 기존 API endpoint와 응답 필드의 의미는 변경하지 않아야 한다.
---
## 3. Goals
- 크리에이터 채널 오디오 탭 조회 API를 제공한다.
- 클라이언트에서 호출하는 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.audio` 하위 조립 계층에 둔다.
- 오디오 리스트, 오디오 개수, 소장률 계산, 테마 조회처럼 재사용 가능한 조회 책임은 API 패키지 밖의 도메인 패키지에 둔다.
- 요청은 `creatorId`, 정렬 순서, 테마를 받는다.
- 정렬 순서를 보내지 않으면 최신순을 기본값으로 사용한다.
- 테마를 보내지 않으면 전체 테마의 오디오 콘텐츠를 조회한다.
- 응답에는 오디오 콘텐츠 개수, 유료 콘텐츠 개수, 호출자가 구매한 콘텐츠 개수, 호출자가 구매한 콘텐츠 개수의 비율, 크리에이터의 콘텐츠 목록, 실제 적용된 정렬 순서, 테마 목록을 포함한다.
- 콘텐츠 목록 item은 기존 `CreatorChannelAudioContentResponse`와 같은 필드/의미를 사용한다.
- 오디오 콘텐츠 목록은 라이브 탭의 `다시듣기` 목록과 같은 조회/정렬/소장 상태 의미를 따르되, 시리즈 이름이 표시되어야 한다.
- 테마 목록은 테마 id와 호출 유저 언어코드에 맞는 테마명을 내려준다.
---
## 4. Non-Goals
- 이번 범위는 크리에이터 채널 `오디오` 탭 조회 API만 포함한다.
- 기존 크리에이터 채널 홈 API, 라이브 탭 API endpoint와 응답 필드의 의미는 변경하지 않는다.
- 오디오 콘텐츠 구매, 소장, 대여, 결제 API는 포함하지 않는다.
- 오디오 콘텐츠 생성/수정/삭제 API는 포함하지 않는다.
- 테마 관리 화면, 테마 생성/수정/삭제 API, 테마 번역 관리 API는 포함하지 않는다.
- 테마 번역 데이터가 없는 항목을 새로 번역하거나 생성하는 배치 작업은 포함하지 않는다.
- 앱 표시용 날짜 포맷, 가격 단위 표시는 서버에서 처리하지 않는다.
---
## 5. Target Users
- 회원: 크리에이터 채널 오디오 탭에서 크리에이터의 오디오 콘텐츠를 테마별로 탐색하는 사용자
- 앱 클라이언트: 오디오 탭 구성에 필요한 테마/개수/소장률/목록 데이터를 단일 API 응답으로 표시하려는 클라이언트
- 크리에이터: 자신의 오디오 콘텐츠가 테마와 정렬 기준에 따라 적절히 노출되기를 원하는 사용자
---
## 6. User Stories
- 사용자는 크리에이터 채널 오디오 탭에 들어가면 전체 오디오 콘텐츠 개수를 확인하고 싶다.
- 사용자는 테마를 선택해 특정 테마의 오디오 콘텐츠만 보고 싶다.
- 사용자는 최신순, 인기순, 소장순, 높은 가격순, 낮은 가격순으로 오디오 콘텐츠를 바꿔 보고 싶다.
- 사용자는 유료 콘텐츠 중 자신이 구매한 콘텐츠 비율을 확인하고 싶다.
- 앱 클라이언트는 현재 적용된 정렬 순서를 응답에서 확인해 화면 상태와 서버 조회 결과를 맞추고 싶다.
- 앱 클라이언트는 호출 유저 언어코드에 맞는 테마명을 받아 화면에 표시하고 싶다.
---
## 7. Core Features
### Feature A. 크리에이터 채널 오디오 탭 조회 API
#### Requirements
- 신규 API는 크리에이터 채널 전용 v2 API로 작성한다.
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/audio`를 기본안으로 한다.
- `creatorId`는 path variable로 받는다.
- 정렬 순서는 query parameter로 받는다.
- 정렬 순서 query parameter 이름은 `sort`를 기본안으로 한다.
- `sort`를 보내지 않으면 `LATEST`를 기본값으로 사용한다.
- `sort` 값이 없거나 기존 `ContentSort` enum 값에 없으면 `LATEST`로 fallback한다.
- 테마는 query parameter로 받는다.
- 테마 query parameter 이름은 `themeId`를 기본안으로 한다.
- `themeId`를 보내지 않으면 전체 테마의 오디오 콘텐츠를 조회한다.
- 오디오 콘텐츠 추가 로딩을 위해 `page`, `size` query parameter를 받는다.
- `page`는 0부터 시작하는 page index로 처리한다.
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
- `page`가 0보다 작으면 `0`으로 fallback한다.
- `size`가 20보다 작으면 `20`으로 fallback한다.
- `size`가 50보다 크면 `50`으로 fallback한다.
- API는 인증 회원만 조회할 수 있어야 한다.
- 비회원이 조회하면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
- 조회 대상 회원이 존재하지 않으면 기존 정책과 동일하게 `member.validation.user_not_found` 계열 오류를 반환한다.
- 조회 대상 회원이 크리에이터가 아니면 기존 정책과 동일하게 `member.validation.creator_not_found` 계열 오류를 반환한다.
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
- 공개된 오디오 콘텐츠가 없어도 전체 API는 성공 처리한다.
#### Edge Cases
- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다.
- 알 수 없는 `sort` 값은 400 오류를 반환하지 않고 `LATEST`로 fallback한다.
- `themeId`가 존재하지 않거나 비활성 테마이면 오류를 반환하지 않고 전체 테마 조회로 fallback한다.
- `page`가 0보다 작거나 `size`가 허용 범위를 벗어나도 400 오류를 반환하지 않고 실제 적용값으로 보정한다.
### Feature B. 응답 스키마
#### Requirements
- 응답 DTO는 구현 전에 명시하고 공개 API 계약으로 관리한다.
- 응답 최상위 DTO 이름은 `CreatorChannelAudioTabResponse`를 기본안으로 한다.
- 응답에는 다음 값을 포함한다.
- `audioContentCount`: 선택한 테마 필터를 적용한 오디오 콘텐츠 전체 개수
- `paidAudioContentCount`: 선택한 테마 필터를 적용한 유료 오디오 콘텐츠 전체 개수
- `purchasedAudioContentCount`: 선택한 테마 필터를 적용한 유료 오디오 콘텐츠 중 호출자가 구매한 콘텐츠 개수
- `purchasedAudioContentRate`: `paidAudioContentCount` 대비 `purchasedAudioContentCount`의 퍼센트 값
- `themes`: 활성 테마 목록
- `audioContents`: 오디오 콘텐츠 목록
- `sort`: 콘텐츠 조회에 실제 적용한 정렬 순서
- `themeId`: 콘텐츠 조회에 실제 적용한 테마 id, 전체 조회이면 `null`
- `page`: 현재 응답의 page index
- `size`: 현재 응답의 page size
- `hasNext`: 다음 page 존재 여부
- `audioContents`의 각 item은 기존 `CreatorChannelAudioContentResponse`와 같은 필드/의미를 사용한다.
- `sort`는 요청값이 없거나 알 수 없는 값이면 실제 적용값인 `LATEST`를 내려준다.
- `themeId`는 요청값이 없거나 비활성/미존재 테마라 전체 조회로 fallback하면 `null`을 내려준다.
- `page`, `size`는 fallback 보정 이후 실제 적용된 값을 내려준다.
- `hasNext`는 같은 필터/정렬 조건에서 다음 page에 노출할 오디오 콘텐츠가 있으면 `true`로 내려준다.
- `purchasedAudioContentRate``paidAudioContentCount == 0`이면 `0.0`으로 내려준다.
- `purchasedAudioContentRate``(purchasedAudioContentCount / paidAudioContentCount) * 100`을 기준으로 계산한 퍼센트 값으로 내려준다.
- 응답 스키마 예시는 다음과 같다.
```kotlin
data class CreatorChannelAudioTabResponse(
val audioContentCount: Int,
val paidAudioContentCount: Int,
val purchasedAudioContentCount: Int,
val purchasedAudioContentRate: Double,
val themes: List<CreatorChannelAudioThemeResponse>,
val audioContents: List<CreatorChannelAudioContentResponse>,
val sort: ContentSort,
val themeId: Long?,
val page: Int,
val size: Int,
val hasNext: Boolean
)
data class CreatorChannelAudioThemeResponse(
val themeId: Long,
val themeName: String
)
data class CreatorChannelAudioContentResponse(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
val isAdult: Boolean,
val isPointAvailable: Boolean,
val isFirstContent: Boolean,
val seriesName: String?,
val isOriginalSeries: Boolean?,
val isOwned: Boolean,
val isRented: Boolean
)
enum class ContentSort {
LATEST,
POPULAR,
OWNED,
PRICE_HIGH,
PRICE_LOW
}
```
#### Edge Cases
- 공개된 오디오 콘텐츠가 없으면 `audioContentCount`, `paidAudioContentCount`, `purchasedAudioContentCount``0`, `purchasedAudioContentRate``0.0`, `audioContents`는 빈 배열, `hasNext``false`로 내려준다.
- 요청한 page 범위에 콘텐츠가 없으면 `audioContents`는 빈 배열, `hasNext``false`로 내려주되 개수 필드는 전체 개수를 유지한다.
### Feature C. 테마 목록
#### Requirements
- 테마 목록은 `AudioContentTheme.isActive == true`인 테마만 내려준다.
- 테마 목록은 기존 테마 정렬 정책인 `AudioContentTheme.orders`를 따른다.
- 테마 응답은 테마 id와 테마명을 포함한다.
- 테마명은 호출하는 유저의 언어코드에 따라 한글, 영문, 일본어로 반환한다.
- 호출 유저 언어코드는 기존 `LangContext.lang.code` 값을 사용한다.
- 지원 언어코드는 `ko`, `en`, `ja`를 기준으로 한다.
- `ko``AudioContentTheme.theme` 원문을 기본으로 사용한다.
- `en`, `ja``ContentThemeTranslation.locale`에 해당하는 번역값이 있으면 해당 `theme`을 사용한다.
- 요청 언어의 번역값이 없으면 `AudioContentTheme.theme` 원문을 fallback으로 사용한다.
- 테마 목록은 선택한 `themeId`와 무관하게 활성 테마 전체를 내려준다.
#### Edge Cases
- 활성 테마가 없으면 `themes`는 빈 배열로 내려준다.
- 번역 데이터는 있지만 빈 문자열이면 원문 테마명을 fallback으로 사용한다.
### Feature D. 오디오 콘텐츠 목록과 개수
#### Requirements
- 조회 대상은 지정한 `creatorId`의 오디오 콘텐츠로 제한한다.
- 공개된 콘텐츠만 조회한다.
- 예약 공개 전 콘텐츠는 포함하지 않는다.
- `releaseDate == null`인 오디오 콘텐츠는 삭제/미공개 데이터로 보고 조회에서 제외한다.
- `themeId`가 있고 활성 테마이면 해당 테마의 오디오 콘텐츠만 조회한다.
- `themeId`가 없거나 비활성/미존재 테마이면 전체 활성 테마의 오디오 콘텐츠를 조회한다.
- 콘텐츠 목록은 `page`, `size` 기준으로 페이징 조회한다.
- 다음 page 존재 여부는 `size + 1`개를 조회하거나 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
- 오디오 콘텐츠 개수는 목록 조회와 같은 공개 여부, 예약 공개, 성인 콘텐츠, 차단 정책, 테마 필터를 적용해 계산한다.
- 유료 콘텐츠 개수는 같은 필터를 적용한 콘텐츠 중 `price > 0`인 콘텐츠 개수로 계산한다.
- 호출자가 구매한 콘텐츠 개수는 같은 필터를 적용한 유료 콘텐츠 중 조회자가 유효하게 소장하거나 대여 중인 콘텐츠 개수로 계산한다.
- 대여 중인 콘텐츠는 호출자가 구매한 콘텐츠 개수와 `purchasedAudioContentRate` 계산에 포함한다.
- 조회자의 성인 콘텐츠 노출 정책이 false이면 성인 콘텐츠는 목록과 개수에서 제외한다.
- 응답 item 필드는 기존 `CreatorChannelAudioContentResponse`와 같은 의미를 유지한다.
- `seriesName`은 콘텐츠가 속한 시리즈 이름을 내려준다.
- 시리즈 이름은 호출 유저 언어코드에 맞는 번역값이 있으면 번역명을 사용하고, 없으면 원문 시리즈명을 사용한다.
- `isFirstContent`, `seriesName`, `isOriginalSeries`, 구매/대여/포인트 사용 가능 여부의 의미는 기존 오디오 콘텐츠/시리즈 콘텐츠 응답과 동일하게 유지한다.
- `isFirstContent`는 선택한 테마 안에서 첫 콘텐츠인지가 아니라, 크리에이터의 전체 공개 오디오 콘텐츠 중 첫 콘텐츠인지로 판단한다.
#### Edge Cases
- 시리즈에 속하지 않은 콘텐츠는 `seriesName`, `isOriginalSeries``null`로 내려준다.
- 무료 콘텐츠는 구매한 콘텐츠 개수와 구매 비율 계산에서 제외한다.
- 일반적으로 `isOwned == true``isRented == true`가 동시에 발생하지 않지만, 데이터상 동시에 유효한 소장/대여 주문이 있으면 둘 다 `true`로 내려준다.
### Feature E. 콘텐츠 정렬
#### Requirements
- 정렬 순서는 기존 공용 `ContentSort` enum을 사용한다.
- 공개 요청/응답 값은 다음을 사용한다.
- `LATEST`: 최신순, 기본값
- `POPULAR`: 인기순
- `OWNED`: 소장순
- `PRICE_HIGH`: 높은 가격순
- `PRICE_LOW`: 낮은 가격순
- `LATEST`는 공개일 최신순을 1차 정렬로 사용한다.
- `LATEST`의 2차 정렬은 높은 가격순이다.
- `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차 정렬은 `audioContent.id desc`다.
- 최신순 기준에 사용하는 날짜는 기존 `CreatorChannelAudioContentResponse` 목록 정책과 동일하게 공개 시각(`releaseDate`)을 기준으로 한다.
- 인기순의 매출은 대여/소장 여부와 관계없이 해당 콘텐츠에 순수하게 결제된 캔 매출 합계(`orders.can`)를 기준으로 하며, 포인트 사용액은 매출 기준에 포함하지 않는다.
- 환불되었거나 비활성 처리된 구매 내역은 기존 콘텐츠 구매/매출 정책과 동일하게 제외한다.
#### Edge Cases
- 매출이 없는 콘텐츠의 인기순 매출값은 0으로 처리한다.
- 조회자가 소장 또는 대여 중인 콘텐츠가 없으면 `OWNED` 정렬은 최신순 + `audioContent.id desc` 보조 정렬과 같은 결과가 될 수 있다.
- 가격이 같은 콘텐츠는 각 정렬의 2차/3차 기준을 따른다.
---
## 8. Technical Constraints
- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다.
- Kotlin + Spring Boot 2.7.14 기존 스타일을 따른다.
- 신규 공개 API 스키마는 구현 전에 PRD와 구현 계획/TASK 문서에 명시한다.
- 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.audio` 하위에 둔다.
- API 조립 계층은 HTTP 계약과 공개 응답 변환만 담당한다.
- 도메인 조회 코드는 `kr.co.vividnext.sodalive.v2.creator.channel.audio` 또는 재사용 범위가 더 넓은 기존 도메인 패키지 하위에 둔다.
- 도메인 패키지는 `kr.co.vividnext.sodalive.v2.api.*` 패키지에 의존하지 않는다.
- 기존 라이브 탭의 `CreatorChannelAudioContentResponse`와 필드/의미가 어긋나지 않도록 오디오 탭 응답 DTO를 작성한다.
- 기존 `ContentSort` enum을 재사용하고, API binding, service 정책, 테스트에서 같은 타입을 사용한다.
- 기존 크리에이터 채널 홈/라이브 API의 인증, 예외, 성인 콘텐츠 노출, 차단 관계 정책은 재사용한다.
- 페이징 응답은 기존 라이브 탭 API와 같은 `page`, `size`, `hasNext` 패턴을 따른다.
- 테마명 다국어 처리는 기존 `LangContext`, `ContentThemeTranslation` 구조를 따른다.
- 시리즈명 다국어 처리는 기존 `SeriesTranslation` 구조를 따른다.
---
## 9. Metrics
- 오디오 탭 API 성공/실패 건수
- 오디오 탭 API 응답 시간
- 테마별 조회 건수
- 정렬 기준별 조회 건수
- 오디오 탭에서 콘텐츠 추가 로딩 요청 건수

View File

@@ -0,0 +1,43 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacade
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/creator-channels")
class CreatorChannelAudioController(
private val creatorChannelAudioFacade: CreatorChannelAudioFacade
) {
@GetMapping("/{creatorId}/audio")
fun getAudioTab(
@PathVariable creatorId: Long,
@RequestParam(required = false) sort: String?,
@RequestParam(required = false) themeId: Long?,
@RequestParam(required = false) page: Int?,
@RequestParam(required = false) size: Int?,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
creatorChannelAudioFacade.getAudioTab(
creatorId = creatorId,
viewer = requireMember(member),
sort = sort,
themeId = themeId,
page = page,
size = size
)
)
}
private fun requireMember(member: Member?): Member {
return member ?: throw SodaException(messageKey = "common.error.bad_credentials")
}
}

View File

@@ -0,0 +1,36 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.creator.channel.audio.dto.CreatorChannelAudioTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
@Transactional(readOnly = true)
class CreatorChannelAudioFacade(
private val creatorChannelAudioQueryService: CreatorChannelAudioQueryService
) {
fun getAudioTab(
creatorId: Long,
viewer: Member,
sort: String?,
themeId: Long?,
page: Int?,
size: Int?,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelAudioTabResponse {
return CreatorChannelAudioTabResponse.from(
creatorChannelAudioQueryService.getAudioTab(
creatorId = creatorId,
viewer = viewer,
sort = sort,
themeId = themeId,
page = page,
size = size,
now = now
)
)
}
}

View File

@@ -0,0 +1,54 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.audio.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTab
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTheme
data class CreatorChannelAudioTabResponse(
val audioContentCount: Int,
val paidAudioContentCount: Int,
val purchasedAudioContentCount: Int,
val purchasedAudioContentRate: Double,
val themes: List<CreatorChannelAudioThemeResponse>,
val audioContents: List<CreatorChannelAudioContentResponse>,
val sort: ContentSort,
val themeId: Long?,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(tab: CreatorChannelAudioTab): CreatorChannelAudioTabResponse {
return CreatorChannelAudioTabResponse(
audioContentCount = tab.audioContentCount,
paidAudioContentCount = tab.paidAudioContentCount,
purchasedAudioContentCount = tab.purchasedAudioContentCount,
purchasedAudioContentRate = tab.purchasedAudioContentRate,
themes = tab.themes.map(CreatorChannelAudioThemeResponse::from),
audioContents = tab.audioContents.map(CreatorChannelAudioContentResponse::from),
sort = tab.sort,
themeId = tab.themeId,
page = tab.page.page,
size = tab.page.size,
hasNext = tab.hasNext
)
}
}
}
data class CreatorChannelAudioThemeResponse(
val themeId: Long,
val themeName: String
) {
companion object {
fun from(theme: CreatorChannelAudioTheme): CreatorChannelAudioThemeResponse {
return CreatorChannelAudioThemeResponse(
themeId = theme.themeId,
themeName = theme.themeName
)
}
}
}

View File

@@ -0,0 +1,44 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
data class CreatorChannelAudioContentResponse(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
val seriesName: String?,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean?,
@JsonProperty("isOwned")
val isOwned: Boolean,
@JsonProperty("isRented")
val isRented: Boolean
) {
companion object {
fun from(audioContent: CreatorChannelAudioContent): CreatorChannelAudioContentResponse {
return CreatorChannelAudioContentResponse(
audioContentId = audioContent.audioContentId,
title = audioContent.title,
duration = audioContent.duration,
imageUrl = audioContent.imageUrl,
price = audioContent.price,
isAdult = audioContent.isAdult,
isPointAvailable = audioContent.isPointAvailable,
isFirstContent = audioContent.isFirstContent,
seriesName = audioContent.seriesName,
isOriginalSeries = audioContent.isOriginalSeries,
isOwned = audioContent.isOwned,
isRented = audioContent.isRented
)
}
}
}

View File

@@ -1,9 +1,9 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCreator
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelDonation
@@ -108,46 +108,6 @@ data class CreatorChannelLiveResponse(
}
}
data class CreatorChannelAudioContentResponse(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
val seriesName: String?,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean?,
@JsonProperty("isOwned")
val isOwned: Boolean,
@JsonProperty("isRented")
val isRented: Boolean
) {
companion object {
fun from(audioContent: CreatorChannelAudioContent): CreatorChannelAudioContentResponse {
return CreatorChannelAudioContentResponse(
audioContentId = audioContent.audioContentId,
title = audioContent.title,
duration = audioContent.duration,
imageUrl = audioContent.imageUrl,
price = audioContent.price,
isAdult = audioContent.isAdult,
isPointAvailable = audioContent.isPointAvailable,
isFirstContent = audioContent.isFirstContent,
seriesName = audioContent.seriesName,
isOriginalSeries = audioContent.isOriginalSeries,
isOwned = audioContent.isOwned,
isRented = audioContent.isRented
)
}
}
}
data class CreatorChannelDonationResponse(
val nickname: String,
val profileImageUrl: String,

View File

@@ -1,8 +1,8 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.live.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLive
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveTab
import java.time.LocalDateTime
@@ -33,46 +33,6 @@ data class CreatorChannelLiveTabResponse(
}
}
data class CreatorChannelAudioContentResponse(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
val seriesName: String?,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean?,
@JsonProperty("isOwned")
val isOwned: Boolean,
@JsonProperty("isRented")
val isRented: Boolean
) {
companion object {
fun from(content: CreatorChannelAudioContent): CreatorChannelAudioContentResponse {
return CreatorChannelAudioContentResponse(
audioContentId = content.audioContentId,
title = content.title,
duration = content.duration,
imageUrl = content.imageUrl,
price = content.price,
isAdult = content.isAdult,
isPointAvailable = content.isPointAvailable,
isFirstContent = content.isFirstContent,
seriesName = content.seriesName,
isOriginalSeries = content.isOriginalSeries,
isOwned = content.isOwned,
isRented = content.isRented
)
}
}
}
data class CreatorChannelLiveResponse(
val liveId: Long,
val title: String,

View File

@@ -0,0 +1,7 @@
package kr.co.vividnext.sodalive.v2.common.domain
fun String?.toCdnUrl(cloudFrontHost: String): String? {
if (isNullOrBlank()) return null
if (startsWith("https://") || startsWith("http://")) return this
return "$cloudFrontHost/$this"
}

View File

@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioQueryPort
interface CreatorChannelAudioQueryRepository : CreatorChannelAudioQueryPort

View File

@@ -0,0 +1,407 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence
import com.querydsl.core.Tuple
import com.querydsl.core.types.Projections
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.core.types.dsl.CaseBuilder
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.order.QOrder
import kr.co.vividnext.sodalive.content.order.QOrder.order
import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
import kr.co.vividnext.sodalive.content.theme.translation.QContentThemeTranslation
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
import kr.co.vividnext.sodalive.member.QMember.member
import kr.co.vividnext.sodalive.member.block.QBlockMember
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioThemeRecord
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
@Repository
class DefaultCreatorChannelAudioQueryRepository(
private val queryFactory: JPAQueryFactory
) : CreatorChannelAudioQueryRepository {
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelAudioCreatorRecord? {
return queryFactory
.select(
Projections.constructor(
CreatorChannelAudioCreatorRecord::class.java,
member.id,
member.role,
member.nickname
)
)
.from(member)
.where(
member.id.eq(creatorId),
member.isActive.isTrue
)
.fetchFirst()
}
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean {
val blockMember = QBlockMember("creatorChannelAudioBlockMember")
return queryFactory
.select(blockMember.id)
.from(blockMember)
.where(
blockMember.isActive.isTrue,
blockMember.member.id.eq(viewerId).and(blockMember.blockedMember.id.eq(creatorId))
.or(blockMember.member.id.eq(creatorId).and(blockMember.blockedMember.id.eq(viewerId)))
)
.fetchFirst() != null
}
override fun findActiveThemeId(themeId: Long): Long? {
return queryFactory
.select(audioContentTheme.id)
.from(audioContentTheme)
.where(
audioContentTheme.id.eq(themeId),
audioContentTheme.isActive.isTrue
)
.fetchFirst()
}
override fun findAudioThemes(locale: String): List<CreatorChannelAudioThemeRecord> {
val themeTranslation = QContentThemeTranslation("audioThemeTranslation")
return queryFactory
.select(audioContentTheme.id, audioContentTheme.theme, themeTranslation.theme)
.from(audioContentTheme)
.leftJoin(themeTranslation)
.on(
themeTranslation.contentThemeId.eq(audioContentTheme.id),
themeTranslation.locale.eq(locale)
)
.where(audioContentTheme.isActive.isTrue)
.orderBy(audioContentTheme.orders.asc(), audioContentTheme.id.asc())
.fetch()
.map {
CreatorChannelAudioThemeRecord(
themeId = it.get(audioContentTheme.id)!!,
themeName = it.get(themeTranslation.theme).takeUnless(String?::isNullOrBlank)
?: it.get(audioContentTheme.theme)!!
)
}
}
override fun countAudioContents(
creatorId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int {
return queryFactory
.select(audioContent.id.count())
.from(audioContent)
.innerJoin(audioContent.theme, audioContentTheme)
.where(audioContentCondition(creatorId, themeId, now, canViewAdultContent))
.fetchOne()
?.toInt()
?: 0
}
override fun countPaidAudioContents(
creatorId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int {
return queryFactory
.select(audioContent.id.count())
.from(audioContent)
.innerJoin(audioContent.theme, audioContentTheme)
.where(
audioContentCondition(creatorId, themeId, now, canViewAdultContent),
audioContent.price.gt(0)
)
.fetchOne()
?.toInt()
?: 0
}
override fun countPurchasedAudioContents(
creatorId: Long,
viewerId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int {
val purchasedOrder = QOrder("audioPurchasedOrder")
return queryFactory
.select(audioContent.id.countDistinct())
.from(audioContent)
.innerJoin(audioContent.theme, audioContentTheme)
.innerJoin(purchasedOrder)
.on(purchasedOrder.audioContent.id.eq(audioContent.id))
.where(
audioContentCondition(creatorId, themeId, now, canViewAdultContent),
audioContent.price.gt(0),
purchasedOrder.member.id.eq(viewerId),
purchasedOrder.isActive.isTrue,
validPurchasedOrderCondition(purchasedOrder, now)
)
.fetchOne()
?.toInt()
?: 0
}
override fun findAudioContents(
creatorId: Long,
viewerId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean,
sort: ContentSort,
locale: String,
offset: Long,
limit: Int
): List<CreatorChannelAudioContentRecord> {
val rows = findAudioContentRows(creatorId, viewerId, themeId, now, canViewAdultContent, sort, offset, limit)
val contentIds = rows.map { itAudioId(it) }
val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent)
val seriesByContentId = audioSeriesByContentIds(contentIds, locale)
val orderStatesByContentId = orderStatesByContentIds(viewerId, contentIds, now)
return rows.map { it.toAudioRecord(firstContentId, seriesByContentId, orderStatesByContentId) }
}
private fun findAudioContentRows(
creatorId: Long,
viewerId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean,
sort: ContentSort,
offset: Long,
limit: Int
): List<Tuple> {
val query = queryFactory
.select(
audioContent.id,
audioContent.title,
audioContent.duration,
audioContent.coverImage,
audioContent.price,
audioContent.isAdult,
audioContent.isPointAvailable,
audioContent.releaseDate
)
.from(audioContent)
.innerJoin(audioContent.theme, audioContentTheme)
.where(audioContentCondition(creatorId, themeId, now, canViewAdultContent))
when (sort) {
ContentSort.POPULAR -> {
val revenueOrder = QOrder("audioRevenueOrder")
query
.leftJoin(revenueOrder)
.on(
revenueOrder.audioContent.id.eq(audioContent.id),
revenueOrder.isActive.isTrue
)
.groupByAudioContentRow()
.orderBy(
revenueOrder.can.sum().coalesce(0).desc(),
audioContent.releaseDate.desc(),
audioContent.id.desc()
)
}
ContentSort.OWNED -> {
val ownedOrder = QOrder("audioOwnedOrder")
query
.leftJoin(ownedOrder)
.on(
ownedOrder.audioContent.id.eq(audioContent.id),
ownedOrder.member.id.eq(viewerId),
ownedOrder.isActive.isTrue,
validPurchasedOrderCondition(ownedOrder, now)
)
.groupByAudioContentRow()
.orderBy(
CaseBuilder()
.`when`(ownedOrder.id.countDistinct().gt(0))
.then(1)
.otherwise(0)
.desc(),
audioContent.releaseDate.desc(),
audioContent.id.desc()
)
}
ContentSort.LATEST -> query.orderBy(
audioContent.releaseDate.desc(),
audioContent.price.desc(),
audioContent.id.desc()
)
ContentSort.PRICE_HIGH -> query.orderBy(
audioContent.price.desc(),
audioContent.releaseDate.desc(),
audioContent.id.desc()
)
ContentSort.PRICE_LOW -> query.orderBy(
audioContent.price.asc(),
audioContent.releaseDate.desc(),
audioContent.id.desc()
)
}
return query
.offset(offset)
.limit(limit.toLong())
.fetch()
}
private fun com.querydsl.jpa.impl.JPAQuery<Tuple>.groupByAudioContentRow(): com.querydsl.jpa.impl.JPAQuery<Tuple> {
return groupBy(
audioContent.id,
audioContent.title,
audioContent.duration,
audioContent.coverImage,
audioContent.price,
audioContent.isAdult,
audioContent.isPointAvailable,
audioContent.releaseDate
)
}
private fun audioContentCondition(
creatorId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): BooleanExpression {
return audioContent.member.id.eq(creatorId)
.and(audioContent.member.isActive.isTrue)
.and(audioContent.isActive.isTrue)
.and(audioContentTheme.isActive.isTrue)
.and(themeCondition(themeId))
.and(audioContent.duration.isNotNull)
.and(audioContent.releaseDate.isNotNull)
.and(audioContent.releaseDate.loe(now))
.and(adultAudioCondition(canViewAdultContent))
}
private fun themeCondition(themeId: Long?): BooleanExpression? {
return themeId?.let { audioContentTheme.id.eq(it) }
}
private fun validPurchasedOrderCondition(targetOrder: QOrder, now: LocalDateTime): BooleanExpression {
return targetOrder.type.eq(OrderType.KEEP)
.or(targetOrder.type.eq(OrderType.RENTAL).and(targetOrder.endDate.after(now)))
}
private fun itAudioId(row: Tuple): Long = row.get(audioContent.id)!!
private fun Tuple.toAudioRecord(
firstContentId: Long?,
seriesByContentId: Map<Long, AudioSeriesSummary>,
orderStatesByContentId: Map<Long, AudioOrderState>
): CreatorChannelAudioContentRecord {
val audioContentId = get(audioContent.id)!!
val seriesSummary = seriesByContentId[audioContentId]
val orderState = orderStatesByContentId[audioContentId]
return CreatorChannelAudioContentRecord(
audioContentId = audioContentId,
title = get(audioContent.title)!!,
duration = get(audioContent.duration),
imagePath = get(audioContent.coverImage),
price = get(audioContent.price)!!,
isAdult = get(audioContent.isAdult)!!,
isPointAvailable = get(audioContent.isPointAvailable)!!,
isFirstContent = firstContentId == audioContentId,
seriesName = seriesSummary?.title,
isOriginalSeries = seriesSummary?.isOriginal,
isOwned = orderState?.isOwned ?: false,
isRented = orderState?.isRented ?: false
)
}
private fun firstAudioContentId(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean
): Long? {
return queryFactory
.select(audioContent.id)
.from(audioContent)
.innerJoin(audioContent.theme, audioContentTheme)
.where(audioContentCondition(creatorId, themeId = null, now, canViewAdultContent))
.orderBy(audioContent.releaseDate.asc(), audioContent.id.asc())
.fetchFirst()
}
private fun audioSeriesByContentIds(contentIds: List<Long>, locale: String): Map<Long, AudioSeriesSummary> {
if (contentIds.isEmpty()) return emptyMap()
val seriesTranslation = QSeriesTranslation("audioSeriesTranslation")
return queryFactory
.select(
seriesContent.content.id,
series.title,
series.isOriginal,
seriesTranslation
)
.from(seriesContent)
.innerJoin(seriesContent.series, series)
.leftJoin(seriesTranslation)
.on(
seriesTranslation.seriesId.eq(series.id),
seriesTranslation.locale.eq(locale)
)
.where(seriesContent.content.id.`in`(contentIds))
.fetch()
.associate {
val originalTitle = it.get(series.title)!!
val translatedTitle = it.get(seriesTranslation)?.renderedPayload?.title
it.get(seriesContent.content.id)!! to AudioSeriesSummary(
title = translatedTitle.takeUnless(String?::isNullOrBlank) ?: originalTitle,
isOriginal = it.get(series.isOriginal)!!
)
}
}
private fun orderStatesByContentIds(
viewerId: Long,
contentIds: List<Long>,
now: LocalDateTime
): Map<Long, AudioOrderState> {
if (contentIds.isEmpty()) return emptyMap()
return queryFactory
.select(order.audioContent.id, order.type)
.from(order)
.where(
order.member.id.eq(viewerId),
order.audioContent.id.`in`(contentIds),
order.isActive.isTrue,
validPurchasedOrderCondition(order, now)
)
.fetch()
.groupBy { it.get(order.audioContent.id)!! }
.mapValues { (_, rows) ->
val types = rows.map { it.get(order.type)!! }.toSet()
AudioOrderState(
isOwned = OrderType.KEEP in types,
isRented = OrderType.RENTAL in types
)
}
}
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else audioContent.isAdult.isFalse
}
private data class AudioSeriesSummary(
val title: String,
val isOriginal: Boolean
)
private data class AudioOrderState(
val isOwned: Boolean,
val isRented: Boolean
)
}

View File

@@ -0,0 +1,135 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTab
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTheme
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioThemeRecord
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
import org.springframework.beans.factory.ObjectProvider
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
@Transactional(readOnly = true)
class CreatorChannelAudioQueryService(
private val queryPortProvider: ObjectProvider<CreatorChannelAudioQueryPort>,
private val queryPolicy: CreatorChannelAudioQueryPolicy,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val messageSource: SodaMessageSource,
private val langContext: LangContext,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
fun getAudioTab(
creatorId: Long,
viewer: Member,
sort: String?,
themeId: Long?,
page: Int?,
size: Int?,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelAudioTab {
val resolvedSort = queryPolicy.resolveSort(sort)
val audioPage = queryPolicy.createPage(page, size)
val queryPort = queryPortProvider.getObject()
val viewerId = viewer.id!!
val creator = queryPort.findCreator(creatorId, viewerId)
?: throw SodaException(messageKey = "member.validation.user_not_found")
if (queryPort.existsBlockedBetween(viewerId, creatorId)) {
val messageTemplate = messageSource
.getMessage("explorer.creator.blocked_access", langContext.lang)
.orEmpty()
throw SodaException(message = String.format(messageTemplate, creator.nickname))
}
validateCreatorRole(creator)
val preference = memberContentPreferenceService.getStoredPreference(viewer)
val canViewAdultContent = isAdultVisibleByPolicy(viewer, preference.isAdultContentVisible)
val resolvedThemeId = themeId?.let(queryPort::findActiveThemeId)
val locale = langContext.lang.code
val fetchedContents = queryPort.findAudioContents(
creatorId = creatorId,
viewerId = viewerId,
themeId = resolvedThemeId,
now = now,
canViewAdultContent = canViewAdultContent,
sort = resolvedSort,
locale = locale,
offset = audioPage.offset,
limit = audioPage.fetchLimit
)
val paidAudioContentCount = queryPort.countPaidAudioContents(
creatorId = creatorId,
themeId = resolvedThemeId,
now = now,
canViewAdultContent = canViewAdultContent
)
val purchasedAudioContentCount = queryPort.countPurchasedAudioContents(
creatorId = creatorId,
viewerId = viewerId,
themeId = resolvedThemeId,
now = now,
canViewAdultContent = canViewAdultContent
)
return CreatorChannelAudioTab(
audioContentCount = queryPort.countAudioContents(
creatorId = creatorId,
themeId = resolvedThemeId,
now = now,
canViewAdultContent = canViewAdultContent
),
paidAudioContentCount = paidAudioContentCount,
purchasedAudioContentCount = purchasedAudioContentCount,
purchasedAudioContentRate = queryPolicy.purchaseRate(paidAudioContentCount, purchasedAudioContentCount),
themes = queryPort.findAudioThemes(locale).map { it.toDomain() },
audioContents = queryPolicy.limitItems(fetchedContents, audioPage).map { it.toDomain() },
sort = resolvedSort,
themeId = resolvedThemeId,
page = audioPage,
hasNext = queryPolicy.hasNext(fetchedContents, audioPage)
)
}
private fun validateCreatorRole(creator: CreatorChannelAudioCreatorRecord) {
when (creator.role) {
MemberRole.CREATOR -> return
else -> throw SodaException(messageKey = "member.validation.creator_not_found")
}
}
private fun CreatorChannelAudioThemeRecord.toDomain() = CreatorChannelAudioTheme(
themeId = themeId,
themeName = themeName
)
private fun CreatorChannelAudioContentRecord.toDomain() = CreatorChannelAudioContent(
audioContentId = audioContentId,
title = title,
duration = duration,
imageUrl = imagePath.toCdnUrl(cloudFrontHost),
price = price,
isAdult = isAdult,
isPointAvailable = isPointAvailable,
isFirstContent = isFirstContent,
seriesName = seriesName,
isOriginalSeries = isOriginalSeries,
isOwned = isOwned,
isRented = isRented
)
}

View File

@@ -0,0 +1,43 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio.domain
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
import org.springframework.stereotype.Component
@Component
class CreatorChannelAudioQueryPolicy {
fun resolveSort(sort: String?): ContentSort {
return runCatching { ContentSort.valueOf(sort ?: ContentSort.LATEST.name) }
.getOrDefault(ContentSort.LATEST)
}
fun createPage(page: Int?, size: Int?): CreatorChannelPage {
return CreatorChannelPage(
page = page?.coerceAtLeast(MIN_PAGE) ?: DEFAULT_PAGE,
size = size?.coerceIn(MIN_PAGE_SIZE, MAX_PAGE_SIZE) ?: DEFAULT_PAGE_SIZE
)
}
fun <T> limitItems(fetched: List<T>, page: CreatorChannelPage): List<T> {
return fetched.take(page.size)
}
fun hasNext(fetched: List<*>, page: CreatorChannelPage): Boolean {
return fetched.size > page.size
}
fun purchaseRate(paidAudioContentCount: Int, purchasedAudioContentCount: Int): Double {
if (paidAudioContentCount == 0) {
return 0.0
}
return purchasedAudioContentCount.toDouble() / paidAudioContentCount * 100
}
companion object {
private const val DEFAULT_PAGE = 0
private const val DEFAULT_PAGE_SIZE = 20
private const val MIN_PAGE = 0
private const val MIN_PAGE_SIZE = 20
private const val MAX_PAGE_SIZE = 50
}
}

View File

@@ -0,0 +1,23 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio.domain
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
data class CreatorChannelAudioTab(
val audioContentCount: Int,
val paidAudioContentCount: Int,
val purchasedAudioContentCount: Int,
val purchasedAudioContentRate: Double,
val themes: List<CreatorChannelAudioTheme>,
val audioContents: List<CreatorChannelAudioContent>,
val sort: ContentSort,
val themeId: Long?,
val page: CreatorChannelPage,
val hasNext: Boolean
)
data class CreatorChannelAudioTheme(
val themeId: Long,
val themeName: String
)

View File

@@ -0,0 +1,75 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import java.time.LocalDateTime
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 countAudioContents(
creatorId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int
fun countPaidAudioContents(
creatorId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int
fun countPurchasedAudioContents(
creatorId: Long,
viewerId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int
fun findAudioContents(
creatorId: Long,
viewerId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean,
sort: ContentSort,
locale: String,
offset: Long,
limit: Int
): List<CreatorChannelAudioContentRecord>
}
data class CreatorChannelAudioCreatorRecord(
val creatorId: Long,
val role: MemberRole,
val nickname: String
)
data class CreatorChannelAudioThemeRecord(
val themeId: Long,
val themeName: String
)
data class CreatorChannelAudioContentRecord(
val audioContentId: Long,
val title: String,
val duration: String?,
val imagePath: String?,
val price: Int,
val isAdult: Boolean,
val isPointAvailable: Boolean,
val isFirstContent: Boolean,
val seriesName: String?,
val isOriginalSeries: Boolean?,
val isOwned: Boolean,
val isRented: Boolean
)

View File

@@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.v2.creator.channel.common.domain
data class CreatorChannelAudioContent(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
val isAdult: Boolean,
val isPointAvailable: Boolean,
val isFirstContent: Boolean,
val seriesName: String?,
val isOriginalSeries: Boolean?,
val isOwned: Boolean,
val isRented: Boolean
)

View File

@@ -572,7 +572,6 @@ class DefaultCreatorChannelHomeQueryRepository(
isAdult = get(audioContent.isAdult)!!,
isPointAvailable = get(audioContent.isPointAvailable)!!,
isFirstContent = firstContentId == audioContentId,
publishedAt = get(audioContent.releaseDate)!!,
seriesName = seriesSummary?.title,
isOriginalSeries = seriesSummary?.isOriginal,
isOwned = orderState?.isOwned ?: false,

View File

@@ -8,8 +8,9 @@ import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCreator
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelDonation
@@ -152,7 +153,7 @@ class CreatorChannelHomeQueryService(
creatorId = creatorId,
characterId = characterId,
nickname = nickname,
profileImageUrl = profileImagePath.toCdnUrl() ?: defaultProfileImageUrl(),
profileImageUrl = profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
followerCount = followerCount,
isAiChatAvailable = isAiChatAvailable,
isDmAvailable = isDmAvailable,
@@ -163,7 +164,7 @@ class CreatorChannelHomeQueryService(
private fun CreatorChannelLiveRecord.toDomain() = CreatorChannelLive(
liveId = liveId,
title = title,
coverImageUrl = coverImagePath.toCdnUrl(),
coverImageUrl = coverImagePath.toCdnUrl(cloudFrontHost),
beginDateTime = beginDateTime,
price = price,
isAdult = isAdult
@@ -173,12 +174,11 @@ class CreatorChannelHomeQueryService(
audioContentId = audioContentId,
title = title,
duration = duration,
imageUrl = imagePath.toCdnUrl(),
imageUrl = imagePath.toCdnUrl(cloudFrontHost),
price = price,
isAdult = isAdult,
isPointAvailable = isPointAvailable,
isFirstContent = isFirstContent,
publishedAt = publishedAt,
seriesName = seriesName,
isOriginalSeries = isOriginalSeries,
isOwned = isOwned,
@@ -187,7 +187,7 @@ class CreatorChannelHomeQueryService(
private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation(
nickname = nickname,
profileImageUrl = profileImagePath.toCdnUrl() ?: defaultProfileImageUrl(),
profileImageUrl = profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
can = can,
message = message,
createdAt = createdAt
@@ -204,7 +204,7 @@ class CreatorChannelHomeQueryService(
private fun CreatorChannelSeriesRecord.toDomain() = CreatorChannelSeries(
seriesId = seriesId,
title = title,
coverImageUrl = coverImagePath.toCdnUrl().orEmpty(),
coverImageUrl = coverImagePath.toCdnUrl(cloudFrontHost).orEmpty(),
numberOfContent = numberOfContent,
isNew = isNew,
isOriginal = isOriginal
@@ -214,9 +214,9 @@ class CreatorChannelHomeQueryService(
postId = postId,
creatorId = creatorId,
creatorNickname = creatorNickname,
creatorProfileUrl = creatorProfilePath.toCdnUrl() ?: defaultProfileImageUrl(),
imageUrl = imagePath.toCdnUrl(),
audioUrl = audioPath.toCdnUrl(),
creatorProfileUrl = creatorProfilePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
imageUrl = imagePath.toCdnUrl(cloudFrontHost),
audioUrl = audioPath.toCdnUrl(cloudFrontHost),
content = content,
price = price,
date = date,
@@ -234,7 +234,7 @@ class CreatorChannelHomeQueryService(
fanTalkId = fanTalkId,
memberId = memberId,
nickname = nickname,
profileImageUrl = profileImagePath.toCdnUrl() ?: defaultProfileImageUrl(),
profileImageUrl = profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
content = content,
languageCode = languageCode,
createdAt = createdAt
@@ -258,11 +258,5 @@ class CreatorChannelHomeQueryService(
kakaoOpenChatUrl = kakaoOpenChatUrl
)
private fun String?.toCdnUrl(): String? {
if (isNullOrBlank()) return null
if (startsWith("https://") || startsWith("http://")) return this
return "$cloudFrontHost/$this"
}
private fun defaultProfileImageUrl(): String = "$cloudFrontHost/profile/default-profile.png"
}

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.v2.creator.channel.home.domain
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
import java.time.LocalDateTime
data class CreatorChannelHome(
@@ -40,22 +41,6 @@ data class CreatorChannelLive(
val isAdult: Boolean
)
data class CreatorChannelAudioContent(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
val isAdult: Boolean,
val isPointAvailable: Boolean,
val isFirstContent: Boolean,
val publishedAt: LocalDateTime,
val seriesName: String?,
val isOriginalSeries: Boolean?,
val isOwned: Boolean,
val isRented: Boolean
)
data class CreatorChannelDonation(
val nickname: String,
val profileImageUrl: String,

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.v2.creator.channel.home.domain
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
import org.springframework.stereotype.Component
import java.time.LocalDateTime
@@ -25,16 +26,6 @@ class CreatorChannelHomeQueryPolicy {
return audioContents.filter { it.audioContentId != latestAudioContentId }
}
fun markFirstAudioContent(audioContents: List<CreatorChannelAudioContent>): List<CreatorChannelAudioContent> {
val firstAudioContentId = audioContents
.minWithOrNull(compareBy<CreatorChannelAudioContent> { it.publishedAt }.thenBy { it.audioContentId })
?.audioContentId
return audioContents.map { audioContent ->
audioContent.copy(isFirstContent = audioContent.audioContentId == firstAudioContentId)
}
}
private fun CreatorActivityType.scheduleOrder(): Int {
return if (this == CreatorActivityType.LIVE) 0 else 1
}

View File

@@ -109,7 +109,6 @@ data class CreatorChannelAudioContentRecord(
val isAdult: Boolean,
val isPointAvailable: Boolean,
val isFirstContent: Boolean,
val publishedAt: LocalDateTime,
val seriesName: String?,
val isOriginalSeries: Boolean?,
val isOwned: Boolean,

View File

@@ -261,7 +261,6 @@ class DefaultCreatorChannelLiveQueryRepository(
isAdult = get(audioContent.isAdult)!!,
isPointAvailable = get(audioContent.isPointAvailable)!!,
isFirstContent = firstContentId == audioContentId,
publishedAt = get(audioContent.releaseDate)!!,
seriesName = seriesSummary?.title,
isOriginalSeries = seriesSummary?.isOriginal,
isOwned = orderState?.isOwned ?: false,

View File

@@ -9,7 +9,8 @@ import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLive
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveTab
@@ -107,7 +108,7 @@ class CreatorChannelLiveQueryService(
private fun CreatorChannelLiveRecord.toDomain() = CreatorChannelLive(
liveId = liveId,
title = title,
coverImageUrl = coverImagePath.toCdnUrl(),
coverImageUrl = coverImagePath.toCdnUrl(cloudFrontHost),
beginDateTime = beginDateTime,
price = price,
isAdult = isAdult
@@ -117,21 +118,14 @@ class CreatorChannelLiveQueryService(
audioContentId = audioContentId,
title = title,
duration = duration,
imageUrl = imagePath.toCdnUrl(),
imageUrl = imagePath.toCdnUrl(cloudFrontHost),
price = price,
isAdult = isAdult,
isPointAvailable = isPointAvailable,
isFirstContent = isFirstContent,
publishedAt = publishedAt,
seriesName = seriesName,
isOriginalSeries = isOriginalSeries,
isOwned = isOwned,
isRented = isRented
)
private fun String?.toCdnUrl(): String? {
if (isNullOrBlank()) return null
if (startsWith("https://") || startsWith("http://")) return this
return "$cloudFrontHost/$this"
}
}

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.v2.creator.channel.live.domain
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
import java.time.LocalDateTime
data class CreatorChannelLiveTab(
@@ -20,19 +21,3 @@ data class CreatorChannelLive(
val price: Int,
val isAdult: Boolean
)
data class CreatorChannelAudioContent(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
val isAdult: Boolean,
val isPointAvailable: Boolean,
val isFirstContent: Boolean,
val publishedAt: LocalDateTime,
val seriesName: String?,
val isOriginalSeries: Boolean?,
val isOwned: Boolean,
val isRented: Boolean
)

View File

@@ -60,7 +60,6 @@ data class CreatorChannelAudioContentRecord(
val isAdult: Boolean,
val isPointAvailable: Boolean,
val isFirstContent: Boolean,
val publishedAt: LocalDateTime,
val seriesName: String?,
val isOriginalSeries: Boolean?,
val isOwned: Boolean,

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.ranking.application
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingItem
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicy
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicy
@@ -159,14 +160,10 @@ class CreatorRankingQueryService(
isNew = false,
creatorId = creatorId,
nickname = nickname,
profileImageUrl = profileImageUrl.toCdnUrl()
profileImageUrl = profileImageUrl.toCdnUrl(cloudFrontHost)
)
}
private fun String?.toCdnUrl(): String? {
return if (isNullOrBlank()) null else "$cloudFrontHost/$this"
}
private fun CreatorRankingSnapshotCandidate.toSnapshotRecord(utcRange: CreatorRankingUtcRange): CreatorRankingSnapshotRecord {
val calculatedContentLiveScore = scorePolicy.calculateContentLiveScore(
liveCanAmount = liveCanAmount,

View File

@@ -0,0 +1,218 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.`in`.web
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacade
import kr.co.vividnext.sodalive.v2.api.creator.channel.audio.dto.CreatorChannelAudioTabResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.audio.dto.CreatorChannelAudioThemeResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Import
import org.springframework.http.HttpStatus
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.HttpStatusEntryPoint
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import java.time.LocalDateTime
import javax.servlet.http.HttpServletResponse
@WebMvcTest(CreatorChannelAudioController::class)
@Import(CreatorChannelAudioControllerTest.TestSecurityConfig::class)
class CreatorChannelAudioControllerTest @Autowired constructor(
private val mockMvc: MockMvc
) {
@MockBean
private lateinit var facade: CreatorChannelAudioFacade
@MockBean
private lateinit var countryContext: CountryContext
@MockBean
private lateinit var langContext: LangContext
@MockBean
private lateinit var sodaMessageSource: SodaMessageSource
@TestConfiguration
class TestSecurityConfig {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
return http
.csrf().disable()
.authorizeRequests()
.anyRequest().authenticated()
.and()
.exceptionHandling()
.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
.accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) }
.and()
.build()
}
}
@Test
@DisplayName("크리에이터 채널 오디오 탭 조회는 비회원 요청을 거부한다")
fun shouldRejectAnonymousCreatorChannelAudioRequest() {
mockMvc.perform(
get("/api/v2/creator-channels/1/audio")
.with(anonymous())
)
.andExpect(status().isUnauthorized)
}
@Test
@DisplayName("크리에이터 채널 오디오 탭 조회는 기본 요청값을 facade에 전달하고 성공 응답을 반환한다")
fun shouldReturnCreatorChannelAudioTabForAuthenticatedMember() {
val viewer = createMember(id = 10L)
Mockito.doReturn(createResponse()).`when`(facade).getAudioTab(
eqValue(1L),
eqValue(viewer),
eqValue(null),
eqValue(null),
eqValue(null),
eqValue(null),
anyValue(LocalDateTime.now())
)
mockMvc.perform(
get("/api/v2/creator-channels/1/audio")
.with(user(MemberAdapter(viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.audioContentCount").value(3))
.andExpect(jsonPath("$.data.paidAudioContentCount").value(2))
.andExpect(jsonPath("$.data.purchasedAudioContentCount").value(1))
.andExpect(jsonPath("$.data.purchasedAudioContentRate").value(50.0))
.andExpect(jsonPath("$.data.themes").isArray)
.andExpect(jsonPath("$.data.audioContents").isArray)
.andExpect(jsonPath("$.data.sort").value("LATEST"))
.andExpect(jsonPath("$.data.themeId").doesNotExist())
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
.andExpect(jsonPath("$.data.audioContents[0].isOwned").value(true))
.andExpect(jsonPath("$.data.audioContents[0].isRented").value(false))
Mockito.verify(facade).getAudioTab(
eqValue(1L),
eqValue(viewer),
eqValue(null),
eqValue(null),
eqValue(null),
eqValue(null),
anyValue(LocalDateTime.now())
)
}
@Test
@DisplayName("크리에이터 채널 오디오 탭 조회는 잘못된 query parameter도 controller에서 거부하지 않고 facade에 전달한다")
fun shouldPassInvalidQueryParametersToFacade() {
val viewer = createMember(id = 10L)
Mockito.doReturn(createResponse(themeId = null, page = 0, size = 50)).`when`(facade).getAudioTab(
eqValue(1L),
eqValue(viewer),
eqValue("INVALID"),
eqValue(999L),
eqValue(-1),
eqValue(100),
anyValue(LocalDateTime.now())
)
mockMvc.perform(
get("/api/v2/creator-channels/1/audio")
.param("sort", "INVALID")
.param("page", "-1")
.param("size", "100")
.param("themeId", "999")
.with(user(MemberAdapter(viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.sort").value("LATEST"))
.andExpect(jsonPath("$.data.themeId").doesNotExist())
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(50))
Mockito.verify(facade).getAudioTab(
eqValue(1L),
eqValue(viewer),
eqValue("INVALID"),
eqValue(999L),
eqValue(-1),
eqValue(100),
anyValue(LocalDateTime.now())
)
}
private fun <T> eqValue(value: T): T {
return Mockito.eq(value) ?: value
}
private fun <T> anyValue(fallback: T): T {
return Mockito.any<T>() ?: fallback
}
private fun createMember(id: Long): Member {
return Member(
email = "viewer$id@test.com",
password = "password",
nickname = "viewer$id",
role = MemberRole.USER
).apply {
this.id = id
}
}
private fun createResponse(
themeId: Long? = null,
page: Int = 0,
size: Int = 20
): CreatorChannelAudioTabResponse {
return CreatorChannelAudioTabResponse(
audioContentCount = 3,
paidAudioContentCount = 2,
purchasedAudioContentCount = 1,
purchasedAudioContentRate = 50.0,
themes = listOf(CreatorChannelAudioThemeResponse(themeId = 10L, themeName = "theme")),
audioContents = listOf(
CreatorChannelAudioContentResponse(
audioContentId = 201L,
title = "audio",
duration = "00:10:00",
imageUrl = "audio.png",
price = 30,
isAdult = false,
isPointAvailable = true,
isFirstContent = true,
seriesName = "series",
isOriginalSeries = true,
isOwned = true,
isRented = false
)
),
sort = ContentSort.LATEST,
themeId = themeId,
page = page,
size = size,
hasNext = false
)
}
}

View File

@@ -0,0 +1,148 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.`in`.web
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.order.Order
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime
import javax.persistence.EntityManager
@SpringBootTest(
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:creator-channel-live-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
]
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class CreatorChannelAudioEndToEndTest @Autowired constructor(
private val mockMvc: MockMvc,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
) {
@Test
@DisplayName("오디오 탭 API는 controller-service-repository를 거쳐 fallback 적용 응답을 반환한다")
fun shouldReturnAudioTabWithFallbacksThroughControllerServiceAndRepository() {
val fixture = createFixture()
mockMvc.perform(
get("/api/v2/creator-channels/${fixture.creatorId}/audio")
.param("sort", "INVALID")
.param("page", "-1")
.param("size", "100")
.param("themeId", "999")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.audioContentCount").value(1))
.andExpect(jsonPath("$.data.paidAudioContentCount").value(1))
.andExpect(jsonPath("$.data.purchasedAudioContentCount").value(1))
.andExpect(jsonPath("$.data.purchasedAudioContentRate").value(100.0))
.andExpect(jsonPath("$.data.themes").isArray)
.andExpect(jsonPath("$.data.audioContents[0].audioContentId").value(fixture.audioContentId))
.andExpect(jsonPath("$.data.audioContents[0].imageUrl").value("https://cdn.test/audio-e2e.png"))
.andExpect(jsonPath("$.data.audioContents[0].isOwned").value(true))
.andExpect(jsonPath("$.data.audioContents[0].isRented").value(false))
.andExpect(jsonPath("$.data.sort").value("LATEST"))
.andExpect(jsonPath("$.data.themeId").doesNotExist())
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(50))
.andExpect(jsonPath("$.data.hasNext").value(false))
}
private fun createFixture(): Fixture {
return transactionTemplate.execute {
val now = LocalDateTime.now()
val viewer = saveMember("audio-e2e-viewer", MemberRole.USER)
val creator = saveMember("audio-e2e-creator", MemberRole.CREATOR)
val theme = saveTheme("수면")
val content = saveAudioContent(creator, now.minusHours(1), theme)
saveOrder(viewer, creator, content, OrderType.KEEP)
entityManager.flush()
Fixture(
viewer = viewer,
creatorId = creator.id!!,
audioContentId = content.id!!
)
}!!
}
private fun saveMember(nickname: String, role: MemberRole): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
role = role
)
entityManager.persist(member)
return member
}
private fun saveTheme(name: String): AudioContentTheme {
val theme = AudioContentTheme(theme = name, image = "$name.png", isActive = true)
entityManager.persist(theme)
return theme
}
private fun saveAudioContent(
creator: Member,
releaseDate: LocalDateTime,
theme: AudioContentTheme
): AudioContent {
val content = AudioContent(
title = "audio-e2e",
detail = "detail",
languageCode = "ko",
releaseDate = releaseDate,
isAdult = false,
price = 100,
isPointAvailable = true
)
content.member = creator
content.theme = theme
content.isActive = true
content.coverImage = "audio-e2e.png"
content.duration = "00:10:00"
entityManager.persist(content)
return content
}
private fun saveOrder(
member: Member,
creator: Member,
content: AudioContent,
type: OrderType
): Order {
val order = Order(type = type, isActive = true)
order.member = member
order.creator = creator
order.audioContent = content
entityManager.persist(order)
return order
}
private data class Fixture(
val viewer: Member,
val creatorId: Long,
val audioContentId: Long
)
}

View File

@@ -0,0 +1,110 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryService
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTab
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTheme
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import java.time.LocalDateTime
class CreatorChannelAudioFacadeTest {
@Test
@DisplayName("오디오 탭 facade는 query service 결과를 공개 응답 DTO로 변환한다")
fun shouldMapAudioTabQueryResultToPublicResponse() {
val service = Mockito.mock(CreatorChannelAudioQueryService::class.java)
val facade = CreatorChannelAudioFacade(service)
val viewer = createMember(id = 10L)
val now = LocalDateTime.of(2026, 6, 19, 10, 0)
Mockito.doReturn(createTab()).`when`(service).getAudioTab(
creatorId = 1L,
viewer = viewer,
sort = "OWNED",
themeId = 10L,
page = 0,
size = 20,
now = now
)
val response = facade.getAudioTab(
creatorId = 1L,
viewer = viewer,
sort = "OWNED",
themeId = 10L,
page = 0,
size = 20,
now = now
)
assertEquals(3, response.audioContentCount)
assertEquals(2, response.paidAudioContentCount)
assertEquals(1, response.purchasedAudioContentCount)
assertEquals(50.0, response.purchasedAudioContentRate)
assertEquals(10L, response.themes.first().themeId)
assertEquals("theme", response.themes.first().themeName)
assertEquals(201L, response.audioContents.first().audioContentId)
assertTrue(response.audioContents.first().isOwned)
assertFalse(response.audioContents.first().isRented)
assertEquals(ContentSort.OWNED, response.sort)
assertEquals(10L, response.themeId)
assertEquals(0, response.page)
assertEquals(20, response.size)
assertTrue(response.hasNext)
val json = ObjectMapper().registerModule(KotlinModule.Builder().build()).readTree(
ObjectMapper().registerModule(KotlinModule.Builder().build()).writeValueAsString(response)
)
assertTrue(json["hasNext"].asBoolean())
assertTrue(json["audioContents"][0]["isOwned"].asBoolean())
assertFalse(json["audioContents"][0]["isRented"].asBoolean())
}
private fun createMember(id: Long): Member {
return Member(
email = "viewer$id@test.com",
password = "password",
nickname = "viewer$id",
role = MemberRole.USER
).apply { this.id = id }
}
private fun createTab(): CreatorChannelAudioTab {
return CreatorChannelAudioTab(
audioContentCount = 3,
paidAudioContentCount = 2,
purchasedAudioContentCount = 1,
purchasedAudioContentRate = 50.0,
themes = listOf(CreatorChannelAudioTheme(themeId = 10L, themeName = "theme")),
audioContents = listOf(
CreatorChannelAudioContent(
audioContentId = 201L,
title = "audio",
duration = "00:10:00",
imageUrl = "audio.png",
price = 30,
isAdult = false,
isPointAvailable = true,
isFirstContent = true,
seriesName = "series",
isOriginalSeries = true,
isOwned = true,
isRented = false
)
),
sort = ContentSort.OWNED,
themeId = 10L,
page = CreatorChannelPage(page = 0, size = 20),
hasNext = true
)
}
}

View File

@@ -9,8 +9,8 @@ import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacade
import kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto.CreatorChannelHomeResponse
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCreator
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelDonation
@@ -198,7 +198,6 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
isAdult = true,
isPointAvailable = true,
isFirstContent = true,
publishedAt = LocalDateTime.of(2026, 6, 11, 1, 0),
seriesName = "series",
isOriginalSeries = true,
isOwned = true,
@@ -233,7 +232,6 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
isAdult = false,
isPointAvailable = false,
isFirstContent = false,
publishedAt = LocalDateTime.of(2026, 6, 10, 1, 0),
seriesName = null,
isOriginalSeries = null,
isOwned = false,

View File

@@ -3,9 +3,9 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.home.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryService
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCreator
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelDonation
@@ -128,7 +128,6 @@ class CreatorChannelHomeFacadeTest {
isAdult = true,
isPointAvailable = true,
isFirstContent = true,
publishedAt = LocalDateTime.of(2026, 6, 11, 1, 0),
seriesName = "series",
isOriginalSeries = true,
isOwned = true,
@@ -163,7 +162,6 @@ class CreatorChannelHomeFacadeTest {
isAdult = false,
isPointAvailable = false,
isFirstContent = false,
publishedAt = LocalDateTime.of(2026, 6, 10, 1, 0),
seriesName = null,
isOriginalSeries = null,
isOwned = false,

View File

@@ -6,8 +6,8 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.live.application.CreatorChannelLiveFacade
import kr.co.vividnext.sodalive.v2.api.creator.channel.live.dto.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.live.dto.CreatorChannelLiveResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.live.dto.CreatorChannelLiveTabResponse
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort

View File

@@ -3,8 +3,8 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.live.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryService
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLive
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveTab
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
@@ -86,7 +86,6 @@ class CreatorChannelLiveFacadeTest {
isAdult = false,
isPointAvailable = true,
isFirstContent = true,
publishedAt = LocalDateTime.of(2026, 6, 16, 1, 0),
seriesName = "series",
isOriginalSeries = true,
isOwned = true,

View File

@@ -0,0 +1,39 @@
package kr.co.vividnext.sodalive.v2.common.domain
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
class CdnUrlExtensionsTest {
private val cloudFrontHost = "https://cdn.test"
@Test
@DisplayName("CDN URL 변환은 null과 blank를 null로 반환한다")
fun shouldReturnNullForNullOrBlankPath() {
assertEquals(null, null.toCdnUrl(cloudFrontHost))
assertEquals(null, "".toCdnUrl(cloudFrontHost))
assertEquals(null, " ".toCdnUrl(cloudFrontHost))
}
@Test
@DisplayName("CDN URL 변환은 절대 URL을 그대로 반환한다")
fun shouldKeepAbsoluteUrl() {
assertEquals(
"https://image.test/profile.png",
"https://image.test/profile.png".toCdnUrl(cloudFrontHost)
)
assertEquals(
"http://image.test/profile.png",
"http://image.test/profile.png".toCdnUrl(cloudFrontHost)
)
}
@Test
@DisplayName("CDN URL 변환은 상대 path 앞에 CloudFront host를 붙인다")
fun shouldPrependCloudFrontHostToRelativePath() {
assertEquals(
"https://cdn.test/profile/default-profile.png",
"profile/default-profile.png".toCdnUrl(cloudFrontHost)
)
}
}

View File

@@ -0,0 +1,391 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.order.Order
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslation
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.time.LocalDateTime
import javax.persistence.EntityManager
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
]
)
@Import(QueryDslConfig::class)
class DefaultCreatorChannelAudioQueryRepositoryTest @Autowired constructor(
private val entityManager: EntityManager,
queryFactory: JPAQueryFactory
) {
private val repository = DefaultCreatorChannelAudioQueryRepository(queryFactory)
@Test
@DisplayName("크리에이터, 차단 관계, 활성 테마, 테마 번역 fallback을 조회한다")
fun shouldFindCreatorBlockAndThemesWithTranslationFallback() {
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 inactiveTheme = saveTheme("비활성", isActive = false)
saveThemeTranslation(translatedTheme, "en", "Sleep")
saveThemeTranslation(blankTranslatedTheme, "en", " ")
saveThemeTranslation(inactiveTheme, "en", "Inactive")
saveBlock(creator, viewer)
flushAndClear()
val record = repository.findCreator(creator.id!!, viewer.id!!)
val themes = repository.findAudioThemes("en")
assertEquals(creator.id, record!!.creatorId)
assertEquals(MemberRole.CREATOR, record.role)
assertTrue(repository.existsBlockedBetween(viewer.id!!, creator.id!!))
assertEquals(translatedTheme.id, repository.findActiveThemeId(translatedTheme.id!!))
assertEquals(null, repository.findActiveThemeId(inactiveTheme.id!!))
assertEquals(listOf(blankTranslatedTheme.id, translatedTheme.id), themes.map { it.themeId })
assertEquals(listOf("집중", "Sleep"), themes.map { it.themeName })
}
@Test
@DisplayName("오디오 콘텐츠 count는 공개 조건, 성인 노출 정책, 활성 themeId 필터를 공유한다")
fun shouldCountPublicAudioContentsWithFilters() {
val now = LocalDateTime.of(2026, 6, 19, 12, 0)
val creator = saveMember("count-creator", MemberRole.CREATOR)
val theme = saveTheme("수면")
val otherTheme = saveTheme("집중")
val inactiveTheme = saveTheme("비활성", isActive = false)
saveAudioContent(creator, now.minusDays(2), false, theme, price = 0)
saveAudioContent(creator, now.minusDays(1), false, theme, price = 100)
saveAudioContent(creator, now.minusHours(1), true, theme, price = 200)
saveAudioContent(creator, now.minusHours(2), false, otherTheme, price = 0)
saveAudioContent(creator, now.plusHours(1), false, theme, price = 100)
saveAudioContent(creator, now.minusHours(3), false, theme, price = 100).isActive = false
saveAudioContent(creator, now.minusHours(4), false, inactiveTheme, price = 100)
saveAudioContent(creator, now.minusHours(5), false, theme, price = 100).duration = null
saveAudioContent(creator, now.minusHours(6), false, theme, price = 100).releaseDate = null
flushAndClear()
assertEquals(3, repository.countAudioContents(creator.id!!, null, now, canViewAdultContent = false))
assertEquals(4, repository.countAudioContents(creator.id!!, null, now, canViewAdultContent = true))
assertEquals(2, repository.countAudioContents(creator.id!!, theme.id, now, canViewAdultContent = false))
assertEquals(1, repository.countPaidAudioContents(creator.id!!, null, now, canViewAdultContent = false))
assertEquals(2, repository.countPaidAudioContents(creator.id!!, theme.id, now, canViewAdultContent = true))
}
@Test
@DisplayName("구매 count는 유료 콘텐츠의 활성 KEEP 또는 유효 RENTAL 주문을 distinct로 계산한다")
fun shouldCountPurchasedPaidAudioContentsOnly() {
val now = LocalDateTime.of(2026, 6, 19, 12, 0)
val viewer = saveMember("purchase-viewer", MemberRole.USER)
val creator = saveMember("purchase-creator", MemberRole.CREATOR)
val theme = saveTheme("수면")
val keep = saveAudioContent(creator, now.minusDays(6), false, theme, price = 100)
val rental = saveAudioContent(creator, now.minusDays(5), false, theme, price = 100)
val duplicate = saveAudioContent(creator, now.minusDays(4), false, theme, price = 100)
val expiredRental = saveAudioContent(creator, now.minusDays(3), false, theme, price = 100)
val inactiveOrder = saveAudioContent(creator, now.minusDays(2), false, theme, price = 100)
val free = saveAudioContent(creator, now.minusDays(1), false, theme, price = 0)
saveOrder(viewer, creator, keep, OrderType.KEEP)
saveOrder(viewer, creator, rental, OrderType.RENTAL, endDate = now.plusDays(1))
saveOrder(viewer, creator, duplicate, OrderType.KEEP)
saveOrder(viewer, creator, duplicate, OrderType.RENTAL, endDate = now.plusDays(1))
saveOrder(viewer, creator, expiredRental, OrderType.RENTAL, endDate = now.minusDays(1))
saveOrder(viewer, creator, inactiveOrder, OrderType.KEEP, isActive = false)
saveOrder(viewer, creator, free, OrderType.KEEP)
flushAndClear()
val count = repository.countPurchasedAudioContents(creator.id!!, viewer.id!!, null, now, false)
assertEquals(3, count)
}
@Test
@DisplayName("목록은 limit 그대로 조회하고 최신순, 시리즈 번역, 주문 상태, 전체 첫 콘텐츠를 반환한다")
fun shouldFindAudioContentsWithLatestSortAndEnrichedFields() {
val now = LocalDateTime.of(2026, 6, 19, 12, 0)
val viewer = saveMember("latest-viewer", MemberRole.USER)
val creator = saveMember("latest-creator", MemberRole.CREATOR)
val theme = saveTheme("수면")
val otherTheme = saveTheme("집중")
val firstContent = saveAudioContent(creator, now.minusDays(30), false, otherTheme, price = 0)
val oldSelected = saveAudioContent(creator, now.minusDays(3), false, theme, price = 100)
val sameDateLowPrice = saveAudioContent(creator, now.minusDays(1), false, theme, price = 100)
val sameDateHighPrice = saveAudioContent(creator, now.minusDays(1), false, theme, price = 300, isPointAvailable = true)
val series = saveSeries("original-series", creator, isOriginal = true)
saveSeriesContent(series, sameDateHighPrice)
saveSeriesTranslation(series, "en", "Translated Series")
saveOrder(viewer, creator, sameDateHighPrice, OrderType.KEEP)
saveOrder(viewer, creator, sameDateHighPrice, OrderType.RENTAL, endDate = now.plusDays(1))
flushAndClear()
val firstPage = repository.findAudioContents(
creator.id!!,
viewer.id!!,
theme.id,
now,
false,
ContentSort.LATEST,
"en",
offset = 0,
limit = 2
)
val allThemes = repository.findAudioContents(
creator.id!!,
viewer.id!!,
null,
now,
false,
ContentSort.LATEST,
"en",
offset = 0,
limit = 10
)
assertEquals(2, firstPage.size)
assertEquals(listOf(sameDateHighPrice.id, sameDateLowPrice.id), firstPage.map { it.audioContentId })
assertEquals("Translated Series", firstPage.first().seriesName)
assertEquals(true, firstPage.first().isOriginalSeries)
assertTrue(firstPage.first().isOwned)
assertTrue(firstPage.first().isRented)
assertTrue(firstPage.first().isPointAvailable)
assertEquals(firstContent.id, allThemes.last().audioContentId)
assertTrue(allThemes.last().isFirstContent)
assertFalse(firstPage.any { it.audioContentId == oldSelected.id && it.isFirstContent })
}
@Test
@DisplayName("목록은 가격순과 인기순 can 매출 정렬을 적용한다")
fun shouldSortAudioContentsByPriceAndPopularRevenue() {
val now = LocalDateTime.of(2026, 6, 19, 12, 0)
val viewer = saveMember("sort-viewer", MemberRole.USER)
val creator = saveMember("sort-creator", MemberRole.CREATOR)
val theme = saveTheme("수면")
val low = saveAudioContent(creator, now.minusDays(3), false, theme, price = 100)
val high = saveAudioContent(creator, now.minusDays(2), false, theme, price = 300)
val noRevenue = saveAudioContent(creator, now.minusDays(1), false, theme, price = 200)
saveOrder(viewer, creator, low, OrderType.KEEP, can = 500, point = 9000)
saveOrder(viewer, creator, high, OrderType.KEEP, can = 100, point = 9999)
saveOrder(viewer, creator, noRevenue, OrderType.KEEP, isActive = false, can = 1000)
flushAndClear()
val highRecords = repository.findAudioContents(
creator.id!!,
viewer.id!!,
null,
now,
false,
ContentSort.PRICE_HIGH,
"ko",
0,
20
)
val lowRecords = repository.findAudioContents(
creator.id!!,
viewer.id!!,
null,
now,
false,
ContentSort.PRICE_LOW,
"ko",
0,
20
)
val popularRecords = repository.findAudioContents(
creator.id!!,
viewer.id!!,
null,
now,
false,
ContentSort.POPULAR,
"ko",
0,
20
)
assertEquals(listOf(high.id, noRevenue.id, low.id), highRecords.map { it.audioContentId })
assertEquals(listOf(low.id, noRevenue.id, high.id), lowRecords.map { it.audioContentId })
assertEquals(listOf(low.id, high.id, noRevenue.id), popularRecords.map { it.audioContentId })
}
@Test
@DisplayName("소장순은 KEEP 또는 유효 RENTAL 콘텐츠를 먼저 노출하고 시리즈명 blank 번역은 원문 fallback한다")
fun shouldSortAudioContentsByOwnedAndReturnOrderStatesWithSeriesFallback() {
val now = LocalDateTime.of(2026, 6, 19, 12, 0)
val viewer = saveMember("owned-viewer", MemberRole.USER)
val creator = saveMember("owned-creator", MemberRole.CREATOR)
val theme = saveTheme("수면")
val noOrder = saveAudioContent(creator, now.minusDays(5), false, theme, price = 100)
val expiredRental = saveAudioContent(creator, now.minusDays(4), false, theme, price = 100)
val keepAndRental = saveAudioContent(creator, now.minusDays(3), false, theme, price = 100)
val rentalOnly = saveAudioContent(creator, now.minusDays(2), false, theme, price = 100)
val keepOnly = saveAudioContent(creator, now.minusDays(1), false, theme, price = 100)
val series = saveSeries("fallback-series", creator, isOriginal = false)
saveSeriesContent(series, keepOnly)
saveSeriesTranslation(series, "en", " ")
saveOrder(viewer, creator, keepOnly, OrderType.KEEP)
saveOrder(viewer, creator, rentalOnly, OrderType.RENTAL, endDate = now.plusDays(1))
saveOrder(viewer, creator, keepAndRental, OrderType.KEEP)
saveOrder(viewer, creator, keepAndRental, OrderType.RENTAL, endDate = now.plusDays(1))
saveOrder(viewer, creator, expiredRental, OrderType.RENTAL, endDate = now.minusDays(1))
flushAndClear()
val records = repository.findAudioContents(creator.id!!, viewer.id!!, null, now, false, ContentSort.OWNED, "en", 0, 20)
assertEquals(
listOf(keepOnly.id, rentalOnly.id, keepAndRental.id, expiredRental.id, noOrder.id),
records.map { it.audioContentId }
)
assertEquals(listOf(true, false, true, false, false), records.map { it.isOwned })
assertEquals(listOf(false, true, true, false, false), records.map { it.isRented })
assertEquals("fallback-series", records.first().seriesName)
assertEquals(false, records.first().isOriginalSeries)
}
private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
profileImage = "$nickname.png",
role = role,
isActive = isActive
)
entityManager.persist(member)
return member
}
private fun saveBlock(member: Member, blockedMember: Member): BlockMember {
val block = BlockMember(isActive = true)
block.member = member
block.blockedMember = blockedMember
entityManager.persist(block)
return block
}
private fun saveTheme(name: String, isActive: Boolean = true, orders: Int = 1): AudioContentTheme {
val theme = AudioContentTheme(theme = name, image = "$name.png", isActive = isActive, orders = orders)
entityManager.persist(theme)
return theme
}
private fun saveThemeTranslation(theme: AudioContentTheme, locale: String, translatedTheme: String): ContentThemeTranslation {
val translation = ContentThemeTranslation(theme.id!!, locale, translatedTheme)
entityManager.persist(translation)
return translation
}
private fun saveAudioContent(
creator: Member,
releaseDate: LocalDateTime,
isAdult: Boolean,
theme: AudioContentTheme,
price: Int = 0,
isPointAvailable: Boolean = false
): AudioContent {
val content = AudioContent(
title = "audio-${creator.nickname}-$releaseDate",
detail = "detail",
languageCode = "ko",
releaseDate = releaseDate,
isAdult = isAdult,
price = price,
isPointAvailable = isPointAvailable
)
content.member = creator
content.theme = theme
content.isActive = true
content.coverImage = "audio.png"
content.duration = "00:10:00"
entityManager.persist(content)
return content
}
private fun saveSeries(title: String, creator: Member, isOriginal: Boolean = false): Series {
val series = Series(title = title, introduction = "introduction", languageCode = "ko", isOriginal = isOriginal)
series.member = creator
series.genre = saveSeriesGenre(title)
series.coverImage = "$title.png"
entityManager.persist(series)
return series
}
private fun saveSeriesGenre(name: String): SeriesGenre {
val genre = SeriesGenre(genre = "genre-$name", isAdult = false, isActive = true)
entityManager.persist(genre)
return genre
}
private fun saveSeriesContent(series: Series, content: AudioContent): SeriesContent {
val seriesContent = SeriesContent()
seriesContent.series = series
seriesContent.content = content
entityManager.persist(seriesContent)
return seriesContent
}
private fun saveSeriesTranslation(series: Series, locale: String, title: String): SeriesTranslation {
val translation = SeriesTranslation(
seriesId = series.id!!,
locale = locale,
renderedPayload = SeriesTranslationPayload(title = title, introduction = "", keywords = emptyList())
)
entityManager.persist(translation)
entityManager.flush()
val payload = "{\"title\":\"$title\",\"introduction\":\"\",\"keywords\":[]}"
entityManager.createNativeQuery(
"update series_translation set rendered_payload = '$payload' format json where id = :id"
)
.setParameter("id", translation.id)
.executeUpdate()
return translation
}
private fun saveOrder(
member: Member,
creator: Member,
content: AudioContent,
type: OrderType,
isActive: Boolean = true,
endDate: LocalDateTime? = null,
can: Int? = null,
point: Int = 0
): Order {
val order = Order(type = type, isActive = isActive)
order.member = member
order.creator = creator
order.audioContent = content
can?.let { order.can = it }
order.point = point
entityManager.persist(order)
if (endDate != null) {
entityManager.flush()
order.endDate = endDate
}
return order
}
private fun flushAndClear() {
entityManager.flush()
entityManager.clear()
}
}

View File

@@ -0,0 +1,263 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberProvider
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioThemeRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.ObjectProvider
import java.time.LocalDateTime
class CreatorChannelAudioQueryServiceTest {
@Test
@DisplayName("오디오 탭 서비스는 요청 fallback과 조회 컨텍스트를 port에 전달하고 탭을 조립한다")
fun shouldResolveRequestFallbacksAndAssembleAudioTab() {
val port = FakeCreatorChannelAudioQueryPort().apply {
activeThemeId = null
paidAudioContentCount = 4
purchasedAudioContentCount = 3
audioContents = (1L..51L).map { audioContentRecord(it) }
}
val service = createService(port, canViewAdultContent = false)
val viewer = createMember(id = 10L)
val now = LocalDateTime.of(2026, 6, 19, 10, 0)
val tab = service.getAudioTab(
creatorId = 1L,
viewer = viewer,
sort = "UNKNOWN",
themeId = 999L,
page = -1,
size = 100,
now = now
)
assertEquals(ContentSort.LATEST, tab.sort)
assertNull(tab.themeId)
assertEquals(0, tab.page.page)
assertEquals(50, tab.page.size)
assertEquals(0L, port.listOffset)
assertEquals(51, port.listLimit)
assertEquals(ContentSort.LATEST, port.listSort)
assertNull(port.listThemeId)
assertEquals("en", port.listLocale)
assertEquals(false, port.listCanViewAdultContent)
assertEquals(75.0, tab.purchasedAudioContentRate)
assertEquals(50, tab.audioContents.size)
assertTrue(tab.hasNext)
assertEquals("https://cdn.test/audio/1.png", tab.audioContents.first().imageUrl)
}
@Test
@DisplayName("유료 오디오 콘텐츠가 없으면 소장률은 0.0이다")
fun shouldReturnZeroPurchaseRateWhenPaidContentCountIsZero() {
val port = FakeCreatorChannelAudioQueryPort().apply {
paidAudioContentCount = 0
purchasedAudioContentCount = 3
}
val service = createService(port)
val viewer = createMember(id = 10L)
val tab = service.getAudioTab(1L, viewer, null, null, null, null, LocalDateTime.of(2026, 6, 19, 10, 0))
assertEquals(0.0, tab.purchasedAudioContentRate)
}
@Test
@DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다")
fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() {
val port = FakeCreatorChannelAudioQueryPort().apply { creator = null }
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getAudioTab(1L, viewer, null, null, null, null, LocalDateTime.of(2026, 6, 19, 10, 0))
}
assertEquals("member.validation.user_not_found", exception.messageKey)
}
@Test
@DisplayName("대상 회원 role이 CREATOR가 아니면 creator_not_found 예외를 던진다")
fun shouldThrowCreatorNotFoundWhenMemberIsNotCreator() {
val port = FakeCreatorChannelAudioQueryPort().apply { creator = creator?.copy(role = MemberRole.USER) }
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getAudioTab(1L, viewer, null, null, null, null, LocalDateTime.of(2026, 6, 19, 10, 0))
}
assertEquals("member.validation.creator_not_found", exception.messageKey)
}
@Test
@DisplayName("차단 관계가 있으면 기존 차단 메시지 예외를 던진다")
fun shouldThrowBlockedAccessWhenViewerAndTargetAreBlocked() {
val port = FakeCreatorChannelAudioQueryPort().apply { blocked = true }
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getAudioTab(1L, viewer, null, null, null, null, LocalDateTime.of(2026, 6, 19, 10, 0))
}
assertNull(exception.messageKey)
assertEquals("Channel access is restricted at creator's request.", exception.message)
}
private fun createService(
port: FakeCreatorChannelAudioQueryPort,
canViewAdultContent: Boolean = true
): CreatorChannelAudioQueryService {
val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
Mockito.`when`(
preferenceService.getStoredPreference(Mockito.any(Member::class.java) ?: createMember(id = 0L))
).thenReturn(
ViewerContentPreference(
countryCode = "US",
isAdultContentVisible = canViewAdultContent,
contentType = ContentType.ALL,
isAdult = canViewAdultContent
)
)
val langContext = LangContext()
langContext.setLang(Lang.EN)
return CreatorChannelAudioQueryService(
queryPortProvider = FixedCreatorChannelAudioQueryPortProvider(port),
queryPolicy = CreatorChannelAudioQueryPolicy(),
memberContentPreferenceService = preferenceService,
messageSource = SodaMessageSource(),
langContext = langContext,
cloudFrontHost = "https://cdn.test"
)
}
private fun createMember(id: Long): Member {
return Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id",
provider = MemberProvider.EMAIL
).apply { this.id = id }
}
}
private class FixedCreatorChannelAudioQueryPortProvider(
private val port: CreatorChannelAudioQueryPort
) : ObjectProvider<CreatorChannelAudioQueryPort> {
override fun getObject(vararg args: Any?): CreatorChannelAudioQueryPort = port
override fun getIfAvailable(): CreatorChannelAudioQueryPort = port
override fun getIfUnique(): CreatorChannelAudioQueryPort = port
override fun getObject(): CreatorChannelAudioQueryPort = port
}
private class FakeCreatorChannelAudioQueryPort : CreatorChannelAudioQueryPort {
var creator: CreatorChannelAudioCreatorRecord? = CreatorChannelAudioCreatorRecord(
creatorId = 1L,
role = MemberRole.CREATOR,
nickname = "creator"
)
var blocked = false
var activeThemeId: Long? = 10L
var audioContentCount = 60
var paidAudioContentCount = 4
var purchasedAudioContentCount = 3
var audioContents = (1L..21L).map { audioContentRecord(it) }
var listThemeId: Long? = null
var listSort: ContentSort? = null
var listLocale: String? = null
var listOffset: Long? = null
var listLimit: Int? = null
var listCanViewAdultContent: Boolean? = null
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelAudioCreatorRecord? = creator
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean = blocked
override fun findActiveThemeId(themeId: Long): Long? = activeThemeId
override fun findAudioThemes(locale: String): List<CreatorChannelAudioThemeRecord> {
return listOf(CreatorChannelAudioThemeRecord(themeId = 10L, themeName = locale))
}
override fun countAudioContents(
creatorId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int = audioContentCount
override fun countPaidAudioContents(
creatorId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int = paidAudioContentCount
override fun countPurchasedAudioContents(
creatorId: Long,
viewerId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int = purchasedAudioContentCount
override fun findAudioContents(
creatorId: Long,
viewerId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean,
sort: ContentSort,
locale: String,
offset: Long,
limit: Int
): List<CreatorChannelAudioContentRecord> {
listThemeId = themeId
listSort = sort
listLocale = locale
listOffset = offset
listLimit = limit
listCanViewAdultContent = canViewAdultContent
return audioContents
}
}
private fun audioContentRecord(audioContentId: Long): CreatorChannelAudioContentRecord {
return CreatorChannelAudioContentRecord(
audioContentId = audioContentId,
title = "audio-$audioContentId",
duration = "00:10:00",
imagePath = "audio/$audioContentId.png",
price = 10,
isAdult = false,
isPointAvailable = true,
isFirstContent = audioContentId == 1L,
seriesName = "series",
isOriginalSeries = true,
isOwned = audioContentId == 1L,
isRented = audioContentId == 2L
)
}

View File

@@ -0,0 +1,56 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio.domain
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
class CreatorChannelAudioQueryPolicyTest {
private val policy = CreatorChannelAudioQueryPolicy()
@Test
@DisplayName("오디오 탭 page 정책은 page와 size를 fallback하고 fetch limit을 계산한다")
fun shouldFallbackPageAndSizeForAudioTab() {
val minimumPage = policy.createPage(page = -1, size = 10)
val maximumPage = policy.createPage(page = 2, size = 100)
assertEquals(0, minimumPage.page)
assertEquals(20, minimumPage.size)
assertEquals(0L, minimumPage.offset)
assertEquals(21, minimumPage.fetchLimit)
assertEquals(2, maximumPage.page)
assertEquals(50, maximumPage.size)
assertEquals(100L, maximumPage.offset)
assertEquals(51, maximumPage.fetchLimit)
}
@Test
@DisplayName("오디오 탭 sort 정책은 null과 알 수 없는 값을 LATEST로 fallback한다")
fun shouldFallbackInvalidSortToLatest() {
assertEquals(ContentSort.LATEST, policy.resolveSort(null))
assertEquals(ContentSort.LATEST, policy.resolveSort("UNKNOWN"))
assertEquals(ContentSort.POPULAR, policy.resolveSort("POPULAR"))
}
@Test
@DisplayName("오디오 탭 목록 정책은 요청 size만 남기고 다음 페이지 여부를 계산한다")
fun shouldLimitItemsAndCalculateHasNext() {
val page = policy.createPage(page = 0, size = 20)
val fetched = (1..21).toList()
val items = policy.limitItems(fetched, page)
assertEquals((1..20).toList(), items)
assertTrue(policy.hasNext(fetched, page))
assertFalse(policy.hasNext((1..20).toList(), page))
}
@Test
@DisplayName("오디오 탭 소장률은 유료 콘텐츠가 없으면 0이고 있으면 백분율로 계산한다")
fun shouldCalculatePurchaseRate() {
assertEquals(0.0, policy.purchaseRate(paidAudioContentCount = 0, purchasedAudioContentCount = 3))
assertEquals(75.0, policy.purchaseRate(paidAudioContentCount = 4, purchasedAudioContentCount = 3))
}
}

View File

@@ -15,8 +15,8 @@ import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto.CreatorChannelHomeResponse
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCreator
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelDonation
@@ -307,7 +307,6 @@ class CreatorChannelHomeQueryServiceTest {
isAdult = true,
isPointAvailable = true,
isFirstContent = true,
publishedAt = LocalDateTime.of(2026, 6, 11, 1, 0),
seriesName = "series",
isOriginalSeries = true,
isOwned = true,
@@ -342,7 +341,6 @@ class CreatorChannelHomeQueryServiceTest {
isAdult = false,
isPointAvailable = false,
isFirstContent = false,
publishedAt = LocalDateTime.of(2026, 6, 10, 1, 0),
seriesName = null,
isOriginalSeries = null,
isOwned = false,
@@ -646,7 +644,6 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
isAdult = false,
isPointAvailable = true,
isFirstContent = false,
publishedAt = LocalDateTime.of(2026, 6, 10, 0, audioContentId.toInt() % 60),
seriesName = null,
isOriginalSeries = null,
isOwned = audioContentId == 201L || audioContentId == 202L,

View File

@@ -1,9 +1,8 @@
package kr.co.vividnext.sodalive.v2.creator.channel.home.domain
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
@@ -80,23 +79,6 @@ class CreatorChannelHomeQueryPolicyTest {
assertEquals(listOf(1L, 3L), filtered.map { it.audioContentId })
}
@Test
@DisplayName("오디오 콘텐츠의 첫 공개 콘텐츠 여부는 공개 시각 오름차순, 동일 시각이면 id 오름차순으로 판정한다")
fun shouldMarkFirstAudioContentByPublishedAtAndId() {
val publishedAt = LocalDateTime.of(2026, 6, 12, 10, 0)
val audioContents = listOf(
audioContent(3L, publishedAt = publishedAt.plusDays(1)),
audioContent(2L, publishedAt = publishedAt),
audioContent(1L, publishedAt = publishedAt)
)
val marked = policy.markFirstAudioContent(audioContents)
assertTrue(marked.first { it.audioContentId == 1L }.isFirstContent)
assertFalse(marked.first { it.audioContentId == 2L }.isFirstContent)
assertFalse(marked.first { it.audioContentId == 3L }.isFirstContent)
}
private fun schedule(
targetId: Long,
scheduledAt: LocalDateTime,
@@ -112,10 +94,7 @@ class CreatorChannelHomeQueryPolicyTest {
)
}
private fun audioContent(
audioContentId: Long,
publishedAt: LocalDateTime = LocalDateTime.of(2026, 6, 12, 10, 0)
): CreatorChannelAudioContent {
private fun audioContent(audioContentId: Long): CreatorChannelAudioContent {
return CreatorChannelAudioContent(
audioContentId = audioContentId,
title = "audio-$audioContentId",
@@ -125,7 +104,6 @@ class CreatorChannelHomeQueryPolicyTest {
isAdult = false,
isPointAvailable = false,
isFirstContent = false,
publishedAt = publishedAt,
seriesName = null,
isOriginalSeries = null,
isOwned = false,

View File

@@ -388,7 +388,6 @@ private fun audioContentRecord(audioContentId: Long): CreatorChannelAudioContent
isAdult = false,
isPointAvailable = true,
isFirstContent = audioContentId == 1L,
publishedAt = LocalDateTime.of(2026, 6, 16, 10, 0),
seriesName = "series",
isOriginalSeries = true,
isOwned = audioContentId == 1L,