Compare commits
17 Commits
f6cb07fc0b
...
e5006d6334
| Author | SHA1 | Date | |
|---|---|---|---|
| e5006d6334 | |||
| ababd9a962 | |||
| 357d207fcc | |||
| 405bb12713 | |||
| 76cc6e6557 | |||
| cffd50c33f | |||
| 98241e16b0 | |||
| d1fb87556e | |||
| 63c28f8504 | |||
| 4ba0116f55 | |||
| c71f1ed17c | |||
| 4fdb9bcb26 | |||
| 80a06ad63d | |||
| f743d090c3 | |||
| 9a1bfed6a4 | |||
| f3a574a54a | |||
| c6b6c16e12 |
41
docs/20260619_cdn_url_공통화/plan-task.md
Normal file
41
docs/20260619_cdn_url_공통화/plan-task.md
Normal 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`로 통과했다.
|
||||
36
docs/20260619_cdn_url_공통화/prd.md
Normal file
36
docs/20260619_cdn_url_공통화/prd.md
Normal 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로 작성한다.
|
||||
607
docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md
Normal file
607
docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md
Normal 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, 소장/대여 상태 관련 차단 이슈 없음.
|
||||
265
docs/20260619_크리에이터_채널_오디오_탭_API/prd.md
Normal file
265
docs/20260619_크리에이터_채널_오디오_탭_API/prd.md
Normal 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 응답 시간
|
||||
- 테마별 조회 건수
|
||||
- 정렬 기준별 조회 건수
|
||||
- 오디오 탭에서 콘텐츠 추가 로딩 요청 건수
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user