Compare commits
8 Commits
87f6e47844
...
9f0ca9caa9
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f0ca9caa9 | |||
| 147d770e9d | |||
| 9bd0ce712e | |||
| 24556c1987 | |||
| 2bced956dc | |||
| 2aeb9418a9 | |||
| 1f84f8eaf2 | |||
| 74dc87db1e |
614
docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md
Normal file
614
docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md
Normal file
@@ -0,0 +1,614 @@
|
|||||||
|
# 메인 콘텐츠 전체 탭 API Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
||||||
|
|
||||||
|
**Goal:** `GET /api/v2/audio/contents`로 메인 콘텐츠 전체 탭의 오디오, 시리즈, 오리지널, 무료, 포인트 목록을 정렬/요일/페이징 조건에 맞춰 조회한다.
|
||||||
|
|
||||||
|
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.content.all` 조립 계층에 둔다. 전체 탭 조회 service, 요청 보정 정책, domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.content.all` 하위에 두고 `v2.api.*`에 의존하지 않는다. 기존 `ContentSort`, `SeriesPublishedDaysOfWeek`, 콘텐츠 추천/채널 오디오/채널 시리즈 조회 패턴을 재사용하되 공개 응답 DTO는 전체 탭 전용으로 최소 필드만 둔다.
|
||||||
|
|
||||||
|
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Security, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 구현 전 확정 사항
|
||||||
|
|
||||||
|
- API endpoint: `GET /api/v2/audio/contents`
|
||||||
|
- 인증 정책: 비회원 조회 가능. 인증 회원이면 `MemberContentPreferenceService`의 성인 콘텐츠 노출 가능 여부를 반영한다.
|
||||||
|
- 응답 wrapper: `ApiResponse.ok(...)`
|
||||||
|
- 요청 query parameter:
|
||||||
|
- `type`: `AUDIO`, `SERIES`, `ORIGINAL`, `FREE`, `POINT`; 기본값 `AUDIO`
|
||||||
|
- `sort`: `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW`; 기본값 `LATEST`
|
||||||
|
- `dayOfWeek`: `type=SERIES`에서만 적용. `SeriesPublishedDaysOfWeek` 값 `SUN`, `MON`, `TUE`, `WED`, `THU`, `FRI`, `SAT`, `RANDOM`
|
||||||
|
- `page`: 0부터 시작. 기본값 `0`
|
||||||
|
- `size`: 기본값 `20`, 최소 `20`, 최대 `50`
|
||||||
|
- `sort`가 invalid이거나 `OWNED`이면 `LATEST`로 fallback한다.
|
||||||
|
- `dayOfWeek`가 invalid이면 요일 조건을 적용하지 않고 `dayOfWeek = null`로 fallback한다.
|
||||||
|
- `type != SERIES`이면 `dayOfWeek`는 조회 조건에 적용하지 않고 응답에서 `null`로 내려준다.
|
||||||
|
- `type=ORIGINAL`에는 `dayOfWeek`를 적용하지 않는다.
|
||||||
|
- 전체 응답은 `totalCount`, `audios`, `series`, `sort`, `dayOfWeek`, `page`, `size`, `hasNext`를 포함한다.
|
||||||
|
- `AUDIO`, `FREE`, `POINT`는 `audios`만 채우고 `series`는 빈 배열로 내려준다.
|
||||||
|
- `SERIES`, `ORIGINAL`은 `series`만 채우고 `audios`는 빈 배열로 내려준다.
|
||||||
|
- 공개 오디오 조건: `audioContent.isActive == true`, `duration != null`, `releaseDate != null`, `releaseDate <= now`, 활성 테마, 활성 크리에이터.
|
||||||
|
- 공개 시리즈 조건: `series.isActive == true`, 활성 크리에이터. 성인 콘텐츠 노출 불가이면 `series.isAdult == false`.
|
||||||
|
- 회원이 차단했거나 회원을 차단한 크리에이터의 콘텐츠/시리즈는 제외한다.
|
||||||
|
- 신규 Entity와 DDL은 작성하지 않는다.
|
||||||
|
- `SecurityConfig`에는 `GET /api/v2/audio/contents` permitAll 설정을 추가한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 파일 구조 계획
|
||||||
|
|
||||||
|
### 신규 API 조립 계층
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllController.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacade.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponse.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllControllerTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacadeTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt`
|
||||||
|
|
||||||
|
### 신규 도메인 조회 계층
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAll.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllType.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllQueryPolicy.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentPage.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/port/out/MainContentAllQueryPort.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/MainContentAllQueryRepository.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllQueryPolicyTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt`
|
||||||
|
|
||||||
|
### 기존 설정/회귀
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSort.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/Series.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Response data class 초안
|
||||||
|
|
||||||
|
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.api.content.all.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAll
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllAudio
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllSeries
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllType
|
||||||
|
|
||||||
|
data class MainContentAllTabResponse(
|
||||||
|
val type: MainContentAllType,
|
||||||
|
val totalCount: Int,
|
||||||
|
val audios: List<MainContentAudioResponse>,
|
||||||
|
val series: List<MainContentSeriesResponse>,
|
||||||
|
val sort: ContentSort,
|
||||||
|
val dayOfWeek: SeriesPublishedDaysOfWeek?,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
@JsonProperty("hasNext")
|
||||||
|
val hasNext: Boolean
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(tab: MainContentAll): MainContentAllTabResponse {
|
||||||
|
return MainContentAllTabResponse(
|
||||||
|
type = tab.type,
|
||||||
|
totalCount = tab.totalCount,
|
||||||
|
audios = tab.audios.map(MainContentAudioResponse::from),
|
||||||
|
series = tab.series.map(MainContentSeriesResponse::from),
|
||||||
|
sort = tab.sort,
|
||||||
|
dayOfWeek = tab.dayOfWeek,
|
||||||
|
page = tab.page.page,
|
||||||
|
size = tab.page.size,
|
||||||
|
hasNext = tab.hasNext
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MainContentAudioResponse(
|
||||||
|
val audioContentId: Long,
|
||||||
|
val title: String,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val price: Int,
|
||||||
|
@JsonProperty("isAdult")
|
||||||
|
val isAdult: Boolean,
|
||||||
|
@JsonProperty("isPointAvailable")
|
||||||
|
val isPointAvailable: Boolean,
|
||||||
|
@JsonProperty("isFirstContent")
|
||||||
|
val isFirstContent: Boolean,
|
||||||
|
@JsonProperty("isOriginalSeries")
|
||||||
|
val isOriginalSeries: Boolean,
|
||||||
|
val creatorNickname: String
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(audio: MainContentAllAudio): MainContentAudioResponse {
|
||||||
|
return MainContentAudioResponse(
|
||||||
|
audioContentId = audio.audioContentId,
|
||||||
|
title = audio.title,
|
||||||
|
imageUrl = audio.imageUrl,
|
||||||
|
price = audio.price,
|
||||||
|
isAdult = audio.isAdult,
|
||||||
|
isPointAvailable = audio.isPointAvailable,
|
||||||
|
isFirstContent = audio.isFirstContent,
|
||||||
|
isOriginalSeries = audio.isOriginalSeries,
|
||||||
|
creatorNickname = audio.creatorNickname
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MainContentSeriesResponse(
|
||||||
|
val seriesId: Long,
|
||||||
|
val title: String,
|
||||||
|
val coverImageUrl: String?,
|
||||||
|
val creatorNickname: String,
|
||||||
|
@JsonProperty("isOriginal")
|
||||||
|
val isOriginal: Boolean,
|
||||||
|
@JsonProperty("isAdult")
|
||||||
|
val isAdult: Boolean
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(series: MainContentAllSeries): MainContentSeriesResponse {
|
||||||
|
return MainContentSeriesResponse(
|
||||||
|
seriesId = series.seriesId,
|
||||||
|
title = series.title,
|
||||||
|
coverImageUrl = series.coverImageUrl,
|
||||||
|
creatorNickname = series.creatorNickname,
|
||||||
|
isOriginal = series.isOriginal,
|
||||||
|
isAdult = series.isAdult
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Domain / Port 초안
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.content.all.domain
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||||
|
|
||||||
|
enum class MainContentAllType {
|
||||||
|
AUDIO,
|
||||||
|
SERIES,
|
||||||
|
ORIGINAL,
|
||||||
|
FREE,
|
||||||
|
POINT
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MainContentAll(
|
||||||
|
val type: MainContentAllType,
|
||||||
|
val totalCount: Int,
|
||||||
|
val audios: List<MainContentAllAudio>,
|
||||||
|
val series: List<MainContentAllSeries>,
|
||||||
|
val sort: ContentSort,
|
||||||
|
val dayOfWeek: SeriesPublishedDaysOfWeek?,
|
||||||
|
val page: MainContentPage,
|
||||||
|
val hasNext: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MainContentAllAudio(
|
||||||
|
val audioContentId: Long,
|
||||||
|
val title: String,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val price: Int,
|
||||||
|
val isAdult: Boolean,
|
||||||
|
val isPointAvailable: Boolean,
|
||||||
|
val isFirstContent: Boolean,
|
||||||
|
val isOriginalSeries: Boolean,
|
||||||
|
val creatorNickname: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MainContentAllSeries(
|
||||||
|
val seriesId: Long,
|
||||||
|
val title: String,
|
||||||
|
val coverImageUrl: String?,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val isOriginal: Boolean,
|
||||||
|
val isAdult: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MainContentPage(
|
||||||
|
val page: Int,
|
||||||
|
val size: Int
|
||||||
|
) {
|
||||||
|
val offset: Long = page.toLong() * size
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.content.all.port.out
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllAudio
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllSeries
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
interface MainContentAllQueryPort {
|
||||||
|
fun countAudios(
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
onlyFree: Boolean = false,
|
||||||
|
onlyPointAvailable: Boolean = false
|
||||||
|
): Int
|
||||||
|
|
||||||
|
fun findAudios(
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
sort: ContentSort,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
onlyFree: Boolean = false,
|
||||||
|
onlyPointAvailable: Boolean = false
|
||||||
|
): List<MainContentAllAudio>
|
||||||
|
|
||||||
|
fun countSeries(
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
onlyOriginal: Boolean = false,
|
||||||
|
dayOfWeek: SeriesPublishedDaysOfWeek? = null
|
||||||
|
): Int
|
||||||
|
|
||||||
|
fun findSeries(
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
sort: ContentSort,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
onlyOriginal: Boolean = false,
|
||||||
|
dayOfWeek: SeriesPublishedDaysOfWeek? = null,
|
||||||
|
locale: String
|
||||||
|
): List<MainContentAllSeries>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 1: 요청 보정 정책과 도메인 모델
|
||||||
|
|
||||||
|
- [x] **Task 1.1: 전체 탭 타입, page, 요청 보정 policy 추가**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllType.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentPage.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllQueryPolicy.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllQueryPolicyTest.kt`
|
||||||
|
- RED: 다음 테스트를 먼저 작성한다.
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun shouldResolveDefaultsAndFallbacks() {
|
||||||
|
val policy = MainContentAllQueryPolicy()
|
||||||
|
|
||||||
|
assertEquals(MainContentAllType.AUDIO, policy.resolveType(null))
|
||||||
|
assertEquals(MainContentAllType.AUDIO, policy.resolveType("UNKNOWN"))
|
||||||
|
assertEquals(ContentSort.LATEST, policy.resolveSort(null))
|
||||||
|
assertEquals(ContentSort.LATEST, policy.resolveSort("UNKNOWN"))
|
||||||
|
assertEquals(ContentSort.LATEST, policy.resolveSort("OWNED"))
|
||||||
|
assertEquals(ContentSort.POPULAR, policy.resolveSort("POPULAR"))
|
||||||
|
assertEquals(MainContentPage(page = 0, size = 20), policy.createPage(page = null, size = null))
|
||||||
|
assertEquals(MainContentPage(page = 0, size = 20), policy.createPage(page = -1, size = 1))
|
||||||
|
assertEquals(MainContentPage(page = 2, size = 50), policy.createPage(page = 2, size = 100))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- RED: `type=SERIES`일 때만 요일이 적용되는 테스트를 작성한다.
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun shouldResolveDayOfWeekOnlyForSeriesType() {
|
||||||
|
val policy = MainContentAllQueryPolicy()
|
||||||
|
|
||||||
|
assertEquals(SeriesPublishedDaysOfWeek.MON, policy.resolveDayOfWeek(MainContentAllType.SERIES, "MON"))
|
||||||
|
assertEquals(SeriesPublishedDaysOfWeek.RANDOM, policy.resolveDayOfWeek(MainContentAllType.SERIES, "RANDOM"))
|
||||||
|
assertNull(policy.resolveDayOfWeek(MainContentAllType.SERIES, "INVALID"))
|
||||||
|
assertNull(policy.resolveDayOfWeek(MainContentAllType.ORIGINAL, "MON"))
|
||||||
|
assertNull(policy.resolveDayOfWeek(MainContentAllType.AUDIO, "MON"))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest`
|
||||||
|
- GREEN: `resolveType(sort: String?)`, `resolveSort(sort: String?)`, `resolveDayOfWeek(type, dayOfWeek)`, `createPage(page, size)`, `limitItems`, `hasNext`를 최소 구현한다.
|
||||||
|
- REFACTOR: `OWNED` fallback과 invalid `dayOfWeek` fallback이 400으로 흐르지 않도록 controller에서 enum 직접 binding을 사용하지 않는 설계를 확인한다.
|
||||||
|
- 기대 결과: 요청 보정 정책이 순수 단위 테스트로 고정된다.
|
||||||
|
- 검증 기록:
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest` 실행 시 `MainContentAllQueryPolicy`, `MainContentAllType`, `MainContentPage` 미구현 컴파일 실패를 확인했다.
|
||||||
|
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest` 성공으로 기본값/fallback/page/hasNext 정책을 확인했다.
|
||||||
|
|
||||||
|
- [x] **Task 1.2: 전체 탭 domain model 추가**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAll.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt`
|
||||||
|
- RED: `MainContentAllTabResponse.from(...)`이 최소 필드만 변환하는 테스트를 작성한다.
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun shouldMapDomainToResponseWithMinimalFields() {
|
||||||
|
val response = MainContentAllTabResponse.from(
|
||||||
|
MainContentAll(
|
||||||
|
type = MainContentAllType.SERIES,
|
||||||
|
totalCount = 1,
|
||||||
|
audios = emptyList(),
|
||||||
|
series = listOf(
|
||||||
|
MainContentAllSeries(
|
||||||
|
seriesId = 10L,
|
||||||
|
title = "시리즈",
|
||||||
|
coverImageUrl = "https://cdn/series.jpg",
|
||||||
|
creatorNickname = "creator",
|
||||||
|
isOriginal = true,
|
||||||
|
isAdult = false
|
||||||
|
)
|
||||||
|
),
|
||||||
|
sort = ContentSort.LATEST,
|
||||||
|
dayOfWeek = SeriesPublishedDaysOfWeek.MON,
|
||||||
|
page = MainContentPage(0, 20),
|
||||||
|
hasNext = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(MainContentAllType.SERIES, response.type)
|
||||||
|
assertEquals(1, response.totalCount)
|
||||||
|
assertTrue(response.audios.isEmpty())
|
||||||
|
assertEquals("creator", response.series.first().creatorNickname)
|
||||||
|
assertEquals(SeriesPublishedDaysOfWeek.MON, response.dayOfWeek)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest`
|
||||||
|
- GREEN: `MainContentAll`, `MainContentAllAudio`, `MainContentAllSeries`, response DTO를 최소 구현한다.
|
||||||
|
- REFACTOR: `MainContentAudioResponse`에 `duration`, `MainContentSeriesResponse`에 `publishedDaysOfWeek`, `isProceeding`, `contentCount`, `paidContentCount`가 없는지 소스와 테스트에서 확인한다.
|
||||||
|
- 기대 결과: 공개 응답 계약이 PRD와 일치한다.
|
||||||
|
- 검증 기록:
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest` 실행 시 `MainContentAllTabResponse`, `MainContentAll` 계열 도메인 모델 미구현 컴파일 실패를 확인했다.
|
||||||
|
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest` 성공으로 도메인→응답 DTO 변환과 boolean `is*` JSON 필드명을 확인했다.
|
||||||
|
|
||||||
|
### Phase 2: API 조립 계층
|
||||||
|
|
||||||
|
- [x] **Task 2.1: facade 작성**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacade.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacadeTest.kt`
|
||||||
|
- RED: facade가 문자열 query parameter를 그대로 query service에 넘기고 응답 DTO로 변환하는 테스트를 작성한다.
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun shouldDelegateToQueryServiceAndMapResponse() {
|
||||||
|
val service = FakeMainContentAllQueryService()
|
||||||
|
val facade = MainContentAllFacade(service)
|
||||||
|
|
||||||
|
val response = facade.getContents(
|
||||||
|
type = "FREE",
|
||||||
|
sort = "PRICE_LOW",
|
||||||
|
dayOfWeek = "MON",
|
||||||
|
page = 1,
|
||||||
|
size = 30,
|
||||||
|
member = null
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("FREE", service.requestedType)
|
||||||
|
assertEquals("PRICE_LOW", service.requestedSort)
|
||||||
|
assertEquals("MON", service.requestedDayOfWeek)
|
||||||
|
assertEquals(MainContentAllType.FREE, response.type)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest`
|
||||||
|
- GREEN: facade는 query service 호출과 `MainContentAllTabResponse.from(...)` 변환만 담당한다.
|
||||||
|
- REFACTOR: facade에 정렬, 요일, DB 조회 정책이 들어가지 않도록 확인한다.
|
||||||
|
- 기대 결과: API 조립 계층과 도메인 조회 계층의 책임이 분리된다.
|
||||||
|
- 검증 기록:
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` 실행 시 `MainContentAllFacade`, `MainContentAllQueryService` 미구현 컴파일 실패를 확인했다.
|
||||||
|
- GREEN: 동일 명령 성공으로 facade가 문자열 query parameter와 `Member?`를 query service에 그대로 전달하고 응답 DTO로 변환함을 확인했다.
|
||||||
|
|
||||||
|
- [x] **Task 2.2: controller와 보안 설정 추가**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllController.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllControllerTest.kt`
|
||||||
|
- RED: `GET /api/v2/audio/contents`가 비회원에게 `200 OK`를 반환하고 `type` 기본값을 service까지 전달하는 MockMvc 테스트를 작성한다.
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun shouldAllowAnonymousAndUseDefaultType() {
|
||||||
|
mockMvc.get("/api/v2/audio/contents")
|
||||||
|
.andExpect {
|
||||||
|
status { isOk() }
|
||||||
|
jsonPath("$.data.type") { value("AUDIO") }
|
||||||
|
jsonPath("$.data.sort") { value("LATEST") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- RED: `GET /api/v2/audio/contents?type=SERIES&dayOfWeek=MON&sort=POPULAR&page=1&size=30`이 query parameter를 facade로 전달하는 테스트를 작성한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest`
|
||||||
|
- GREEN: `@RequestMapping("/api/v2/audio/contents")`, `@RequestParam type: String?`, `sort: String?`, `dayOfWeek: String?`, `page: Int?`, `size: Int?`, optional `member: Member?`로 controller를 구현한다.
|
||||||
|
- GREEN: `SecurityConfig`에 `antMatchers(HttpMethod.GET, "/api/v2/audio/contents").permitAll()`을 추가한다.
|
||||||
|
- REFACTOR: `ContentSort`와 `SeriesPublishedDaysOfWeek`를 controller parameter에 직접 binding하지 않는지 확인한다.
|
||||||
|
- 기대 결과: 공개 endpoint, 비회원 허용, invalid parameter fallback을 위한 controller 계약이 고정된다.
|
||||||
|
- 검증 기록:
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` 실행 시 `MainContentAllController` 미구현 컴파일 실패를 확인했다.
|
||||||
|
- GREEN: 동일 명령 성공으로 비회원 `GET /api/v2/audio/contents` 200 OK, query parameter/member 전달, `SecurityConfig` permitAll 설정을 확인했다.
|
||||||
|
|
||||||
|
### Phase 3: 조회 service와 port
|
||||||
|
|
||||||
|
- [x] **Task 3.1: query port와 service 분기 작성**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/port/out/MainContentAllQueryPort.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt`
|
||||||
|
- RED: `AUDIO`, `FREE`, `POINT` type이 audio count/list port를 올바른 필터로 호출하는 fake port 테스트를 작성한다.
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun shouldQueryAudiosByType() {
|
||||||
|
val port = FakeMainContentAllQueryPort()
|
||||||
|
val service = createService(port)
|
||||||
|
|
||||||
|
service.getContents(type = "FREE", sort = "LATEST", dayOfWeek = null, page = 0, size = 20, member = null)
|
||||||
|
|
||||||
|
assertEquals("audio", port.lastListKind)
|
||||||
|
assertTrue(port.lastOnlyFree)
|
||||||
|
assertFalse(port.lastOnlyPointAvailable)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- RED: `SERIES` type이 `dayOfWeek=MON`을 series count/list port에 전달하고 `ORIGINAL` type은 `onlyOriginal=true`, `dayOfWeek=null`로 호출하는 테스트를 작성한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest`
|
||||||
|
- GREEN: service는 policy로 type/sort/day/page를 보정하고, `type`에 따라 port 메서드를 호출한다.
|
||||||
|
- GREEN: `limit = page.size + 1`로 조회한 뒤 `policy.limitItems(...)`와 `policy.hasNext(...)`를 적용한다.
|
||||||
|
- REFACTOR: service에는 QueryDSL 조건식이나 response DTO 변환을 두지 않는다.
|
||||||
|
- 기대 결과: type별 조회 분기, 전체 개수, `hasNext`, fallback 정책이 service 단위 테스트로 고정된다.
|
||||||
|
- 검증 기록:
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` 실행 시 `MainContentAllQueryPort`, `MainContentAllQueryService` 미구현 컴파일 실패를 확인했다.
|
||||||
|
- GREEN: 동일 명령 성공으로 `AUDIO/FREE/POINT` audio 분기, `SERIES/ORIGINAL` series 분기, `limit = size + 1`, `hasNext` 처리를 확인했다.
|
||||||
|
|
||||||
|
- [x] **Task 3.2: 성인 콘텐츠 노출 정책 연결**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt`
|
||||||
|
- RED: 비회원이면 `canViewAdultContent=false`, 회원이면 `MemberContentPreferenceService.canViewAdultContent(member)` 결과를 port에 전달하는 테스트를 작성한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest`
|
||||||
|
- GREEN: 기존 `AudioRecommendationQueryService`와 같은 방식으로 성인 콘텐츠 노출 가능 여부를 계산한다.
|
||||||
|
- REFACTOR: 회원 id는 `member?.id`만 port에 전달하고, port/repository에서 차단 관계 제외 조건을 처리하게 둔다.
|
||||||
|
- 기대 결과: 비회원/회원 성인 콘텐츠 정책이 기존 v2 추천 탭과 일치한다.
|
||||||
|
- 검증 기록:
|
||||||
|
- RED: service 테스트 추가 후 `MainContentAllQueryService` 미구현 컴파일 실패를 확인했다.
|
||||||
|
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` 포함 Phase 2-3 테스트 명령 성공으로 비회원 `canViewAdultContent=false`, 회원 `MemberContentPreferenceService.canViewAdultContent(member)` 결과 전달을 확인했다.
|
||||||
|
|
||||||
|
### Phase 4: QueryDSL repository
|
||||||
|
|
||||||
|
- [x] **Task 4.1: audio count/list repository 구현**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/MainContentAllQueryRepository.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt`
|
||||||
|
- RED: 공개 오디오만 조회하고 비회원은 성인 오디오를 제외하며 차단 관계 크리에이터의 오디오를 제외하는 repository 테스트를 작성한다.
|
||||||
|
- RED: `FREE` 조회는 `price == 0`, `POINT` 조회는 `isPointAvailable == true` 필터가 적용되는 테스트를 작성한다.
|
||||||
|
- RED: `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW` 정렬 테스트를 작성한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest`
|
||||||
|
- GREEN: `DefaultAudioRecommendationQueryRepository.audioRows(...)`, `DefaultCreatorChannelAudioQueryRepository.findAudioContentRows(...)` 패턴을 참고해 audio count/list를 구현한다.
|
||||||
|
- GREEN: 인기순은 `orders.isActive == true`인 주문의 `orders.can.sum().coalesce(0)`만 사용하고 `orders.point`는 더하지 않는다.
|
||||||
|
- GREEN: `isFirstContent`는 크리에이터별 전체 공개 오디오 중 가장 먼저 공개된 콘텐츠인지로 계산한다.
|
||||||
|
- GREEN: `isOriginalSeries`는 해당 오디오가 속한 시리즈의 `isOriginal` 기준으로 계산하고 시리즈 미소속이면 `false`로 내려준다.
|
||||||
|
- REFACTOR: CDN URL 변환은 `toCdnUrl(cloudFrontHost)` 패턴을 사용한다.
|
||||||
|
- 기대 결과: 오디오/무료/포인트 조회의 필터, count, 정렬, 카드 필드가 repository 테스트로 고정된다.
|
||||||
|
- 검증 기록:
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest` 실행 시 `DefaultMainContentAllQueryRepository` 미구현 컴파일 실패를 확인했다.
|
||||||
|
- GREEN: 동일 명령 성공으로 공개 오디오 조건, 성인/차단 제외, 무료/포인트 필터, 가격/인기 정렬, CDN URL, 첫 콘텐츠, 오리지널 시리즈 여부를 확인했다.
|
||||||
|
|
||||||
|
- [x] **Task 4.2: series count/list repository 구현**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt`
|
||||||
|
- RED: `SERIES` 조회가 활성 시리즈와 활성 크리에이터만 반환하고, 비회원은 성인 시리즈를 제외하며, 차단 관계 크리에이터의 시리즈를 제외하는 테스트를 작성한다.
|
||||||
|
- RED: `dayOfWeek=MON`이면 `series.publishedDaysOfWeek`에 `MON`이 포함된 시리즈만 반환하고 `dayOfWeek=RANDOM`이면 `RANDOM` 포함 시리즈만 반환하는 테스트를 작성한다.
|
||||||
|
- RED: `ORIGINAL` 조회가 `series.isOriginal == true`만 반환하고 `dayOfWeek`는 적용하지 않는 테스트를 작성한다.
|
||||||
|
- RED: `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW` 시리즈 정렬 테스트를 작성한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest`
|
||||||
|
- GREEN: `DefaultCreatorChannelSeriesQueryRepository.findSeriesIds(...)` 패턴을 참고해 시리즈 id 선조회 후 row를 원래 정렬 순서대로 조립한다.
|
||||||
|
- GREEN: 시리즈 정렬 대표값은 공개 오디오 기준 `max(releaseDate)`, `max(price)`, `min(price)`, `orders.can.sum()`을 사용한다.
|
||||||
|
- GREEN: 시리즈 응답 필드는 `seriesId`, `title`, `coverImageUrl`, `creatorNickname`, `isOriginal`, `isAdult`만 조립한다.
|
||||||
|
- REFACTOR: `MainContentSeriesResponse`에서 제외된 연재 요일/연재 상태/콘텐츠 통계 필드를 조회 응답 조립용으로 불필요하게 projection하지 않는다.
|
||||||
|
- 기대 결과: 시리즈/오리지널 조회의 요일 필터, count, 정렬, 최소 응답 필드가 repository 테스트로 고정된다.
|
||||||
|
- 검증 기록:
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest` 실행 시 repository 미구현 컴파일 실패를 확인했다.
|
||||||
|
- GREEN: 동일 명령 성공으로 활성 시리즈/크리에이터 조건, 성인/차단 제외, 요일 필터, ORIGINAL 필터, 대표 공개 오디오 기준 정렬, 최소 시리즈 응답 필드를 확인했다.
|
||||||
|
|
||||||
|
### Phase 5: 공개 API 통합 검증
|
||||||
|
|
||||||
|
- [x] **Task 5.1: controller-to-repository 통합 테스트 작성**
|
||||||
|
- Files:
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt`
|
||||||
|
- RED: Spring context 기반으로 `GET /api/v2/audio/contents?type=AUDIO&sort=LATEST&page=0&size=20`가 `audios`, `totalCount`, `sort`, `page`, `size`, `hasNext`를 반환하고 `series`는 빈 배열인 테스트를 작성한다.
|
||||||
|
- RED: `GET /api/v2/audio/contents?type=SERIES&dayOfWeek=MON&sort=POPULAR&page=0&size=20`가 `series`, `dayOfWeek=MON`, `audios=[]`를 반환하는 테스트를 작성한다.
|
||||||
|
- RED: `GET /api/v2/audio/contents?type=ORIGINAL&dayOfWeek=MON`이 `dayOfWeek=null`로 응답하고 오리지널 시리즈만 반환하는 테스트를 작성한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest`
|
||||||
|
- GREEN: 테스트 fixture에 공개/비공개/성인/무료/포인트/요일별 시리즈/오리지널 시리즈/차단 관계 데이터를 구성하고 end-to-end 응답을 통과시킨다.
|
||||||
|
- REFACTOR: controller, facade, service, repository 경계가 단방향 의존을 유지하는지 import를 확인한다.
|
||||||
|
- 기대 결과: 실제 HTTP 경로에서 PRD의 주요 응답 계약이 검증된다.
|
||||||
|
- 검증 기록:
|
||||||
|
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest` 성공으로 `AUDIO`, `SERIES dayOfWeek=MON`, `ORIGINAL dayOfWeek 무시` HTTP 통합 경로를 확인했다.
|
||||||
|
- 참고: Phase 1-4 구현이 이미 존재해 신규 E2E 추가 직후 타깃 테스트가 GREEN으로 통과했으므로, 별도 production 수정은 없었다.
|
||||||
|
|
||||||
|
- [x] **Task 5.2: 회귀 테스트와 포맷 검증**
|
||||||
|
- Files:
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/**`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/**`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
|
||||||
|
- Modify: `docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md`
|
||||||
|
- RED: 이 task는 신규 동작 추가가 아니라 전체 회귀 검증 task이므로 별도 실패 테스트를 만들지 않는다.
|
||||||
|
- TDD 예외 사유: 앞선 task에서 기능별 실패 테스트를 작성했고, 이 task는 전체 suite와 문서 검증 기록 누적이 목적이다.
|
||||||
|
- 대체 검증 방법:
|
||||||
|
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*'`
|
||||||
|
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'`
|
||||||
|
- `./gradlew ktlintCheck`
|
||||||
|
- `git diff --check`
|
||||||
|
- `rg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all`
|
||||||
|
- GREEN: 위 명령이 모두 성공하고, 응답 DTO에 제거 대상 필드가 남아 있지 않음을 확인한다.
|
||||||
|
- REFACTOR: 검증 결과를 이 문서 하단 `검증 기록`에 누적한다.
|
||||||
|
- 기대 결과: 신규 API 패키지 테스트와 포맷 검증이 완료된다.
|
||||||
|
- 검증 기록:
|
||||||
|
- GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*'` 성공.
|
||||||
|
- GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'` 성공.
|
||||||
|
- GREEN: `./gradlew ktlintCheck` 성공.
|
||||||
|
- GREEN: `git diff --check` 성공.
|
||||||
|
- 확인: `rg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all` 실행 시 공개 DTO 소스에는 제거 대상 필드가 없고, E2E fixture의 공개 조건 설정과 DTO 테스트의 부재 검증만 검색되었다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 실행 명령
|
||||||
|
|
||||||
|
- 정책 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest`
|
||||||
|
- DTO 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest`
|
||||||
|
- Facade 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest`
|
||||||
|
- Controller 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest`
|
||||||
|
- Service 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest`
|
||||||
|
- Repository 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest`
|
||||||
|
- End-to-end 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest`
|
||||||
|
- 전체 신규 패키지 테스트: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'`
|
||||||
|
- 포맷 검증: `./gradlew ktlintCheck`
|
||||||
|
- 문서 변경 후 명령 유효성 확인: `./gradlew tasks --all`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 검증 기록
|
||||||
|
|
||||||
|
- 2026-06-25 Phase 1-3 RED/GREEN 검증
|
||||||
|
- RED: Phase 1 정책/DTO 테스트 추가 후 `MainContentAllQueryPolicy`, `MainContentAllType`, `MainContentPage`, `MainContentAllTabResponse`, `MainContentAll` 계열 모델 미구현 컴파일 실패를 확인했다.
|
||||||
|
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest` 성공.
|
||||||
|
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest` 성공.
|
||||||
|
- RED: Phase 2-3 facade/controller/service 테스트 추가 후 `MainContentAllFacade`, `MainContentAllController`, `MainContentAllQueryPort`, `MainContentAllQueryService` 미구현 컴파일 실패를 확인했다.
|
||||||
|
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` 성공.
|
||||||
|
- 보강: `MainContentAllQueryServiceTest`에서 `AUDIO`, `FREE`, `POINT` audio 분기를 각각 독립 테스트로 검증하도록 분리했다.
|
||||||
|
- 참고: Phase 4 repository 구현 전이므로 Spring 전체 context에서 `MainContentAllQueryPort` 실제 bean 연결은 아직 범위 밖이다.
|
||||||
|
- 참고: 실제 머지/배포 전에는 Phase 4 repository adapter bean과 Phase 5 end-to-end 테스트를 구현한 뒤 Spring 전체 context 검증을 다시 수행해야 한다.
|
||||||
|
- 2026-06-25 Phase 4 RED/GREEN 검증
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest` 실행 시 `DefaultMainContentAllQueryRepository` 미구현 컴파일 실패를 확인했다.
|
||||||
|
- GREEN: 동일 명령 성공으로 audio/series count/list repository의 공개 조건, 성인/차단 제외, FREE/POINT/ORIGINAL/dayOfWeek 필터, 정렬, CDN URL, 최소 응답 필드를 확인했다.
|
||||||
|
- 2026-06-25 Phase 4 코드 리뷰 및 검증
|
||||||
|
- 리뷰: `DefaultMainContentAllQueryRepository.findSeries(...)`가 `locale` 파라미터를 받지만 `SeriesTranslation`을 조회하지 않아, PRD의 언어코드 기반 시리즈 제목 fallback 요구사항을 충족하지 못하는 것을 확인했다.
|
||||||
|
- 리뷰: `ContentSort.LATEST`의 오디오/시리즈 정렬에 `price` 대표값이 보조 정렬로 포함되어 있어, PRD의 `releaseDate desc, id desc` 기준과 다른 순서가 나올 수 있음을 확인했다.
|
||||||
|
- RED: `shouldSortAudiosByLatestReleaseDateAndIdOnly` 추가 후 `expected: <[2, 1]> but was: <[1, 2]>` 실패로 audio `LATEST`가 같은 공개일에서 price desc를 우선하는 문제를 재현했다.
|
||||||
|
- RED: `shouldFindSeriesWithTranslatedTitleFallback` 추가 후 `expected: <Translated Series> but was: <origin-translated-series>` 실패로 series locale 번역 미적용 문제를 재현했다.
|
||||||
|
- RED: `shouldSortSeriesByPublicAudioRepresentatives` 보강 후 `expected: <[6, 5, 4]> but was: <[5, 4, 6]>` 실패로 series `LATEST`가 같은 대표 공개일에서 highestPrice desc를 우선하는 문제를 재현했다.
|
||||||
|
- GREEN: `findSeries(...)`에 `SeriesTranslation` left join과 blank fallback을 추가하고, audio/series `LATEST` 보조 정렬에서 price 대표값을 제거했다.
|
||||||
|
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest` 성공.
|
||||||
|
- GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'` 성공.
|
||||||
|
- GREEN: `./gradlew ktlintCheck` 성공.
|
||||||
|
- GREEN: `git diff --check` 성공.
|
||||||
|
- 확인: `rg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all` 실행 시 공개 DTO 소스에는 제거 대상 필드가 없고, DTO 테스트의 부재 검증만 검색되었다.
|
||||||
|
- 확인: 위 리뷰 항목 2건은 보강 테스트와 구현 수정으로 해결했다.
|
||||||
|
- 2026-06-25 Phase 5 공개 API 통합 검증
|
||||||
|
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest` 성공으로 실제 HTTP 경로에서 `AUDIO`는 `audios`와 빈 `series`, `SERIES dayOfWeek=MON`은 `series`와 빈 `audios`, `ORIGINAL dayOfWeek=MON`은 `dayOfWeek=null`과 오리지널 시리즈만 반환함을 확인했다.
|
||||||
|
- 참고: Phase 1-4 구현이 이미 존재해 신규 E2E 추가 직후 타깃 테스트가 GREEN으로 통과했으며, Phase 5에서 production 코드는 변경하지 않았다.
|
||||||
|
- 참고: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*'`와 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'`를 동시에 실행했을 때 test result XML 파일 쓰기 충돌이 한 번 발생했다. 동일 명령을 순차 재실행해 두 테스트 모두 성공함을 확인했다.
|
||||||
|
- GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*'` 성공.
|
||||||
|
- GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'` 성공.
|
||||||
|
- GREEN: `./gradlew ktlintCheck` 성공.
|
||||||
|
- GREEN: `git diff --check` 성공.
|
||||||
|
- 확인: `rg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all` 실행 시 공개 DTO 소스에는 제거 대상 필드가 없고, E2E fixture의 공개 조건 설정과 DTO 테스트의 부재 검증만 검색되었다.
|
||||||
333
docs/20260624_메인_콘텐츠_전체_탭_API/prd.md
Normal file
333
docs/20260624_메인_콘텐츠_전체_탭_API/prd.md
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
# PRD: 메인 콘텐츠 전체 탭 API
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
메인 콘텐츠 탭의 내부 전체 탭에서 오디오, 시리즈, 오리지널, 무료, 포인트 구분별 공개 콘텐츠를 정렬과 페이징으로 조회하는 v2 API를 제공한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problem
|
||||||
|
- 기존 메인 콘텐츠 추천 탭 API는 여러 추천 섹션을 한 번에 조립하지만, 전체 탭은 사용자가 선택한 구분별 전체 콘텐츠 목록과 전체 개수, 정렬 상태, 페이징 상태를 제공해야 한다.
|
||||||
|
- 기존 크리에이터 채널 오디오/시리즈 탭 API는 특정 크리에이터 기준 조회라서, 전체 탭처럼 차단 관계가 아닌 모든 크리에이터의 공개 콘텐츠를 대상으로 하기 어렵다.
|
||||||
|
- 정렬 기준은 기존 공용 `ContentSort` enum과 의미를 공유해야 하며, 인기순 매출 산식은 포인트 사용액을 제외한 `orders.can` 합계로 명확히 고정해야 한다.
|
||||||
|
- V2 패키지에는 API 조립 계층과 도메인 조회 계층 분리, 공통 오디오 카드 DTO, 차단/성인 콘텐츠/공개 콘텐츠 필터, 시리즈 정렬 패턴이 이미 있으므로 재사용 범위를 먼저 명시해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
- 메인 콘텐츠 전체 탭 조회 API를 `kr.co.vividnext.sodalive.v2` 하위 신규 코드로 제공한다.
|
||||||
|
- 기존 패턴과 동일하게 API 조립 계층과 도메인 조회 계층을 분리한다.
|
||||||
|
- 구분은 `AUDIO`, `SERIES`, `ORIGINAL`, `FREE`, `POINT`를 지원한다.
|
||||||
|
- 공개된 콘텐츠만 조회한다.
|
||||||
|
- 회원이 차단했거나 회원을 차단한 크리에이터의 콘텐츠는 노출하지 않는다.
|
||||||
|
- 비회원은 19금 콘텐츠를 노출하지 않는다.
|
||||||
|
- 인증 회원은 기존 콘텐츠 조회 설정에 따라 19금 콘텐츠 노출 가능 여부를 반영한다.
|
||||||
|
- 전체 콘텐츠 개수와 페이징 목록을 함께 응답한다.
|
||||||
|
- 정렬 순서는 기존 공용 `ContentSort` enum을 사용한다.
|
||||||
|
- `SERIES` 구분은 legacy 시리즈 메인 요일별 조회와 동일하게 요일 선택을 지원한다.
|
||||||
|
- PRD에 API endpoint와 Response data class 초안을 포함한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
- 기존 `content.main.tab.*` legacy API 스키마를 변경하지 않는다.
|
||||||
|
- 기존 메인 콘텐츠 추천 탭 API와 랭킹 탭 API의 공개 스키마를 변경하지 않는다.
|
||||||
|
- 기존 크리에이터 채널 오디오/시리즈 탭 API의 endpoint, 응답 필드, 인증 정책을 변경하지 않는다.
|
||||||
|
- 신규 스냅샷 테이블이나 배치 집계는 이번 범위에 포함하지 않는다.
|
||||||
|
- 개인화 추천, 랜덤 노출, 운영자 고정/제외 기능은 포함하지 않는다.
|
||||||
|
- 구매, 대여, 소장, 포인트 결제 API는 포함하지 않는다.
|
||||||
|
- `ContentSort` enum에 신규 값을 추가하지 않는다.
|
||||||
|
- `OWNED` 정렬은 전체 탭 요구사항에 포함하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Target Users
|
||||||
|
- 회원: 메인 콘텐츠 전체 탭에서 원하는 구분의 공개 콘텐츠를 정렬해 탐색하는 사용자
|
||||||
|
- 비회원: 인증 없이 조회 가능한 공개 콘텐츠를 탐색하는 사용자
|
||||||
|
- 앱 클라이언트: 전체 탭의 구분, 전체 개수, 정렬 상태, 페이징 목록을 단일 계약으로 구성하려는 클라이언트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. User Stories
|
||||||
|
- 사용자는 오디오 콘텐츠 전체 목록을 최신순, 인기순, 가격순으로 보고 싶다.
|
||||||
|
- 사용자는 선택한 요일의 시리즈 목록을 보고 싶다.
|
||||||
|
- 사용자는 오리지널 시리즈만 따로 보고 싶다.
|
||||||
|
- 사용자는 무료 오디오만 따로 보고 싶다.
|
||||||
|
- 사용자는 포인트를 사용할 수 있는 오디오만 따로 보고 싶다.
|
||||||
|
- 앱 클라이언트는 현재 적용된 구분, 정렬, page, size, hasNext를 응답에서 확인해 화면 상태와 서버 결과를 맞추고 싶다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Core Features
|
||||||
|
|
||||||
|
### Feature A. 메인 콘텐츠 전체 탭 조회 API
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 신규 API endpoint는 `GET /api/v2/audio/contents`를 기본안으로 한다.
|
||||||
|
- 응답 wrapper는 기존 패턴과 동일하게 `ApiResponse.ok(...)`를 사용한다.
|
||||||
|
- 비회원 조회를 허용한다.
|
||||||
|
- 회원 조회 시 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴을 사용한다.
|
||||||
|
- 요청 query parameter는 `type`, `sort`, `dayOfWeek`, `page`, `size`를 사용한다.
|
||||||
|
- `type` 값은 아래 enum으로 정의한다.
|
||||||
|
- `AUDIO`: 오디오
|
||||||
|
- `SERIES`: 시리즈
|
||||||
|
- `ORIGINAL`: 오리지널
|
||||||
|
- `FREE`: 무료
|
||||||
|
- `POINT`: 포인트
|
||||||
|
- `type`을 보내지 않으면 `AUDIO`를 기본값으로 사용한다.
|
||||||
|
- `sort`를 보내지 않으면 `LATEST`를 기본값으로 사용한다.
|
||||||
|
- `sort` 값이 없거나 기존 `ContentSort` enum 값에 없으면 `LATEST`로 fallback한다.
|
||||||
|
- 전체 탭에서 지원하는 정렬 값은 `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW`다.
|
||||||
|
- `OWNED`가 들어오면 전체 탭 요구사항에 없는 정렬이므로 `LATEST`로 fallback한다.
|
||||||
|
- `dayOfWeek`는 `type=SERIES`일 때만 적용한다.
|
||||||
|
- `dayOfWeek` 값은 legacy `SeriesMainController.getDayOfWeekSeriesList(...)`와 동일하게 `SeriesPublishedDaysOfWeek` enum 값을 사용한다.
|
||||||
|
- `dayOfWeek` 지원 값은 `SUN`, `MON`, `TUE`, `WED`, `THU`, `FRI`, `SAT`, `RANDOM`이다.
|
||||||
|
- `dayOfWeek`를 보내지 않으면 전체 요일의 시리즈를 조회한다.
|
||||||
|
- `type`이 `SERIES`가 아니면 `dayOfWeek`는 조회 조건에 적용하지 않고 응답에서는 `null`로 내려준다.
|
||||||
|
- `page`는 0부터 시작하는 page index로 처리한다.
|
||||||
|
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
|
||||||
|
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
|
||||||
|
- `page`가 0보다 작으면 `0`으로 fallback한다.
|
||||||
|
- `size`가 20보다 작으면 `20`으로 fallback한다.
|
||||||
|
- `size`가 50보다 크면 `50`으로 fallback한다.
|
||||||
|
- 응답에는 같은 필터 조건의 전체 콘텐츠 개수와 현재 page 목록을 포함한다.
|
||||||
|
- 다음 page 존재 여부는 `size + 1`개 조회 또는 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 공개된 콘텐츠가 없으면 `totalCount`는 `0`, 목록은 빈 배열, `hasNext`는 `false`로 내려준다.
|
||||||
|
- 요청한 page 범위에 콘텐츠가 없으면 목록은 빈 배열, `hasNext`는 `false`로 내려주되 `totalCount`는 전체 개수를 유지한다.
|
||||||
|
- 특정 구분에서 지원하지 않는 응답 목록 필드는 빈 배열로 내려준다.
|
||||||
|
|
||||||
|
### Feature B. 공통 공개/차단/성인 콘텐츠 정책
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 모든 구분은 공개 가능한 콘텐츠만 조회한다.
|
||||||
|
- 오디오 콘텐츠는 `isActive == true`, `duration != null`, `releaseDate != null`, `releaseDate <= now`, 활성 테마, 활성 크리에이터 조건을 만족해야 한다.
|
||||||
|
- 시리즈는 `isActive == true`, 활성 크리에이터 조건을 만족해야 한다.
|
||||||
|
- 시리즈의 콘텐츠 통계와 정렬 대표값은 공개 가능한 오디오 콘텐츠만 기준으로 계산한다.
|
||||||
|
- 회원이 차단했거나 회원을 차단한 크리에이터의 오디오/시리즈는 제외한다.
|
||||||
|
- 비회원은 19금 오디오/시리즈를 제외한다.
|
||||||
|
- 인증 회원은 `MemberContentPreferenceService`의 기존 성인 콘텐츠 노출 가능 여부를 반영한다.
|
||||||
|
- 이미지 경로는 기존 `v2.common.domain.CdnUrlExtensions`의 CDN URL 변환 패턴을 따른다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 차단 관계가 있는 크리에이터의 시리즈에 속한 오디오도 조회 대상에서 제외한다.
|
||||||
|
- 예약 공개 전 오디오는 모든 구분의 목록, 개수, 정렬 대표값, 매출 집계에서 제외한다.
|
||||||
|
- 비활성 크리에이터의 콘텐츠는 모든 구분에서 제외한다.
|
||||||
|
|
||||||
|
### Feature C. 오디오 구분
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- `type=AUDIO`는 차단 관계가 아닌 모든 크리에이터의 공개 오디오 콘텐츠를 조회한다.
|
||||||
|
- 전체 개수는 같은 공개/차단/성인 콘텐츠 조건을 적용한 오디오 콘텐츠 개수다.
|
||||||
|
- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다.
|
||||||
|
- 응답 item은 기존 추천 탭의 `AudioCardResponse` 필드 의미를 우선 재사용한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 시리즈에 속하지 않은 오디오도 목록에 포함한다.
|
||||||
|
- 오디오의 오리지널 여부는 기존 추천 탭과 동일하게 해당 오디오가 속한 시리즈의 `isOriginal` 기준으로 판단한다.
|
||||||
|
|
||||||
|
### Feature D. 시리즈 구분
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- `type=SERIES`는 차단 관계가 아닌 모든 크리에이터의 요일별 시리즈 콘텐츠를 조회한다.
|
||||||
|
- 활성 시리즈를 조회 대상으로 한다.
|
||||||
|
- `dayOfWeek`가 있으면 `series.publishedDaysOfWeek`에 해당 값이 포함된 시리즈만 조회한다.
|
||||||
|
- 요일 필터는 legacy `GET /audio-content/series/main/day-of-week`와 동일하게 query parameter 이름 `dayOfWeek`와 `SeriesPublishedDaysOfWeek` enum 값을 사용한다.
|
||||||
|
- `dayOfWeek`가 없으면 요일 조건 없이 전체 시리즈를 조회한다.
|
||||||
|
- 전체 개수는 같은 공개/차단/성인 콘텐츠 조건을 적용한 시리즈 개수다.
|
||||||
|
- 응답 목록은 `series`에 내려주고 `audios`는 빈 배열로 내려준다.
|
||||||
|
- 시리즈 제목은 호출 유저 언어코드에 맞는 번역값이 있으면 번역명을 사용하고, 없으면 원문 시리즈명을 사용한다.
|
||||||
|
- 응답 최상위 `dayOfWeek`에는 실제 적용된 요일 값을 내려준다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 콘텐츠가 없는 활성 시리즈는 시리즈 목록에 포함할 수 있다.
|
||||||
|
- `dayOfWeek=RANDOM` 요청은 legacy와 동일하게 `SeriesPublishedDaysOfWeek.RANDOM`이 포함된 시리즈만 조회한다.
|
||||||
|
- `dayOfWeek`가 지원 enum 값이 아니면 400 오류 대신 요일 조건을 적용하지 않는 fallback을 기본안으로 한다.
|
||||||
|
|
||||||
|
### Feature E. 오리지널 구분
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- `type=ORIGINAL`은 차단 관계가 아닌 모든 크리에이터의 `isOriginal == true`인 시리즈를 조회한다.
|
||||||
|
- 정렬, 페이징, 전체 개수, 성인 콘텐츠 정책은 `SERIES`와 동일하다.
|
||||||
|
- 단, `dayOfWeek` 요일 필터는 `type=ORIGINAL`에 적용하지 않는다.
|
||||||
|
- 응답 목록은 `series`에 내려주고 `audios`는 빈 배열로 내려준다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 오리지널 시리즈에 공개 가능한 오디오 콘텐츠가 없어도 활성 시리즈이면 목록에 포함한다.
|
||||||
|
- 19금 오리지널 시리즈는 조회자의 성인 콘텐츠 노출 가능 여부를 따른다.
|
||||||
|
|
||||||
|
### Feature F. 무료 구분
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- `type=FREE`는 차단 관계가 아닌 모든 크리에이터의 무료 오디오 콘텐츠를 조회한다.
|
||||||
|
- 무료 오디오는 `price == 0`인 공개 오디오로 정의한다.
|
||||||
|
- 정렬, 페이징, 전체 개수, 성인 콘텐츠 정책은 `AUDIO`와 동일하다.
|
||||||
|
- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 무료 콘텐츠의 `PRICE_HIGH`와 `PRICE_LOW`는 가격이 모두 0일 수 있으므로 2차/3차 정렬인 `releaseDate desc`, `id desc`가 실제 순서를 결정할 수 있다.
|
||||||
|
|
||||||
|
### Feature G. 포인트 구분
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- `type=POINT`는 차단 관계가 아닌 모든 크리에이터의 포인트 사용 가능 오디오 콘텐츠를 조회한다.
|
||||||
|
- 포인트 오디오는 `isPointAvailable == true`인 공개 오디오로 정의한다.
|
||||||
|
- 정렬, 페이징, 전체 개수, 성인 콘텐츠 정책은 `AUDIO`와 동일하다.
|
||||||
|
- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 포인트 사용 가능 여부는 결제 가능 여부 필터일 뿐이며, 인기순 매출 산식에는 포인트 사용액을 포함하지 않는다.
|
||||||
|
|
||||||
|
### Feature H. 콘텐츠 정렬
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 정렬 순서는 기존 공용 `ContentSort` enum을 사용한다.
|
||||||
|
- 공개 요청/응답 값은 다음을 사용한다.
|
||||||
|
- `LATEST`: 최신순, 기본값
|
||||||
|
- `POPULAR`: 인기순
|
||||||
|
- `PRICE_HIGH`: 높은 가격순
|
||||||
|
- `PRICE_LOW`: 낮은 가격순
|
||||||
|
- `LATEST`는 `releaseDate desc`, `id desc` 순으로 정렬한다.
|
||||||
|
- `POPULAR`은 인기순 매출 내림차순, `releaseDate desc`, `id desc` 순으로 정렬한다.
|
||||||
|
- 인기순의 매출은 대여/소장 여부와 관계없이 해당 콘텐츠에 순수하게 결제된 캔 매출 합계(`orders.can`)를 기준으로 한다.
|
||||||
|
- 인기순 매출에는 포인트 사용액(`orders.point`)을 포함하지 않는다.
|
||||||
|
- 인기순 매출에는 `orders.isActive == true`인 주문만 포함한다.
|
||||||
|
- `PRICE_HIGH`는 `price desc`, `releaseDate desc`, `id desc` 순으로 정렬한다.
|
||||||
|
- `PRICE_LOW`는 `price asc`, `releaseDate desc`, `id desc` 순으로 정렬한다.
|
||||||
|
- 시리즈 정렬에서 `releaseDate`는 시리즈에 속한 공개 오디오 콘텐츠 중 가장 최근 `releaseDate`를 대표값으로 사용한다.
|
||||||
|
- 시리즈 정렬에서 `price desc`는 시리즈에 속한 공개 오디오 콘텐츠 중 가장 높은 가격을 대표값으로 사용한다.
|
||||||
|
- 시리즈 정렬에서 `price asc`는 시리즈에 속한 공개 오디오 콘텐츠 중 가장 낮은 가격을 대표값으로 사용한다.
|
||||||
|
- 시리즈 인기순 매출은 시리즈에 속한 공개 오디오 콘텐츠의 `orders.can` 합계를 사용한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 매출이 없는 오디오 또는 시리즈의 인기순 매출값은 0으로 처리한다.
|
||||||
|
- 콘텐츠가 없는 시리즈는 정렬 대표값이 없는 항목으로 처리해 같은 정렬 내 마지막에 노출한다.
|
||||||
|
- 가격이 같은 콘텐츠는 각 정렬의 2차/3차 기준을 따른다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. API Endpoint
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v2/audio/contents?type=AUDIO&sort=LATEST&page=0&size=20
|
||||||
|
Authorization: Bearer {accessToken} (optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 비회원 조회를 허용한다.
|
||||||
|
- `SecurityConfig`에 `GET /api/v2/audio/contents` permitAll 설정을 추가한다.
|
||||||
|
- `type` 미지정 시 `AUDIO`를 기본값으로 사용한다.
|
||||||
|
- `sort` 미지정 또는 invalid 값은 `LATEST`로 fallback한다.
|
||||||
|
- `type=SERIES`에서 요일 선택이 필요하면 `dayOfWeek`를 함께 보낸다.
|
||||||
|
- 예: `GET /api/v2/audio/contents?type=SERIES&dayOfWeek=MON&sort=LATEST&page=0&size=20`
|
||||||
|
- `page`, `size`는 기존 크리에이터 채널 오디오/시리즈 탭과 같은 보정 정책을 따른다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Response Data Class
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
data class MainContentAllTabResponse(
|
||||||
|
val type: MainContentAllType,
|
||||||
|
val totalCount: Int,
|
||||||
|
val audios: List<MainContentAudioResponse>,
|
||||||
|
val series: List<MainContentSeriesResponse>,
|
||||||
|
val sort: ContentSort,
|
||||||
|
val dayOfWeek: SeriesPublishedDaysOfWeek?,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
@JsonProperty("hasNext")
|
||||||
|
val hasNext: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class MainContentAllType {
|
||||||
|
AUDIO,
|
||||||
|
SERIES,
|
||||||
|
ORIGINAL,
|
||||||
|
FREE,
|
||||||
|
POINT
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MainContentAudioResponse(
|
||||||
|
val audioContentId: Long,
|
||||||
|
val title: String,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val price: Int,
|
||||||
|
@JsonProperty("isAdult")
|
||||||
|
val isAdult: Boolean,
|
||||||
|
@JsonProperty("isPointAvailable")
|
||||||
|
val isPointAvailable: Boolean,
|
||||||
|
@JsonProperty("isFirstContent")
|
||||||
|
val isFirstContent: Boolean,
|
||||||
|
@JsonProperty("isOriginalSeries")
|
||||||
|
val isOriginalSeries: Boolean,
|
||||||
|
val creatorNickname: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MainContentSeriesResponse(
|
||||||
|
val seriesId: Long,
|
||||||
|
val title: String,
|
||||||
|
val coverImageUrl: String?,
|
||||||
|
val creatorNickname: String,
|
||||||
|
@JsonProperty("isOriginal")
|
||||||
|
val isOriginal: Boolean,
|
||||||
|
@JsonProperty("isAdult")
|
||||||
|
val isAdult: Boolean
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Technical Constraints
|
||||||
|
|
||||||
|
### 패키지 구조
|
||||||
|
- 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.content.all` 하위에 둔다.
|
||||||
|
- Controller: `...adapter.in.web`
|
||||||
|
- Facade: `...application`
|
||||||
|
- Response DTO: `...dto`
|
||||||
|
- 도메인 조회 계층은 `kr.co.vividnext.sodalive.v2.content.all` 하위에 둔다.
|
||||||
|
- Query service: `...application`
|
||||||
|
- 조회 정책/domain model: `...domain`
|
||||||
|
- 조회 port: `...port.out`
|
||||||
|
- QueryDSL/JPA 구현: `...adapter.out.persistence`
|
||||||
|
- 의존 방향은 `v2.api.content.all -> v2.content.all`만 허용한다.
|
||||||
|
- 도메인 패키지는 `kr.co.vividnext.sodalive.v2.api.*` 패키지에 의존하지 않는다.
|
||||||
|
|
||||||
|
### V2 공통화/재사용 대상
|
||||||
|
- `v2.common.domain.ContentSort`: 정렬 enum 재사용
|
||||||
|
- `creator.admin.content.series.SeriesPublishedDaysOfWeek`: legacy와 같은 요일 query parameter enum 재사용
|
||||||
|
- `content.series.main.SeriesMainController.getDayOfWeekSeriesList(...)`: legacy 요일별 시리즈 조회 API 계약 참고
|
||||||
|
- `content.series.ContentSeriesService.getDayOfWeekSeriesList(...)`: legacy 요일별 시리즈 조회 service 흐름 참고
|
||||||
|
- `v2.api.content.recommendation.adapter.in.web.AudioRecommendationController`: 비회원 허용 controller와 `ApiResponse.ok(...)` 패턴
|
||||||
|
- `v2.api.content.recommendation.application.AudioRecommendationFacade`: API 조립 계층에서 domain 결과를 response DTO로 변환하는 패턴
|
||||||
|
- `v2.content.recommendation.application.AudioRecommendationQueryService`: 회원 성인 콘텐츠 노출 가능 여부 계산과 전체 추천 조회 service 흐름
|
||||||
|
- `v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepository`: 전체 공개 오디오 조회, 차단 크리에이터 제외, CDN URL 변환, `AudioCard` 조립 패턴
|
||||||
|
- `v2.api.content.recommendation.dto.AudioCardResponse`: 오디오 카드 응답 필드와 `JsonProperty` 네이밍 패턴
|
||||||
|
- `v2.api.creator.channel.series.dto.CreatorChannelSeriesResponse`: 시리즈 응답 필드와 `JsonProperty` 네이밍 패턴 참고
|
||||||
|
- `v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicy`: `sort`, `page`, `size` fallback 정책 참고
|
||||||
|
- `v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepository`: 시리즈 정렬 대표값, 시리즈 콘텐츠 통계, `orders.can` 매출 합산 패턴 참고
|
||||||
|
- `v2.common.domain.CdnUrlExtensions`: 이미지 URL 변환 공통 함수
|
||||||
|
- `MemberContentPreferenceService`: 성인 콘텐츠 노출 가능 여부 판단
|
||||||
|
- `LangContext`: 시리즈 제목 다국어 처리
|
||||||
|
|
||||||
|
### 구현 주의사항
|
||||||
|
- 기존 추천 탭의 무료/포인트 오디오는 랜덤 조회지만, 전체 탭은 사용자가 선택한 `sort` 기준으로 조회한다.
|
||||||
|
- 기존 legacy 요일별 시리즈 API는 `dayOfWeek` query parameter로 `SeriesPublishedDaysOfWeek` enum을 받으므로 v2 전체 탭도 같은 parameter 이름과 enum 값을 사용한다.
|
||||||
|
- 기존 v2 채널 오디오/시리즈 탭처럼 invalid parameter fallback을 유지하려면 controller에서는 `dayOfWeek: String?`으로 받고 policy/service 경계에서 `SeriesPublishedDaysOfWeek`로 보정한다.
|
||||||
|
- 기존 채널 오디오/시리즈 탭의 `OWNED` 정렬은 전체 탭 요구사항에 포함하지 않으므로 전체 탭 policy에서 제외하거나 `LATEST`로 fallback한다.
|
||||||
|
- `POPULAR` 정렬은 기존 채널 탭 코드와 유사하되, 명시적으로 `orders.point`를 더하지 않고 `orders.can`만 집계한다.
|
||||||
|
- 오디오와 시리즈가 다른 응답 item 구조를 가지므로 최상위 응답은 `audios`와 `series`를 분리한다.
|
||||||
|
- 신규 Entity나 DDL은 필요하지 않다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Metrics
|
||||||
|
- 전체 탭 API 성공/실패 건수
|
||||||
|
- 전체 탭 API 응답 시간
|
||||||
|
- `type`별 조회 건수
|
||||||
|
- `sort`별 조회 건수
|
||||||
|
- 추가 로딩 요청 건수
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Open Questions
|
||||||
|
- 없음. endpoint는 기존 메인 콘텐츠 v2 endpoint 축에 맞춰 `GET /api/v2/audio/contents`로 확정한다.
|
||||||
@@ -103,6 +103,7 @@ class SecurityConfig(
|
|||||||
.antMatchers(HttpMethod.POST, "/charge/payverse/webhook").permitAll()
|
.antMatchers(HttpMethod.POST, "/charge/payverse/webhook").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations").permitAll()
|
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/api/v2/audio/recommendations").permitAll()
|
.antMatchers(HttpMethod.GET, "/api/v2/audio/recommendations").permitAll()
|
||||||
|
.antMatchers(HttpMethod.GET, "/api/v2/audio/contents").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/api/v2/audio/rankings").permitAll()
|
.antMatchers(HttpMethod.GET, "/api/v2/audio/rankings").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/api/v2/home/rankings/creators").permitAll()
|
.antMatchers(HttpMethod.GET, "/api/v2/home/rankings/creators").permitAll()
|
||||||
// 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수
|
// 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.content.all.adapter.`in`.web
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacade
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
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/audio/contents")
|
||||||
|
class MainContentAllController(
|
||||||
|
private val facade: MainContentAllFacade
|
||||||
|
) {
|
||||||
|
@GetMapping
|
||||||
|
fun getContents(
|
||||||
|
@RequestParam(required = false) type: String?,
|
||||||
|
@RequestParam(required = false) sort: String?,
|
||||||
|
@RequestParam(required = false) dayOfWeek: String?,
|
||||||
|
@RequestParam(required = false) page: Int?,
|
||||||
|
@RequestParam(required = false) size: Int?,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
ApiResponse.ok(
|
||||||
|
facade.getContents(
|
||||||
|
type = type,
|
||||||
|
sort = sort,
|
||||||
|
dayOfWeek = dayOfWeek,
|
||||||
|
page = page,
|
||||||
|
size = size,
|
||||||
|
member = member
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.content.all.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryService
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class MainContentAllFacade(
|
||||||
|
private val queryService: MainContentAllQueryService
|
||||||
|
) {
|
||||||
|
fun getContents(
|
||||||
|
type: String?,
|
||||||
|
sort: String?,
|
||||||
|
dayOfWeek: String?,
|
||||||
|
page: Int?,
|
||||||
|
size: Int?,
|
||||||
|
member: Member?
|
||||||
|
): MainContentAllTabResponse {
|
||||||
|
return MainContentAllTabResponse.from(
|
||||||
|
queryService.getContents(
|
||||||
|
type = type,
|
||||||
|
sort = sort,
|
||||||
|
dayOfWeek = dayOfWeek,
|
||||||
|
page = page,
|
||||||
|
size = size,
|
||||||
|
member = member
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.content.all.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAll
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllAudio
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllSeries
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllType
|
||||||
|
|
||||||
|
data class MainContentAllTabResponse(
|
||||||
|
val type: MainContentAllType,
|
||||||
|
val totalCount: Int,
|
||||||
|
val audios: List<MainContentAudioResponse>,
|
||||||
|
val series: List<MainContentSeriesResponse>,
|
||||||
|
val sort: ContentSort,
|
||||||
|
val dayOfWeek: SeriesPublishedDaysOfWeek?,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
@JsonProperty("hasNext")
|
||||||
|
val hasNext: Boolean
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(tab: MainContentAll): MainContentAllTabResponse {
|
||||||
|
return MainContentAllTabResponse(
|
||||||
|
type = tab.type,
|
||||||
|
totalCount = tab.totalCount,
|
||||||
|
audios = tab.audios.map(MainContentAudioResponse::from),
|
||||||
|
series = tab.series.map(MainContentSeriesResponse::from),
|
||||||
|
sort = tab.sort,
|
||||||
|
dayOfWeek = tab.dayOfWeek,
|
||||||
|
page = tab.page.page,
|
||||||
|
size = tab.page.size,
|
||||||
|
hasNext = tab.hasNext
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MainContentAudioResponse(
|
||||||
|
val audioContentId: Long,
|
||||||
|
val title: String,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val price: Int,
|
||||||
|
@JsonProperty("isAdult")
|
||||||
|
val isAdult: Boolean,
|
||||||
|
@JsonProperty("isPointAvailable")
|
||||||
|
val isPointAvailable: Boolean,
|
||||||
|
@JsonProperty("isFirstContent")
|
||||||
|
val isFirstContent: Boolean,
|
||||||
|
@JsonProperty("isOriginalSeries")
|
||||||
|
val isOriginalSeries: Boolean,
|
||||||
|
val creatorNickname: String
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(audio: MainContentAllAudio): MainContentAudioResponse {
|
||||||
|
return MainContentAudioResponse(
|
||||||
|
audioContentId = audio.audioContentId,
|
||||||
|
title = audio.title,
|
||||||
|
imageUrl = audio.imageUrl,
|
||||||
|
price = audio.price,
|
||||||
|
isAdult = audio.isAdult,
|
||||||
|
isPointAvailable = audio.isPointAvailable,
|
||||||
|
isFirstContent = audio.isFirstContent,
|
||||||
|
isOriginalSeries = audio.isOriginalSeries,
|
||||||
|
creatorNickname = audio.creatorNickname
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MainContentSeriesResponse(
|
||||||
|
val seriesId: Long,
|
||||||
|
val title: String,
|
||||||
|
val coverImageUrl: String?,
|
||||||
|
val creatorNickname: String,
|
||||||
|
@JsonProperty("isOriginal")
|
||||||
|
val isOriginal: Boolean,
|
||||||
|
@JsonProperty("isAdult")
|
||||||
|
val isAdult: Boolean
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(series: MainContentAllSeries): MainContentSeriesResponse {
|
||||||
|
return MainContentSeriesResponse(
|
||||||
|
seriesId = series.seriesId,
|
||||||
|
title = series.title,
|
||||||
|
coverImageUrl = series.coverImageUrl,
|
||||||
|
creatorNickname = series.creatorNickname,
|
||||||
|
isOriginal = series.isOriginal,
|
||||||
|
isAdult = series.isAdult
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence
|
||||||
|
|
||||||
|
import com.querydsl.core.Tuple
|
||||||
|
import com.querydsl.core.types.Expression
|
||||||
|
import com.querydsl.core.types.dsl.BooleanExpression
|
||||||
|
import com.querydsl.core.types.dsl.CaseBuilder
|
||||||
|
import com.querydsl.jpa.JPAExpressions
|
||||||
|
import com.querydsl.jpa.impl.JPAQuery
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||||
|
import kr.co.vividnext.sodalive.content.order.QOrder
|
||||||
|
import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation
|
||||||
|
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme
|
||||||
|
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
|
||||||
|
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.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
import kr.co.vividnext.sodalive.member.QMember
|
||||||
|
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.common.domain.toCdnUrl
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllAudio
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllSeries
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class DefaultMainContentAllQueryRepository(
|
||||||
|
private val queryFactory: JPAQueryFactory,
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val cloudFrontHost: String
|
||||||
|
) : MainContentAllQueryRepository {
|
||||||
|
override fun countAudios(
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
onlyFree: Boolean,
|
||||||
|
onlyPointAvailable: Boolean
|
||||||
|
): Int {
|
||||||
|
return queryFactory
|
||||||
|
.select(audioContent.id.count())
|
||||||
|
.from(audioContent)
|
||||||
|
.join(audioContent.member, member)
|
||||||
|
.join(audioContent.theme, audioContentTheme)
|
||||||
|
.where(audioCondition(memberId, canViewAdultContent, now, onlyFree, onlyPointAvailable))
|
||||||
|
.fetchOne()
|
||||||
|
?.toInt()
|
||||||
|
?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findAudios(
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
sort: ContentSort,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
onlyFree: Boolean,
|
||||||
|
onlyPointAvailable: Boolean
|
||||||
|
): List<MainContentAllAudio> {
|
||||||
|
val rows = findAudioRows(memberId, canViewAdultContent, now, sort, offset, limit, onlyFree, onlyPointAvailable)
|
||||||
|
if (rows.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val contentIds = rows.map { it.get(audioContent.id)!! }
|
||||||
|
val creatorIds = rows.map { it.get(audioContent.member.id)!! }.distinct()
|
||||||
|
val firstContentIdByCreatorId = firstAudioContentIds(creatorIds, now, canViewAdultContent)
|
||||||
|
val originalSeriesByContentId = originalSeriesFlags(contentIds)
|
||||||
|
|
||||||
|
return rows.map { row ->
|
||||||
|
val contentId = row.get(audioContent.id)!!
|
||||||
|
val creatorId = row.get(audioContent.member.id)!!
|
||||||
|
MainContentAllAudio(
|
||||||
|
audioContentId = contentId,
|
||||||
|
title = row.get(audioContent.title)!!,
|
||||||
|
imageUrl = row.get(audioContent.coverImage).toCdnUrl(cloudFrontHost),
|
||||||
|
price = row.get(audioContent.price)!!,
|
||||||
|
isAdult = row.get(audioContent.isAdult)!!,
|
||||||
|
isPointAvailable = row.get(audioContent.isPointAvailable)!!,
|
||||||
|
isFirstContent = firstContentIdByCreatorId[creatorId] == contentId,
|
||||||
|
isOriginalSeries = originalSeriesByContentId[contentId] ?: false,
|
||||||
|
creatorNickname = row.get(member.nickname)!!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun countSeries(
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
onlyOriginal: Boolean,
|
||||||
|
dayOfWeek: SeriesPublishedDaysOfWeek?
|
||||||
|
): Int {
|
||||||
|
return queryFactory
|
||||||
|
.select(series.id.count())
|
||||||
|
.from(series)
|
||||||
|
.join(series.member, member)
|
||||||
|
.where(seriesCondition(memberId, canViewAdultContent, onlyOriginal, dayOfWeek))
|
||||||
|
.fetchOne()
|
||||||
|
?.toInt()
|
||||||
|
?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findSeries(
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
sort: ContentSort,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
onlyOriginal: Boolean,
|
||||||
|
dayOfWeek: SeriesPublishedDaysOfWeek?,
|
||||||
|
locale: String
|
||||||
|
): List<MainContentAllSeries> {
|
||||||
|
val seriesIds = findSeriesIds(memberId, canViewAdultContent, now, sort, offset, limit, onlyOriginal, dayOfWeek)
|
||||||
|
if (seriesIds.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val seriesTranslation = QSeriesTranslation("mainContentAllSeriesTranslation")
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
series.id,
|
||||||
|
series.title,
|
||||||
|
seriesTranslation.renderedPayload,
|
||||||
|
series.coverImage,
|
||||||
|
member.nickname,
|
||||||
|
series.isOriginal,
|
||||||
|
series.isAdult
|
||||||
|
)
|
||||||
|
.from(series)
|
||||||
|
.join(series.member, member)
|
||||||
|
.leftJoin(seriesTranslation)
|
||||||
|
.on(
|
||||||
|
seriesTranslation.seriesId.eq(series.id),
|
||||||
|
seriesTranslation.locale.eq(locale)
|
||||||
|
)
|
||||||
|
.where(series.id.`in`(seriesIds))
|
||||||
|
.fetch()
|
||||||
|
.sortedBy { seriesIds.indexOf(it.get(series.id)!!) }
|
||||||
|
.map { row ->
|
||||||
|
val translatedTitle = row.get(seriesTranslation.renderedPayload)?.title
|
||||||
|
MainContentAllSeries(
|
||||||
|
seriesId = row.get(series.id)!!,
|
||||||
|
title = translatedTitle.takeUnless(String?::isNullOrBlank) ?: row.get(series.title)!!,
|
||||||
|
coverImageUrl = row.get(series.coverImage).toCdnUrl(cloudFrontHost),
|
||||||
|
creatorNickname = row.get(member.nickname)!!,
|
||||||
|
isOriginal = row.get(series.isOriginal)!!,
|
||||||
|
isAdult = row.get(series.isAdult)!!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findAudioRows(
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
sort: ContentSort,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
onlyFree: Boolean,
|
||||||
|
onlyPointAvailable: Boolean
|
||||||
|
): List<Tuple> {
|
||||||
|
val query = queryFactory
|
||||||
|
.select(
|
||||||
|
audioContent.id,
|
||||||
|
audioContent.title,
|
||||||
|
audioContent.coverImage,
|
||||||
|
audioContent.price,
|
||||||
|
audioContent.isAdult,
|
||||||
|
audioContent.isPointAvailable,
|
||||||
|
audioContent.member.id,
|
||||||
|
member.nickname,
|
||||||
|
audioContent.releaseDate
|
||||||
|
)
|
||||||
|
.from(audioContent)
|
||||||
|
.join(audioContent.member, member)
|
||||||
|
.join(audioContent.theme, audioContentTheme)
|
||||||
|
.where(audioCondition(memberId, canViewAdultContent, now, onlyFree, onlyPointAvailable))
|
||||||
|
|
||||||
|
when (sort) {
|
||||||
|
ContentSort.POPULAR -> {
|
||||||
|
val revenueOrder = QOrder("mainContentAllAudioRevenueOrder")
|
||||||
|
query
|
||||||
|
.leftJoin(revenueOrder)
|
||||||
|
.on(
|
||||||
|
revenueOrder.audioContent.id.eq(audioContent.id),
|
||||||
|
revenueOrder.isActive.isTrue
|
||||||
|
)
|
||||||
|
.groupByAudioRow()
|
||||||
|
.orderBy(
|
||||||
|
revenueOrder.can.sum().coalesce(0).desc(),
|
||||||
|
audioContent.releaseDate.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()
|
||||||
|
)
|
||||||
|
ContentSort.LATEST,
|
||||||
|
ContentSort.OWNED -> query.orderBy(
|
||||||
|
audioContent.releaseDate.desc(),
|
||||||
|
audioContent.id.desc()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.offset(offset).limit(limit.toLong()).fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findSeriesIds(
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
sort: ContentSort,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
onlyOriginal: Boolean,
|
||||||
|
dayOfWeek: SeriesPublishedDaysOfWeek?
|
||||||
|
): List<Long> {
|
||||||
|
val audioCreator = QMember("mainContentAllSeriesAudioCreator")
|
||||||
|
val audioTheme = QAudioContentTheme("mainContentAllSeriesAudioTheme")
|
||||||
|
val revenueOrder = QOrder("mainContentAllSeriesRevenueOrder")
|
||||||
|
val publicSeriesAudioCondition = publicSeriesAudioCondition(canViewAdultContent, now, audioCreator, audioTheme)
|
||||||
|
val latestReleaseDate = CaseBuilder()
|
||||||
|
.`when`(publicSeriesAudioCondition)
|
||||||
|
.then(audioContent.releaseDate)
|
||||||
|
.otherwise(null as LocalDateTime?)
|
||||||
|
.max()
|
||||||
|
val highestPrice = CaseBuilder()
|
||||||
|
.`when`(publicSeriesAudioCondition)
|
||||||
|
.then(audioContent.price)
|
||||||
|
.otherwise(null as Int?)
|
||||||
|
.max()
|
||||||
|
val lowestPrice = CaseBuilder()
|
||||||
|
.`when`(publicSeriesAudioCondition)
|
||||||
|
.then(audioContent.price)
|
||||||
|
.otherwise(null as Int?)
|
||||||
|
.min()
|
||||||
|
val revenue = CaseBuilder()
|
||||||
|
.`when`(publicSeriesAudioCondition)
|
||||||
|
.then(revenueOrder.can)
|
||||||
|
.otherwise(0)
|
||||||
|
.sum()
|
||||||
|
.coalesce(0)
|
||||||
|
val latestReleaseDateNullLast = CaseBuilder().`when`(latestReleaseDate.isNull).then(1).otherwise(0)
|
||||||
|
val highestPriceNullLast = CaseBuilder().`when`(highestPrice.isNull).then(1).otherwise(0)
|
||||||
|
val lowestPriceNullLast = CaseBuilder().`when`(lowestPrice.isNull).then(1).otherwise(0)
|
||||||
|
|
||||||
|
val query = queryFactory
|
||||||
|
.select(series.id)
|
||||||
|
.from(series)
|
||||||
|
.join(series.member, member)
|
||||||
|
.leftJoin(seriesContent).on(seriesContent.series.id.eq(series.id))
|
||||||
|
.leftJoin(audioContent).on(seriesContent.content.id.eq(audioContent.id))
|
||||||
|
.leftJoin(audioContent.member, audioCreator)
|
||||||
|
.leftJoin(audioContent.theme, audioTheme)
|
||||||
|
.where(seriesCondition(memberId, canViewAdultContent, onlyOriginal, dayOfWeek))
|
||||||
|
.groupBy(series.id)
|
||||||
|
|
||||||
|
when (sort) {
|
||||||
|
ContentSort.POPULAR ->
|
||||||
|
query
|
||||||
|
.leftJoin(revenueOrder)
|
||||||
|
.on(
|
||||||
|
revenueOrder.audioContent.id.eq(audioContent.id),
|
||||||
|
revenueOrder.isActive.isTrue
|
||||||
|
)
|
||||||
|
.orderBy(revenue.desc(), latestReleaseDate.desc(), series.id.desc())
|
||||||
|
ContentSort.PRICE_HIGH -> query.orderBy(
|
||||||
|
highestPriceNullLast.asc(),
|
||||||
|
highestPrice.desc(),
|
||||||
|
latestReleaseDate.desc(),
|
||||||
|
series.id.desc()
|
||||||
|
)
|
||||||
|
ContentSort.PRICE_LOW -> query.orderBy(
|
||||||
|
lowestPriceNullLast.asc(),
|
||||||
|
lowestPrice.asc(),
|
||||||
|
latestReleaseDate.desc(),
|
||||||
|
series.id.desc()
|
||||||
|
)
|
||||||
|
ContentSort.LATEST,
|
||||||
|
ContentSort.OWNED -> query.orderBy(
|
||||||
|
latestReleaseDateNullLast.asc(),
|
||||||
|
latestReleaseDate.desc(),
|
||||||
|
series.id.desc()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.offset(offset).limit(limit.toLong()).fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JPAQuery<Tuple>.groupByAudioRow(): JPAQuery<Tuple> {
|
||||||
|
return groupBy(
|
||||||
|
audioContent.id,
|
||||||
|
audioContent.title,
|
||||||
|
audioContent.coverImage,
|
||||||
|
audioContent.price,
|
||||||
|
audioContent.isAdult,
|
||||||
|
audioContent.isPointAvailable,
|
||||||
|
audioContent.member.id,
|
||||||
|
member.nickname,
|
||||||
|
audioContent.releaseDate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun firstAudioContentIds(
|
||||||
|
creatorIds: List<Long>,
|
||||||
|
now: LocalDateTime,
|
||||||
|
canViewAdultContent: Boolean
|
||||||
|
): Map<Long, Long> {
|
||||||
|
return creatorIds.associateWith { creatorId ->
|
||||||
|
queryFactory
|
||||||
|
.select(audioContent.id)
|
||||||
|
.from(audioContent)
|
||||||
|
.join(audioContent.member, member)
|
||||||
|
.join(audioContent.theme, audioContentTheme)
|
||||||
|
.where(
|
||||||
|
audioContent.member.id.eq(creatorId),
|
||||||
|
publicAudioCondition(canViewAdultContent, now)
|
||||||
|
)
|
||||||
|
.orderBy(audioContent.releaseDate.asc(), audioContent.id.asc())
|
||||||
|
.fetchFirst()
|
||||||
|
}.filterValues { it != null }.mapValues { it.value!! }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun originalSeriesFlags(contentIds: List<Long>): Map<Long, Boolean> {
|
||||||
|
if (contentIds.isEmpty()) return emptyMap()
|
||||||
|
return queryFactory
|
||||||
|
.select(seriesContent.content.id, series.isOriginal)
|
||||||
|
.from(seriesContent)
|
||||||
|
.join(seriesContent.series, series)
|
||||||
|
.where(seriesContent.content.id.`in`(contentIds))
|
||||||
|
.fetch()
|
||||||
|
.associate { it.get(seriesContent.content.id)!! to it.get(series.isOriginal)!! }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun audioCondition(
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
onlyFree: Boolean,
|
||||||
|
onlyPointAvailable: Boolean
|
||||||
|
): BooleanExpression {
|
||||||
|
return publicAudioCondition(canViewAdultContent, now)
|
||||||
|
.and(optionalAudioFreeCondition(onlyFree))
|
||||||
|
.and(optionalAudioPointCondition(onlyPointAvailable))
|
||||||
|
.withOptionalAnd(notBlockedCreatorCondition(memberId, audioContent.member.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publicAudioCondition(canViewAdultContent: Boolean, now: LocalDateTime): BooleanExpression {
|
||||||
|
return audioContent.isActive.isTrue
|
||||||
|
.and(audioContent.duration.isNotNull)
|
||||||
|
.and(audioContent.releaseDate.isNotNull)
|
||||||
|
.and(audioContent.releaseDate.loe(now))
|
||||||
|
.and(audioContent.member.isActive.isTrue)
|
||||||
|
.and(audioContentTheme.isActive.isTrue)
|
||||||
|
.withOptionalAnd(adultAudioCondition(canViewAdultContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publicSeriesAudioCondition(
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
audioCreator: QMember,
|
||||||
|
audioTheme: QAudioContentTheme
|
||||||
|
): BooleanExpression {
|
||||||
|
return audioContent.isActive.isTrue
|
||||||
|
.and(audioContent.duration.isNotNull)
|
||||||
|
.and(audioContent.releaseDate.isNotNull)
|
||||||
|
.and(audioContent.releaseDate.loe(now))
|
||||||
|
.and(audioCreator.isActive.isTrue)
|
||||||
|
.and(audioTheme.isActive.isTrue)
|
||||||
|
.withOptionalAnd(adultAudioCondition(canViewAdultContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun seriesCondition(
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
onlyOriginal: Boolean,
|
||||||
|
dayOfWeek: SeriesPublishedDaysOfWeek?
|
||||||
|
): BooleanExpression {
|
||||||
|
return series.isActive.isTrue
|
||||||
|
.and(member.isActive.isTrue)
|
||||||
|
.and(optionalOriginalCondition(onlyOriginal))
|
||||||
|
.withOptionalAnd(dayOfWeekCondition(dayOfWeek))
|
||||||
|
.withOptionalAnd(adultSeriesCondition(canViewAdultContent))
|
||||||
|
.withOptionalAnd(notBlockedCreatorCondition(memberId, series.member.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun optionalAudioFreeCondition(onlyFree: Boolean): BooleanExpression? {
|
||||||
|
return if (onlyFree) audioContent.price.eq(0) else null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun optionalAudioPointCondition(onlyPointAvailable: Boolean): BooleanExpression? {
|
||||||
|
return if (onlyPointAvailable) audioContent.isPointAvailable.isTrue else null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun optionalOriginalCondition(onlyOriginal: Boolean): BooleanExpression? {
|
||||||
|
return if (onlyOriginal) series.isOriginal.isTrue else null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dayOfWeekCondition(dayOfWeek: SeriesPublishedDaysOfWeek?): BooleanExpression? {
|
||||||
|
return dayOfWeek?.let { series.publishedDaysOfWeek.contains(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
|
||||||
|
return if (canViewAdultContent) null else audioContent.isAdult.isFalse
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun adultSeriesCondition(canViewAdultContent: Boolean): BooleanExpression? {
|
||||||
|
return if (canViewAdultContent) null else series.isAdult.isFalse
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notBlockedCreatorCondition(memberId: Long?, creatorIdPath: Expression<Long>): BooleanExpression? {
|
||||||
|
if (memberId == null) return null
|
||||||
|
val blockMember = QBlockMember("mainContentAllBlockMember")
|
||||||
|
return JPAExpressions
|
||||||
|
.selectOne()
|
||||||
|
.from(blockMember)
|
||||||
|
.where(
|
||||||
|
blockMember.isActive.isTrue,
|
||||||
|
blockMember.member.id.eq(memberId).and(blockMember.blockedMember.id.eq(creatorIdPath))
|
||||||
|
.or(blockMember.member.id.eq(creatorIdPath).and(blockMember.blockedMember.id.eq(memberId)))
|
||||||
|
)
|
||||||
|
.notExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BooleanExpression.withOptionalAnd(condition: BooleanExpression?): BooleanExpression {
|
||||||
|
return if (condition == null) this else and(condition)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.port.out.MainContentAllQueryPort
|
||||||
|
|
||||||
|
interface MainContentAllQueryRepository : MainContentAllQueryPort
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.content.all.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAll
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicy
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllType
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentPage
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.port.out.MainContentAllQueryPort
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
class MainContentAllQueryService(
|
||||||
|
private val queryPort: MainContentAllQueryPort,
|
||||||
|
private val memberContentPreferenceService: MemberContentPreferenceService,
|
||||||
|
private val queryPolicy: MainContentAllQueryPolicy = MainContentAllQueryPolicy(),
|
||||||
|
private val langContext: LangContext
|
||||||
|
) {
|
||||||
|
fun getContents(
|
||||||
|
type: String?,
|
||||||
|
sort: String?,
|
||||||
|
dayOfWeek: String?,
|
||||||
|
page: Int?,
|
||||||
|
size: Int?,
|
||||||
|
member: Member?
|
||||||
|
): MainContentAll {
|
||||||
|
val resolvedType = queryPolicy.resolveType(type)
|
||||||
|
val resolvedSort = queryPolicy.resolveSort(sort)
|
||||||
|
val resolvedDayOfWeek = queryPolicy.resolveDayOfWeek(resolvedType, dayOfWeek)
|
||||||
|
val resolvedPage = queryPolicy.createPage(page, size)
|
||||||
|
val now = LocalDateTime.now()
|
||||||
|
val memberId = member?.id
|
||||||
|
val canViewAdultContent = canViewAdultContent(member)
|
||||||
|
|
||||||
|
return when (resolvedType) {
|
||||||
|
MainContentAllType.AUDIO -> getAudioContents(
|
||||||
|
type = resolvedType,
|
||||||
|
sort = resolvedSort,
|
||||||
|
dayOfWeek = resolvedDayOfWeek,
|
||||||
|
page = resolvedPage,
|
||||||
|
memberId = memberId,
|
||||||
|
canViewAdultContent = canViewAdultContent,
|
||||||
|
now = now
|
||||||
|
)
|
||||||
|
|
||||||
|
MainContentAllType.FREE -> getAudioContents(
|
||||||
|
type = resolvedType,
|
||||||
|
sort = resolvedSort,
|
||||||
|
dayOfWeek = resolvedDayOfWeek,
|
||||||
|
page = resolvedPage,
|
||||||
|
memberId = memberId,
|
||||||
|
canViewAdultContent = canViewAdultContent,
|
||||||
|
now = now,
|
||||||
|
onlyFree = true
|
||||||
|
)
|
||||||
|
|
||||||
|
MainContentAllType.POINT -> getAudioContents(
|
||||||
|
type = resolvedType,
|
||||||
|
sort = resolvedSort,
|
||||||
|
dayOfWeek = resolvedDayOfWeek,
|
||||||
|
page = resolvedPage,
|
||||||
|
memberId = memberId,
|
||||||
|
canViewAdultContent = canViewAdultContent,
|
||||||
|
now = now,
|
||||||
|
onlyPointAvailable = true
|
||||||
|
)
|
||||||
|
|
||||||
|
MainContentAllType.SERIES -> getSeriesContents(
|
||||||
|
type = resolvedType,
|
||||||
|
sort = resolvedSort,
|
||||||
|
dayOfWeek = resolvedDayOfWeek,
|
||||||
|
page = resolvedPage,
|
||||||
|
memberId = memberId,
|
||||||
|
canViewAdultContent = canViewAdultContent,
|
||||||
|
now = now
|
||||||
|
)
|
||||||
|
|
||||||
|
MainContentAllType.ORIGINAL -> getSeriesContents(
|
||||||
|
type = resolvedType,
|
||||||
|
sort = resolvedSort,
|
||||||
|
dayOfWeek = null,
|
||||||
|
page = resolvedPage,
|
||||||
|
memberId = memberId,
|
||||||
|
canViewAdultContent = canViewAdultContent,
|
||||||
|
now = now,
|
||||||
|
onlyOriginal = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getAudioContents(
|
||||||
|
type: MainContentAllType,
|
||||||
|
sort: ContentSort,
|
||||||
|
dayOfWeek: SeriesPublishedDaysOfWeek?,
|
||||||
|
page: MainContentPage,
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
onlyFree: Boolean = false,
|
||||||
|
onlyPointAvailable: Boolean = false
|
||||||
|
): MainContentAll {
|
||||||
|
val totalCount = queryPort.countAudios(memberId, canViewAdultContent, now, onlyFree, onlyPointAvailable)
|
||||||
|
val audios = queryPort.findAudios(
|
||||||
|
memberId = memberId,
|
||||||
|
canViewAdultContent = canViewAdultContent,
|
||||||
|
now = now,
|
||||||
|
sort = sort,
|
||||||
|
offset = page.offset,
|
||||||
|
limit = page.size + 1,
|
||||||
|
onlyFree = onlyFree,
|
||||||
|
onlyPointAvailable = onlyPointAvailable
|
||||||
|
)
|
||||||
|
|
||||||
|
return MainContentAll(
|
||||||
|
type = type,
|
||||||
|
totalCount = totalCount,
|
||||||
|
audios = queryPolicy.limitItems(audios, page),
|
||||||
|
series = emptyList(),
|
||||||
|
sort = sort,
|
||||||
|
dayOfWeek = dayOfWeek,
|
||||||
|
page = page,
|
||||||
|
hasNext = queryPolicy.hasNext(audios, page)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSeriesContents(
|
||||||
|
type: MainContentAllType,
|
||||||
|
sort: ContentSort,
|
||||||
|
dayOfWeek: SeriesPublishedDaysOfWeek?,
|
||||||
|
page: MainContentPage,
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
onlyOriginal: Boolean = false
|
||||||
|
): MainContentAll {
|
||||||
|
val totalCount = queryPort.countSeries(memberId, canViewAdultContent, now, onlyOriginal, dayOfWeek)
|
||||||
|
val series = queryPort.findSeries(
|
||||||
|
memberId = memberId,
|
||||||
|
canViewAdultContent = canViewAdultContent,
|
||||||
|
now = now,
|
||||||
|
sort = sort,
|
||||||
|
offset = page.offset,
|
||||||
|
limit = page.size + 1,
|
||||||
|
onlyOriginal = onlyOriginal,
|
||||||
|
dayOfWeek = dayOfWeek,
|
||||||
|
locale = langContext.lang.code
|
||||||
|
)
|
||||||
|
|
||||||
|
return MainContentAll(
|
||||||
|
type = type,
|
||||||
|
totalCount = totalCount,
|
||||||
|
audios = emptyList(),
|
||||||
|
series = queryPolicy.limitItems(series, page),
|
||||||
|
sort = sort,
|
||||||
|
dayOfWeek = dayOfWeek,
|
||||||
|
page = page,
|
||||||
|
hasNext = queryPolicy.hasNext(series, page)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun canViewAdultContent(member: Member?): Boolean {
|
||||||
|
if (member == null) return false
|
||||||
|
return memberContentPreferenceService.canViewAdultContent(member)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.content.all.domain
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||||
|
|
||||||
|
data class MainContentAll(
|
||||||
|
val type: MainContentAllType,
|
||||||
|
val totalCount: Int,
|
||||||
|
val audios: List<MainContentAllAudio>,
|
||||||
|
val series: List<MainContentAllSeries>,
|
||||||
|
val sort: ContentSort,
|
||||||
|
val dayOfWeek: SeriesPublishedDaysOfWeek?,
|
||||||
|
val page: MainContentPage,
|
||||||
|
val hasNext: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MainContentAllAudio(
|
||||||
|
val audioContentId: Long,
|
||||||
|
val title: String,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val price: Int,
|
||||||
|
val isAdult: Boolean,
|
||||||
|
val isPointAvailable: Boolean,
|
||||||
|
val isFirstContent: Boolean,
|
||||||
|
val isOriginalSeries: Boolean,
|
||||||
|
val creatorNickname: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MainContentAllSeries(
|
||||||
|
val seriesId: Long,
|
||||||
|
val title: String,
|
||||||
|
val coverImageUrl: String?,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val isOriginal: Boolean,
|
||||||
|
val isAdult: Boolean
|
||||||
|
)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.content.all.domain
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||||
|
|
||||||
|
class MainContentAllQueryPolicy {
|
||||||
|
fun resolveType(type: String?): MainContentAllType {
|
||||||
|
return type.toEnumOrNull<MainContentAllType>() ?: MainContentAllType.AUDIO
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resolveSort(sort: String?): ContentSort {
|
||||||
|
val resolved = sort.toEnumOrNull<ContentSort>() ?: ContentSort.LATEST
|
||||||
|
return if (resolved == ContentSort.OWNED) ContentSort.LATEST else resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resolveDayOfWeek(type: MainContentAllType, dayOfWeek: String?): SeriesPublishedDaysOfWeek? {
|
||||||
|
if (type != MainContentAllType.SERIES) return null
|
||||||
|
return dayOfWeek.toEnumOrNull<SeriesPublishedDaysOfWeek>()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createPage(page: Int?, size: Int?): MainContentPage {
|
||||||
|
return MainContentPage(
|
||||||
|
page = page?.coerceAtLeast(0) ?: DEFAULT_PAGE,
|
||||||
|
size = size?.coerceIn(MIN_SIZE, MAX_SIZE) ?: DEFAULT_SIZE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> limitItems(items: List<T>, page: MainContentPage): List<T> {
|
||||||
|
return items.take(page.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasNext(items: List<*>, page: MainContentPage): Boolean {
|
||||||
|
return items.size > page.size
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T : Enum<T>> String?.toEnumOrNull(): T? {
|
||||||
|
if (this == null) return null
|
||||||
|
return enumValues<T>().firstOrNull { it.name == this }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val DEFAULT_PAGE = 0
|
||||||
|
private const val DEFAULT_SIZE = 20
|
||||||
|
private const val MIN_SIZE = 20
|
||||||
|
private const val MAX_SIZE = 50
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.content.all.domain
|
||||||
|
|
||||||
|
enum class MainContentAllType {
|
||||||
|
AUDIO,
|
||||||
|
SERIES,
|
||||||
|
ORIGINAL,
|
||||||
|
FREE,
|
||||||
|
POINT
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.content.all.domain
|
||||||
|
|
||||||
|
data class MainContentPage(
|
||||||
|
val page: Int,
|
||||||
|
val size: Int
|
||||||
|
) {
|
||||||
|
val offset: Long = page.toLong() * size
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.content.all.port.out
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllAudio
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllSeries
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
interface MainContentAllQueryPort {
|
||||||
|
fun countAudios(
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
onlyFree: Boolean = false,
|
||||||
|
onlyPointAvailable: Boolean = false
|
||||||
|
): Int
|
||||||
|
|
||||||
|
fun findAudios(
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
sort: ContentSort,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
onlyFree: Boolean = false,
|
||||||
|
onlyPointAvailable: Boolean = false
|
||||||
|
): List<MainContentAllAudio>
|
||||||
|
|
||||||
|
fun countSeries(
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
onlyOriginal: Boolean = false,
|
||||||
|
dayOfWeek: SeriesPublishedDaysOfWeek? = null
|
||||||
|
): Int
|
||||||
|
|
||||||
|
fun findSeries(
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
sort: ContentSort,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
onlyOriginal: Boolean = false,
|
||||||
|
dayOfWeek: SeriesPublishedDaysOfWeek? = null,
|
||||||
|
locale: String
|
||||||
|
): List<MainContentAllSeries>
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.content.all.adapter.`in`.web
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.CountryContext
|
||||||
|
import kr.co.vividnext.sodalive.configs.SecurityConfig
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
|
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
|
||||||
|
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
|
||||||
|
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||||
|
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.content.all.application.MainContentAllFacade
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllType
|
||||||
|
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.mock.mockito.MockBean
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||||
|
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
|
||||||
|
|
||||||
|
@WebMvcTest(MainContentAllController::class)
|
||||||
|
@Import(SecurityConfig::class)
|
||||||
|
class MainContentAllControllerTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc
|
||||||
|
) {
|
||||||
|
@MockBean
|
||||||
|
private lateinit var facade: MainContentAllFacade
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var countryContext: CountryContext
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var langContext: LangContext
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var sodaMessageSource: SodaMessageSource
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var tokenProvider: TokenProvider
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var accessDeniedHandler: JwtAccessDeniedHandler
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var authenticationEntryPoint: JwtAuthenticationEntryPoint
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("전체 탭 조회는 비회원에게 200 OK와 기본 응답을 반환한다")
|
||||||
|
fun shouldAllowAnonymousAndUseDefaultType() {
|
||||||
|
Mockito.doReturn(response(MainContentAllType.AUDIO, ContentSort.LATEST)).`when`(facade)
|
||||||
|
.getContents(null, null, null, null, null, null)
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/v2/audio/contents"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.type").value("AUDIO"))
|
||||||
|
.andExpect(jsonPath("$.data.sort").value("LATEST"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("전체 탭 조회는 query parameter와 인증 회원을 facade에 전달한다")
|
||||||
|
fun shouldPassQueryParametersAndMemberToFacade() {
|
||||||
|
val member = Member(
|
||||||
|
email = "viewer@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "viewer",
|
||||||
|
role = MemberRole.USER
|
||||||
|
).apply { id = 10L }
|
||||||
|
Mockito.doReturn(response(MainContentAllType.SERIES, ContentSort.POPULAR)).`when`(facade)
|
||||||
|
.getContents("SERIES", "POPULAR", "MON", 1, 30, member)
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/audio/contents")
|
||||||
|
.param("type", "SERIES")
|
||||||
|
.param("dayOfWeek", "MON")
|
||||||
|
.param("sort", "POPULAR")
|
||||||
|
.param("page", "1")
|
||||||
|
.param("size", "30")
|
||||||
|
.with(user(MemberAdapter(member)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.type").value("SERIES"))
|
||||||
|
.andExpect(jsonPath("$.data.sort").value("POPULAR"))
|
||||||
|
|
||||||
|
Mockito.verify(facade).getContents("SERIES", "POPULAR", "MON", 1, 30, member)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun response(type: MainContentAllType, sort: ContentSort): MainContentAllTabResponse {
|
||||||
|
return MainContentAllTabResponse(
|
||||||
|
type = type,
|
||||||
|
totalCount = 0,
|
||||||
|
audios = emptyList(),
|
||||||
|
series = emptyList(),
|
||||||
|
sort = sort,
|
||||||
|
dayOfWeek = null,
|
||||||
|
page = 0,
|
||||||
|
size = 20,
|
||||||
|
hasNext = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.content.all.adapter.`in`.web
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
|
||||||
|
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.creator.admin.content.series.Series
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import org.hamcrest.Matchers.nullValue
|
||||||
|
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.test.annotation.DirtiesContext
|
||||||
|
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:main-content-all-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||||
|
class MainContentAllEndToEndTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc,
|
||||||
|
private val entityManager: EntityManager,
|
||||||
|
private val transactionTemplate: TransactionTemplate
|
||||||
|
) {
|
||||||
|
@Test
|
||||||
|
@DisplayName("전체 탭 AUDIO API는 controller-service-repository를 거쳐 오디오 응답과 빈 series를 반환한다")
|
||||||
|
fun shouldReturnAudioContentsThroughControllerServiceAndRepository() {
|
||||||
|
val fixture = createAudioFixture("main-all-audio-e2e")
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/audio/contents")
|
||||||
|
.param("type", "AUDIO")
|
||||||
|
.param("sort", "LATEST")
|
||||||
|
.param("page", "0")
|
||||||
|
.param("size", "20")
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.type").value("AUDIO"))
|
||||||
|
.andExpect(jsonPath("$.data.totalCount").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.audios[0].audioContentId").value(fixture.audioContentId))
|
||||||
|
.andExpect(jsonPath("$.data.audios[0].title").value("main-all-audio-e2e-audio"))
|
||||||
|
.andExpect(jsonPath("$.data.audios[0].imageUrl").value("https://cdn.test/main-all-audio-e2e-audio.png"))
|
||||||
|
.andExpect(jsonPath("$.data.audios[0].creatorNickname").value("main-all-audio-e2e-creator"))
|
||||||
|
.andExpect(jsonPath("$.data.series").isEmpty)
|
||||||
|
.andExpect(jsonPath("$.data.sort").value("LATEST"))
|
||||||
|
.andExpect(jsonPath("$.data.page").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.size").value(20))
|
||||||
|
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("전체 탭 SERIES API는 dayOfWeek 조건으로 시리즈 응답과 빈 audios를 반환한다")
|
||||||
|
fun shouldReturnSeriesContentsFilteredByDayOfWeekThroughControllerServiceAndRepository() {
|
||||||
|
val fixture = createSeriesFixture("main-all-series-e2e", SeriesPublishedDaysOfWeek.MON, isOriginal = false)
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/audio/contents")
|
||||||
|
.param("type", "SERIES")
|
||||||
|
.param("dayOfWeek", "MON")
|
||||||
|
.param("sort", "POPULAR")
|
||||||
|
.param("page", "0")
|
||||||
|
.param("size", "20")
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.type").value("SERIES"))
|
||||||
|
.andExpect(jsonPath("$.data.totalCount").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.audios").isEmpty)
|
||||||
|
.andExpect(jsonPath("$.data.series[0].seriesId").value(fixture.seriesId))
|
||||||
|
.andExpect(jsonPath("$.data.series[0].title").value("main-all-series-e2e-series"))
|
||||||
|
.andExpect(jsonPath("$.data.series[0].coverImageUrl").value("https://cdn.test/main-all-series-e2e-series.png"))
|
||||||
|
.andExpect(jsonPath("$.data.series[0].creatorNickname").value("main-all-series-e2e-creator"))
|
||||||
|
.andExpect(jsonPath("$.data.series[0].isOriginal").value(false))
|
||||||
|
.andExpect(jsonPath("$.data.dayOfWeek").value("MON"))
|
||||||
|
.andExpect(jsonPath("$.data.sort").value("POPULAR"))
|
||||||
|
.andExpect(jsonPath("$.data.page").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.size").value(20))
|
||||||
|
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("전체 탭 ORIGINAL API는 dayOfWeek를 무시하고 오리지널 시리즈만 반환한다")
|
||||||
|
fun shouldReturnOriginalSeriesIgnoringDayOfWeekThroughControllerServiceAndRepository() {
|
||||||
|
createSeriesFixture("main-all-original-control-e2e", SeriesPublishedDaysOfWeek.MON, isOriginal = false)
|
||||||
|
val fixture = createSeriesFixture("main-all-original-e2e", SeriesPublishedDaysOfWeek.TUE, isOriginal = true)
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/audio/contents")
|
||||||
|
.param("type", "ORIGINAL")
|
||||||
|
.param("dayOfWeek", "MON")
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.type").value("ORIGINAL"))
|
||||||
|
.andExpect(jsonPath("$.data.totalCount").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.audios").isEmpty)
|
||||||
|
.andExpect(jsonPath("$.data.series[0].seriesId").value(fixture.seriesId))
|
||||||
|
.andExpect(jsonPath("$.data.series[0].title").value("main-all-original-e2e-series"))
|
||||||
|
.andExpect(jsonPath("$.data.series[0].isOriginal").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.dayOfWeek").value(nullValue()))
|
||||||
|
.andExpect(jsonPath("$.data.sort").value("LATEST"))
|
||||||
|
.andExpect(jsonPath("$.data.page").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.size").value(20))
|
||||||
|
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createAudioFixture(prefix: String): AudioFixture {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val creator = saveMember("$prefix-creator", MemberRole.CREATOR)
|
||||||
|
val theme = saveTheme("$prefix-theme")
|
||||||
|
val audio = saveAudioContent(
|
||||||
|
creator = creator,
|
||||||
|
theme = theme,
|
||||||
|
title = "$prefix-audio",
|
||||||
|
coverImage = "$prefix-audio.png",
|
||||||
|
releaseDate = LocalDateTime.now().minusHours(1),
|
||||||
|
price = 100
|
||||||
|
)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
AudioFixture(audioContentId = audio.id!!)
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createSeriesFixture(
|
||||||
|
prefix: String,
|
||||||
|
dayOfWeek: SeriesPublishedDaysOfWeek,
|
||||||
|
isOriginal: Boolean
|
||||||
|
): SeriesFixture {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val creator = saveMember("$prefix-creator", MemberRole.CREATOR)
|
||||||
|
val series = saveSeries("$prefix-series", creator, dayOfWeek, isOriginal)
|
||||||
|
if (isOriginal) {
|
||||||
|
val theme = saveTheme("$prefix-theme")
|
||||||
|
val audio = saveAudioContent(
|
||||||
|
creator = creator,
|
||||||
|
theme = theme,
|
||||||
|
title = "$prefix-audio",
|
||||||
|
coverImage = "$prefix-audio.png",
|
||||||
|
releaseDate = LocalDateTime.now().minusHours(1),
|
||||||
|
price = 100
|
||||||
|
)
|
||||||
|
saveOrder(saveMember("$prefix-buyer", MemberRole.USER), creator, audio)
|
||||||
|
}
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
SeriesFixture(seriesId = series.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,
|
||||||
|
theme: AudioContentTheme,
|
||||||
|
title: String,
|
||||||
|
coverImage: String,
|
||||||
|
releaseDate: LocalDateTime,
|
||||||
|
price: Int
|
||||||
|
): AudioContent {
|
||||||
|
val content = AudioContent(
|
||||||
|
title = title,
|
||||||
|
detail = "detail",
|
||||||
|
languageCode = "ko",
|
||||||
|
releaseDate = releaseDate,
|
||||||
|
price = price,
|
||||||
|
isAdult = false,
|
||||||
|
isPointAvailable = true
|
||||||
|
)
|
||||||
|
content.member = creator
|
||||||
|
content.theme = theme
|
||||||
|
content.isActive = true
|
||||||
|
content.coverImage = coverImage
|
||||||
|
content.duration = "00:10:00"
|
||||||
|
entityManager.persist(content)
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveSeries(
|
||||||
|
title: String,
|
||||||
|
creator: Member,
|
||||||
|
dayOfWeek: SeriesPublishedDaysOfWeek,
|
||||||
|
isOriginal: Boolean
|
||||||
|
): Series {
|
||||||
|
val series = Series(
|
||||||
|
title = title,
|
||||||
|
introduction = "introduction",
|
||||||
|
languageCode = "ko",
|
||||||
|
isAdult = false,
|
||||||
|
isOriginal = isOriginal
|
||||||
|
)
|
||||||
|
series.member = creator
|
||||||
|
series.genre = saveSeriesGenre(title)
|
||||||
|
series.coverImage = "$title.png"
|
||||||
|
series.publishedDaysOfWeek.add(dayOfWeek)
|
||||||
|
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 saveOrder(member: Member, creator: Member, content: AudioContent): Order {
|
||||||
|
val order = Order(type = OrderType.KEEP, isActive = true)
|
||||||
|
order.member = member
|
||||||
|
order.creator = creator
|
||||||
|
order.audioContent = content
|
||||||
|
order.can = 100
|
||||||
|
entityManager.persist(order)
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class AudioFixture(
|
||||||
|
val audioContentId: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class SeriesFixture(
|
||||||
|
val seriesId: Long
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.content.all.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.content.all.application.MainContentAllQueryService
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAll
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllType
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentPage
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
|
||||||
|
class MainContentAllFacadeTest {
|
||||||
|
private val queryService = Mockito.mock(MainContentAllQueryService::class.java)
|
||||||
|
private val facade = MainContentAllFacade(queryService)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("facade는 문자열 query parameter와 회원을 query service에 그대로 전달하고 응답 DTO로 변환한다")
|
||||||
|
fun shouldDelegateToQueryServiceAndMapResponse() {
|
||||||
|
val member = Member(
|
||||||
|
email = "viewer@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "viewer",
|
||||||
|
role = MemberRole.USER
|
||||||
|
).apply { id = 10L }
|
||||||
|
Mockito.doReturn(
|
||||||
|
MainContentAll(
|
||||||
|
type = MainContentAllType.FREE,
|
||||||
|
totalCount = 0,
|
||||||
|
audios = emptyList(),
|
||||||
|
series = emptyList(),
|
||||||
|
sort = ContentSort.PRICE_LOW,
|
||||||
|
dayOfWeek = null,
|
||||||
|
page = MainContentPage(1, 30),
|
||||||
|
hasNext = false
|
||||||
|
)
|
||||||
|
).`when`(queryService).getContents("FREE", "PRICE_LOW", "MON", 1, 30, member)
|
||||||
|
|
||||||
|
val response = facade.getContents(
|
||||||
|
type = "FREE",
|
||||||
|
sort = "PRICE_LOW",
|
||||||
|
dayOfWeek = "MON",
|
||||||
|
page = 1,
|
||||||
|
size = 30,
|
||||||
|
member = member
|
||||||
|
)
|
||||||
|
|
||||||
|
Mockito.verify(queryService).getContents("FREE", "PRICE_LOW", "MON", 1, 30, member)
|
||||||
|
assertEquals(MainContentAllType.FREE, response.type)
|
||||||
|
assertEquals(ContentSort.PRICE_LOW, response.sort)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.content.all.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAll
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllAudio
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllSeries
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllType
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentPage
|
||||||
|
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 MainContentAllTabResponseTest {
|
||||||
|
private val objectMapper = jacksonObjectMapper()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("전체 탭 도메인을 최소 공개 응답 필드로 변환한다")
|
||||||
|
fun shouldMapDomainToResponseWithMinimalFields() {
|
||||||
|
val response = MainContentAllTabResponse.from(
|
||||||
|
MainContentAll(
|
||||||
|
type = MainContentAllType.SERIES,
|
||||||
|
totalCount = 1,
|
||||||
|
audios = emptyList(),
|
||||||
|
series = listOf(
|
||||||
|
MainContentAllSeries(
|
||||||
|
seriesId = 10L,
|
||||||
|
title = "시리즈",
|
||||||
|
coverImageUrl = "https://cdn/series.jpg",
|
||||||
|
creatorNickname = "creator",
|
||||||
|
isOriginal = true,
|
||||||
|
isAdult = false
|
||||||
|
)
|
||||||
|
),
|
||||||
|
sort = ContentSort.LATEST,
|
||||||
|
dayOfWeek = SeriesPublishedDaysOfWeek.MON,
|
||||||
|
page = MainContentPage(0, 20),
|
||||||
|
hasNext = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(MainContentAllType.SERIES, response.type)
|
||||||
|
assertEquals(1, response.totalCount)
|
||||||
|
assertTrue(response.audios.isEmpty())
|
||||||
|
assertEquals("creator", response.series.first().creatorNickname)
|
||||||
|
assertEquals(SeriesPublishedDaysOfWeek.MON, response.dayOfWeek)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("boolean 응답 필드는 is prefix를 유지하고 제외 필드는 노출하지 않는다")
|
||||||
|
fun shouldKeepBooleanJsonNamesAndHideExcludedFields() {
|
||||||
|
val response = MainContentAllTabResponse.from(
|
||||||
|
MainContentAll(
|
||||||
|
type = MainContentAllType.AUDIO,
|
||||||
|
totalCount = 1,
|
||||||
|
audios = listOf(
|
||||||
|
MainContentAllAudio(
|
||||||
|
audioContentId = 1L,
|
||||||
|
title = "audio",
|
||||||
|
imageUrl = "https://cdn/audio.jpg",
|
||||||
|
price = 100,
|
||||||
|
isAdult = false,
|
||||||
|
isPointAvailable = true,
|
||||||
|
isFirstContent = true,
|
||||||
|
isOriginalSeries = false,
|
||||||
|
creatorNickname = "creator"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
series = emptyList(),
|
||||||
|
sort = ContentSort.LATEST,
|
||||||
|
dayOfWeek = null,
|
||||||
|
page = MainContentPage(0, 20),
|
||||||
|
hasNext = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val json = objectMapper.readTree(objectMapper.writeValueAsString(response))
|
||||||
|
|
||||||
|
assertEquals(false, json["audios"][0]["isAdult"].asBoolean())
|
||||||
|
assertEquals(true, json["audios"][0]["isPointAvailable"].asBoolean())
|
||||||
|
assertEquals(true, json["audios"][0]["isFirstContent"].asBoolean())
|
||||||
|
assertEquals(false, json["audios"][0]["isOriginalSeries"].asBoolean())
|
||||||
|
assertEquals(true, json["hasNext"].asBoolean())
|
||||||
|
assertFalse(json["audios"][0].has("duration"))
|
||||||
|
assertFalse(json["series"].any { it.has("publishedDaysOfWeek") })
|
||||||
|
assertFalse(json["series"].any { it.has("isProceeding") })
|
||||||
|
assertFalse(json["series"].any { it.has("contentCount") })
|
||||||
|
assertFalse(json["series"].any { it.has("paidContentCount") })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.content.all.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.creator.admin.content.series.Series
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
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 DefaultMainContentAllQueryRepositoryTest @Autowired constructor(
|
||||||
|
private val entityManager: EntityManager,
|
||||||
|
queryFactory: JPAQueryFactory
|
||||||
|
) {
|
||||||
|
private val repository = DefaultMainContentAllQueryRepository(queryFactory, "https://cdn.test")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("오디오는 공개 조건, 성인 노출 정책, 차단 관계, 무료/포인트 필터를 반영한다")
|
||||||
|
fun shouldFindPublicAudiosWithVisibilityAndTypeFilters() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 25, 12, 0)
|
||||||
|
val viewer = saveMember("audio-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("audio-creator", MemberRole.CREATOR)
|
||||||
|
val blockedCreator = saveMember("blocked-audio-creator", MemberRole.CREATOR)
|
||||||
|
val inactiveCreator = saveMember("inactive-audio-creator", MemberRole.CREATOR, isActive = false)
|
||||||
|
val theme = saveTheme("audio-theme")
|
||||||
|
val inactiveTheme = saveTheme("inactive-audio-theme", isActive = false)
|
||||||
|
val free = saveAudioContent(creator, theme, now.minusDays(3), isAdult = false, price = 0)
|
||||||
|
val point = saveAudioContent(creator, theme, now.minusDays(2), isAdult = false, price = 100, isPointAvailable = true)
|
||||||
|
saveAudioContent(creator, theme, now.minusDays(1), isAdult = true, price = 200)
|
||||||
|
saveAudioContent(blockedCreator, theme, now.minusDays(1), isAdult = false, price = 100)
|
||||||
|
saveAudioContent(inactiveCreator, theme, now.minusDays(1), isAdult = false, price = 100)
|
||||||
|
saveAudioContent(creator, inactiveTheme, now.minusDays(1), isAdult = false, price = 100)
|
||||||
|
saveAudioContent(creator, theme, now.plusDays(1), isAdult = false, price = 100)
|
||||||
|
saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 100).duration = null
|
||||||
|
saveBlock(viewer, blockedCreator)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val visible = repository.findAudios(viewer.id, canViewAdultContent = false, now, ContentSort.LATEST, 0, 20)
|
||||||
|
val freeAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, onlyFree = true)
|
||||||
|
val pointAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, onlyPointAvailable = true)
|
||||||
|
|
||||||
|
assertEquals(2, repository.countAudios(viewer.id, canViewAdultContent = false, now))
|
||||||
|
assertEquals(listOf(point.id, free.id), visible.map { it.audioContentId })
|
||||||
|
assertEquals(listOf(free.id), freeAudios.map { it.audioContentId })
|
||||||
|
assertEquals(listOf(point.id), pointAudios.map { it.audioContentId })
|
||||||
|
assertEquals("https://cdn.test/audio.png", visible.first().imageUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("오디오 목록은 가격순과 인기순 can 매출 정렬, 첫 콘텐츠, 오리지널 시리즈 여부를 반환한다")
|
||||||
|
fun shouldSortAudiosAndReturnEnrichedFields() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 25, 12, 0)
|
||||||
|
val buyer = saveMember("audio-buyer", MemberRole.USER)
|
||||||
|
val creator = saveMember("audio-sort-creator", MemberRole.CREATOR)
|
||||||
|
val theme = saveTheme("audio-sort-theme")
|
||||||
|
val first = saveAudioContent(creator, theme, now.minusDays(10), isAdult = false, price = 100)
|
||||||
|
val low = saveAudioContent(creator, theme, now.minusDays(3), isAdult = false, price = 100)
|
||||||
|
val high = saveAudioContent(creator, theme, now.minusDays(2), isAdult = false, price = 300)
|
||||||
|
val middle = saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 200)
|
||||||
|
val original = saveSeries("original-audio-series", creator, isOriginal = true)
|
||||||
|
saveSeriesContent(original, high)
|
||||||
|
saveOrder(buyer, creator, low, can = 500, point = 9000)
|
||||||
|
saveOrder(buyer, creator, high, can = 100, point = 9999)
|
||||||
|
saveOrder(buyer, creator, middle, can = 1000, isActive = false)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val priceHigh = repository.findAudios(null, false, now, ContentSort.PRICE_HIGH, 0, 20)
|
||||||
|
val priceLow = repository.findAudios(null, false, now, ContentSort.PRICE_LOW, 0, 20)
|
||||||
|
val popular = repository.findAudios(null, false, now, ContentSort.POPULAR, 0, 20)
|
||||||
|
|
||||||
|
assertEquals(listOf(high.id, middle.id, low.id, first.id), priceHigh.map { it.audioContentId })
|
||||||
|
assertEquals(listOf(low.id, first.id, middle.id, high.id), priceLow.map { it.audioContentId })
|
||||||
|
assertEquals(listOf(low.id, high.id, middle.id, first.id), popular.map { it.audioContentId })
|
||||||
|
assertTrue(priceHigh.last().isFirstContent)
|
||||||
|
assertTrue(priceHigh.first().isOriginalSeries)
|
||||||
|
assertFalse(priceHigh[1].isOriginalSeries)
|
||||||
|
assertEquals("audio-sort-creator", priceHigh.first().creatorNickname)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("오디오 최신순은 동일 공개일에서 가격이 아니라 id desc로 정렬한다")
|
||||||
|
fun shouldSortAudiosByLatestReleaseDateAndIdOnly() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 25, 12, 0)
|
||||||
|
val creator = saveMember("audio-latest-creator", MemberRole.CREATOR)
|
||||||
|
val theme = saveTheme("audio-latest-theme")
|
||||||
|
val sameDateHighPrice = saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 500)
|
||||||
|
val sameDateLowPrice = saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 100)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val latest = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20)
|
||||||
|
|
||||||
|
assertEquals(listOf(sameDateLowPrice.id, sameDateHighPrice.id), latest.map { it.audioContentId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("시리즈는 활성 creator, 성인 노출 정책, 차단 관계, 요일과 오리지널 필터를 반영한다")
|
||||||
|
fun shouldFindSeriesWithVisibilityDayOfWeekAndOriginalFilters() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 25, 12, 0)
|
||||||
|
val viewer = saveMember("series-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("series-creator", MemberRole.CREATOR)
|
||||||
|
val blockedCreator = saveMember("blocked-series-creator", MemberRole.CREATOR)
|
||||||
|
val inactiveCreator = saveMember("inactive-series-creator", MemberRole.CREATOR, isActive = false)
|
||||||
|
val mon = saveSeries("mon-series", creator).apply { publishedDaysOfWeek.add(SeriesPublishedDaysOfWeek.MON) }
|
||||||
|
val random = saveSeries("random-series", creator).apply { publishedDaysOfWeek.add(SeriesPublishedDaysOfWeek.RANDOM) }
|
||||||
|
val original = saveSeries("original-series", creator, isOriginal = true).apply {
|
||||||
|
publishedDaysOfWeek.add(SeriesPublishedDaysOfWeek.TUE)
|
||||||
|
}
|
||||||
|
saveSeries("adult-series", creator, isAdult = true).publishedDaysOfWeek.add(SeriesPublishedDaysOfWeek.MON)
|
||||||
|
saveSeries("blocked-series", blockedCreator).publishedDaysOfWeek.add(SeriesPublishedDaysOfWeek.MON)
|
||||||
|
saveSeries("inactive-creator-series", inactiveCreator).publishedDaysOfWeek.add(SeriesPublishedDaysOfWeek.MON)
|
||||||
|
saveSeries("inactive-series", creator).apply {
|
||||||
|
isActive = false
|
||||||
|
publishedDaysOfWeek.add(SeriesPublishedDaysOfWeek.MON)
|
||||||
|
}
|
||||||
|
saveBlock(viewer, blockedCreator)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val monSeries = repository.findSeries(
|
||||||
|
viewer.id,
|
||||||
|
false,
|
||||||
|
now,
|
||||||
|
ContentSort.LATEST,
|
||||||
|
0,
|
||||||
|
20,
|
||||||
|
dayOfWeek = SeriesPublishedDaysOfWeek.MON,
|
||||||
|
locale = "ko"
|
||||||
|
)
|
||||||
|
val randomSeries = repository.findSeries(
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
now,
|
||||||
|
ContentSort.LATEST,
|
||||||
|
0,
|
||||||
|
20,
|
||||||
|
dayOfWeek = SeriesPublishedDaysOfWeek.RANDOM,
|
||||||
|
locale = "ko"
|
||||||
|
)
|
||||||
|
val originalSeries = repository.findSeries(
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
now,
|
||||||
|
ContentSort.LATEST,
|
||||||
|
0,
|
||||||
|
20,
|
||||||
|
onlyOriginal = true,
|
||||||
|
dayOfWeek = null,
|
||||||
|
locale = "ko"
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, repository.countSeries(viewer.id, false, now, dayOfWeek = SeriesPublishedDaysOfWeek.MON))
|
||||||
|
assertEquals(listOf(mon.id), monSeries.map { it.seriesId })
|
||||||
|
assertEquals(listOf(random.id), randomSeries.map { it.seriesId })
|
||||||
|
assertEquals(listOf(original.id), originalSeries.map { it.seriesId })
|
||||||
|
assertEquals("https://cdn.test/mon-series.png", monSeries.first().coverImageUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("시리즈 제목은 locale 번역값을 사용하고 blank 번역은 원문으로 fallback한다")
|
||||||
|
fun shouldFindSeriesWithTranslatedTitleFallback() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 25, 12, 0)
|
||||||
|
val creator = saveMember("series-translation-creator", MemberRole.CREATOR)
|
||||||
|
val translated = saveSeries("origin-translated-series", creator)
|
||||||
|
val blankTranslated = saveSeries("origin-blank-series", creator)
|
||||||
|
saveSeriesTranslation(translated, "en", "Translated Series")
|
||||||
|
saveSeriesTranslation(blankTranslated, "en", " ")
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val records = repository.findSeries(null, false, now, ContentSort.LATEST, 0, 20, locale = "en")
|
||||||
|
|
||||||
|
assertEquals("Translated Series", records.first { it.seriesId == translated.id }.title)
|
||||||
|
assertEquals("origin-blank-series", records.first { it.seriesId == blankTranslated.id }.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("시리즈 목록은 공개 오디오 대표값으로 최신순, 가격순, 인기순 can 매출 정렬을 적용한다")
|
||||||
|
fun shouldSortSeriesByPublicAudioRepresentatives() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 25, 12, 0)
|
||||||
|
val buyer = saveMember("series-buyer", MemberRole.USER)
|
||||||
|
val creator = saveMember("series-sort-creator", MemberRole.CREATOR)
|
||||||
|
val inactiveAudioCreator = saveMember("inactive-audio-creator-for-series", MemberRole.CREATOR, isActive = false)
|
||||||
|
val theme = saveTheme("series-sort-theme")
|
||||||
|
val inactiveTheme = saveTheme("inactive-series-sort-theme", isActive = false)
|
||||||
|
val oldHigh = saveSeries("old-high", creator)
|
||||||
|
val recentLow = saveSeries("recent-low", creator)
|
||||||
|
val sameDateHigh = saveSeries("same-date-high", creator)
|
||||||
|
val sameDateLow = saveSeries("same-date-low", creator)
|
||||||
|
val popular = saveSeries("popular", creator)
|
||||||
|
val inactiveRevenue = saveSeries("inactive-revenue", creator)
|
||||||
|
val inactiveThemeOnly = saveSeries("inactive-theme-only", creator)
|
||||||
|
val inactiveCreatorOnly = saveSeries("inactive-creator-only", creator)
|
||||||
|
val oldHighAudio = saveAudioContent(creator, theme, now.minusDays(5), isAdult = false, price = 500)
|
||||||
|
val recentLowAudio = saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 100)
|
||||||
|
val sameDateHighAudio = saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 300)
|
||||||
|
val sameDateLowAudio = saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 50)
|
||||||
|
val popularAudio = saveAudioContent(creator, theme, now.minusDays(4), isAdult = false, price = 100)
|
||||||
|
val inactiveRevenueAudio = saveAudioContent(creator, theme, now.minusDays(3), isAdult = false, price = 100)
|
||||||
|
val inactiveThemeAudio = saveAudioContent(creator, inactiveTheme, now, isAdult = false, price = 1000)
|
||||||
|
val inactiveCreatorAudio = saveAudioContent(inactiveAudioCreator, theme, now, isAdult = false, price = 1000)
|
||||||
|
saveSeriesContent(oldHigh, oldHighAudio)
|
||||||
|
saveSeriesContent(recentLow, recentLowAudio)
|
||||||
|
saveSeriesContent(sameDateHigh, sameDateHighAudio)
|
||||||
|
saveSeriesContent(sameDateLow, sameDateLowAudio)
|
||||||
|
saveSeriesContent(popular, popularAudio)
|
||||||
|
saveSeriesContent(inactiveRevenue, inactiveRevenueAudio)
|
||||||
|
saveSeriesContent(inactiveThemeOnly, inactiveThemeAudio)
|
||||||
|
saveSeriesContent(inactiveCreatorOnly, inactiveCreatorAudio)
|
||||||
|
saveOrder(buyer, creator, popularAudio, can = 900)
|
||||||
|
saveOrder(buyer, creator, inactiveThemeAudio, can = 5000)
|
||||||
|
saveOrder(buyer, inactiveAudioCreator, inactiveCreatorAudio, can = 5000)
|
||||||
|
saveOrder(buyer, creator, inactiveRevenueAudio, can = 1000, isActive = false)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val latest = findSeriesIds(now, ContentSort.LATEST)
|
||||||
|
val priceHigh = findSeriesIds(now, ContentSort.PRICE_HIGH)
|
||||||
|
val priceLow = findSeriesIds(now, ContentSort.PRICE_LOW)
|
||||||
|
val popularSorted = findSeriesIds(now, ContentSort.POPULAR)
|
||||||
|
|
||||||
|
assertEquals(listOf(sameDateLow.id, sameDateHigh.id, recentLow.id), latest.take(3))
|
||||||
|
assertEquals(oldHigh.id, priceHigh.first())
|
||||||
|
assertEquals(sameDateLow.id, priceLow.first())
|
||||||
|
assertEquals(popular.id, popularSorted.first())
|
||||||
|
assertEquals(listOf(inactiveCreatorOnly.id, inactiveThemeOnly.id), latest.takeLast(2))
|
||||||
|
assertEquals(listOf(inactiveCreatorOnly.id, inactiveThemeOnly.id), priceHigh.takeLast(2))
|
||||||
|
assertEquals(listOf(inactiveCreatorOnly.id, inactiveThemeOnly.id), popularSorted.takeLast(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findSeriesIds(now: LocalDateTime, sort: ContentSort): List<Long> {
|
||||||
|
return repository.findSeries(null, false, now, sort, 0, 20, locale = "ko").map { it.seriesId }
|
||||||
|
}
|
||||||
|
|
||||||
|
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): AudioContentTheme {
|
||||||
|
val theme = AudioContentTheme(theme = name, image = "$name.png", isActive = isActive)
|
||||||
|
entityManager.persist(theme)
|
||||||
|
return theme
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveAudioContent(
|
||||||
|
creator: Member,
|
||||||
|
theme: AudioContentTheme,
|
||||||
|
releaseDate: LocalDateTime,
|
||||||
|
isAdult: Boolean,
|
||||||
|
price: Int,
|
||||||
|
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,
|
||||||
|
isAdult: Boolean = false,
|
||||||
|
isOriginal: Boolean = false
|
||||||
|
): Series {
|
||||||
|
val series = Series(
|
||||||
|
title = title,
|
||||||
|
introduction = "introduction",
|
||||||
|
languageCode = "ko",
|
||||||
|
isAdult = isAdult,
|
||||||
|
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,
|
||||||
|
can: Int,
|
||||||
|
point: Int = 0,
|
||||||
|
isActive: Boolean = true
|
||||||
|
): Order {
|
||||||
|
val order = Order(type = OrderType.KEEP, isActive = isActive)
|
||||||
|
order.member = member
|
||||||
|
order.creator = creator
|
||||||
|
order.audioContent = content
|
||||||
|
order.can = can
|
||||||
|
order.point = point
|
||||||
|
entityManager.persist(order)
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun flushAndClear() {
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.content.all.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
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.v2.common.domain.ContentSort
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllAudio
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicy
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllSeries
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllType
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.all.port.out.MainContentAllQueryPort
|
||||||
|
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 MainContentAllQueryServiceTest {
|
||||||
|
@Test
|
||||||
|
@DisplayName("AUDIO 타입은 audio port를 기본 필터로 호출한다")
|
||||||
|
fun shouldQueryAudiosForAudioType() {
|
||||||
|
val port = FakeMainContentAllQueryPort()
|
||||||
|
val service = createService(port)
|
||||||
|
|
||||||
|
val tab = service.getContents(type = "AUDIO", sort = "POPULAR", dayOfWeek = "MON", page = 1, size = 20, member = null)
|
||||||
|
|
||||||
|
assertEquals(MainContentAllType.AUDIO, tab.type)
|
||||||
|
assertEquals("audio", port.lastListKind)
|
||||||
|
assertEquals(ContentSort.POPULAR, port.lastSort)
|
||||||
|
assertEquals(20L, port.lastOffset)
|
||||||
|
assertEquals(21, port.lastLimit)
|
||||||
|
assertFalse(port.lastOnlyFree)
|
||||||
|
assertFalse(port.lastOnlyPointAvailable)
|
||||||
|
assertEquals(20, tab.audios.size)
|
||||||
|
assertTrue(tab.hasNext)
|
||||||
|
assertEquals(emptyList<MainContentAllSeries>(), tab.series)
|
||||||
|
assertEquals(null, tab.dayOfWeek)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("FREE 타입은 audio port를 무료 필터로 호출한다")
|
||||||
|
fun shouldQueryAudiosForFreeType() {
|
||||||
|
val port = FakeMainContentAllQueryPort()
|
||||||
|
val service = createService(port)
|
||||||
|
|
||||||
|
val tab = service.getContents(type = "FREE", sort = "LATEST", dayOfWeek = null, page = 0, size = 20, member = null)
|
||||||
|
|
||||||
|
assertEquals(MainContentAllType.FREE, tab.type)
|
||||||
|
assertEquals("audio", port.lastListKind)
|
||||||
|
assertTrue(port.lastOnlyFree)
|
||||||
|
assertFalse(port.lastOnlyPointAvailable)
|
||||||
|
assertEquals(21, port.lastLimit)
|
||||||
|
assertEquals(20, tab.audios.size)
|
||||||
|
assertTrue(tab.hasNext)
|
||||||
|
assertEquals(emptyList<MainContentAllSeries>(), tab.series)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("POINT 타입은 audio port를 포인트 사용 가능 필터로 호출한다")
|
||||||
|
fun shouldQueryAudiosForPointType() {
|
||||||
|
val port = FakeMainContentAllQueryPort()
|
||||||
|
val service = createService(port)
|
||||||
|
|
||||||
|
val tab = service.getContents(type = "POINT", sort = "PRICE_LOW", dayOfWeek = null, page = 0, size = 20, member = null)
|
||||||
|
|
||||||
|
assertEquals(MainContentAllType.POINT, tab.type)
|
||||||
|
assertEquals("audio", port.lastListKind)
|
||||||
|
assertEquals(ContentSort.PRICE_LOW, port.lastSort)
|
||||||
|
assertFalse(port.lastOnlyFree)
|
||||||
|
assertTrue(port.lastOnlyPointAvailable)
|
||||||
|
assertEquals(21, port.lastLimit)
|
||||||
|
assertEquals(20, tab.audios.size)
|
||||||
|
assertTrue(tab.hasNext)
|
||||||
|
assertEquals(emptyList<MainContentAllSeries>(), tab.series)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("SERIES는 요일을 전달하고 ORIGINAL은 original 필터와 dayOfWeek null을 전달한다")
|
||||||
|
fun shouldQuerySeriesByType() {
|
||||||
|
val seriesPort = FakeMainContentAllQueryPort()
|
||||||
|
val service = createService(seriesPort, lang = Lang.JA)
|
||||||
|
|
||||||
|
val seriesTab = service.getContents("SERIES", "POPULAR", "MON", 0, 20, null)
|
||||||
|
|
||||||
|
assertEquals(MainContentAllType.SERIES, seriesTab.type)
|
||||||
|
assertEquals("series", seriesPort.lastListKind)
|
||||||
|
assertEquals(SeriesPublishedDaysOfWeek.MON, seriesPort.lastDayOfWeek)
|
||||||
|
assertEquals("ja", seriesPort.lastLocale)
|
||||||
|
assertFalse(seriesPort.lastOnlyOriginal)
|
||||||
|
|
||||||
|
val originalPort = FakeMainContentAllQueryPort()
|
||||||
|
val originalService = createService(originalPort)
|
||||||
|
|
||||||
|
val originalTab = originalService.getContents("ORIGINAL", "POPULAR", "MON", 0, 20, null)
|
||||||
|
|
||||||
|
assertEquals(MainContentAllType.ORIGINAL, originalTab.type)
|
||||||
|
assertEquals("series", originalPort.lastListKind)
|
||||||
|
assertEquals(null, originalPort.lastDayOfWeek)
|
||||||
|
assertTrue(originalPort.lastOnlyOriginal)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("비회원은 성인 콘텐츠 비노출로 조회하고 회원은 preference 결과를 전달한다")
|
||||||
|
fun shouldPassAdultVisibilityByMember() {
|
||||||
|
val anonymousPort = FakeMainContentAllQueryPort()
|
||||||
|
createService(anonymousPort).getContents("AUDIO", null, null, null, null, null)
|
||||||
|
|
||||||
|
assertEquals(null, anonymousPort.lastMemberId)
|
||||||
|
assertFalse(anonymousPort.lastCanViewAdultContent)
|
||||||
|
|
||||||
|
val member = Member(
|
||||||
|
email = "viewer@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "viewer",
|
||||||
|
role = MemberRole.USER
|
||||||
|
).apply { id = 10L }
|
||||||
|
val memberPort = FakeMainContentAllQueryPort()
|
||||||
|
val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||||
|
Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member)
|
||||||
|
|
||||||
|
createService(memberPort, preferenceService).getContents("AUDIO", null, null, null, null, member)
|
||||||
|
|
||||||
|
assertEquals(10L, memberPort.lastMemberId)
|
||||||
|
assertTrue(memberPort.lastCanViewAdultContent)
|
||||||
|
Mockito.verify(preferenceService).canViewAdultContent(member)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createService(
|
||||||
|
port: MainContentAllQueryPort,
|
||||||
|
preferenceService: MemberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java),
|
||||||
|
lang: Lang = Lang.EN
|
||||||
|
): MainContentAllQueryService {
|
||||||
|
val langContext = LangContext()
|
||||||
|
langContext.setLang(lang)
|
||||||
|
return MainContentAllQueryService(
|
||||||
|
queryPort = port,
|
||||||
|
memberContentPreferenceService = preferenceService,
|
||||||
|
queryPolicy = MainContentAllQueryPolicy(),
|
||||||
|
langContext = langContext
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeMainContentAllQueryPort : MainContentAllQueryPort {
|
||||||
|
var lastListKind: String? = null
|
||||||
|
var lastMemberId: Long? = null
|
||||||
|
var lastCanViewAdultContent: Boolean = false
|
||||||
|
var lastSort: ContentSort? = null
|
||||||
|
var lastOffset: Long? = null
|
||||||
|
var lastLimit: Int? = null
|
||||||
|
var lastOnlyFree: Boolean = false
|
||||||
|
var lastOnlyPointAvailable: Boolean = false
|
||||||
|
var lastOnlyOriginal: Boolean = false
|
||||||
|
var lastDayOfWeek: SeriesPublishedDaysOfWeek? = null
|
||||||
|
var lastLocale: String? = null
|
||||||
|
|
||||||
|
override fun countAudios(
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
onlyFree: Boolean,
|
||||||
|
onlyPointAvailable: Boolean
|
||||||
|
): Int {
|
||||||
|
lastMemberId = memberId
|
||||||
|
lastCanViewAdultContent = canViewAdultContent
|
||||||
|
lastOnlyFree = onlyFree
|
||||||
|
lastOnlyPointAvailable = onlyPointAvailable
|
||||||
|
return 30
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findAudios(
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
sort: ContentSort,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
onlyFree: Boolean,
|
||||||
|
onlyPointAvailable: Boolean
|
||||||
|
): List<MainContentAllAudio> {
|
||||||
|
lastListKind = "audio"
|
||||||
|
lastMemberId = memberId
|
||||||
|
lastCanViewAdultContent = canViewAdultContent
|
||||||
|
lastSort = sort
|
||||||
|
lastOffset = offset
|
||||||
|
lastLimit = limit
|
||||||
|
lastOnlyFree = onlyFree
|
||||||
|
lastOnlyPointAvailable = onlyPointAvailable
|
||||||
|
return (1L..limit.toLong()).map { id ->
|
||||||
|
MainContentAllAudio(
|
||||||
|
audioContentId = id,
|
||||||
|
title = "audio-$id",
|
||||||
|
imageUrl = null,
|
||||||
|
price = 0,
|
||||||
|
isAdult = false,
|
||||||
|
isPointAvailable = true,
|
||||||
|
isFirstContent = id == 1L,
|
||||||
|
isOriginalSeries = false,
|
||||||
|
creatorNickname = "creator"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun countSeries(
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
onlyOriginal: Boolean,
|
||||||
|
dayOfWeek: SeriesPublishedDaysOfWeek?
|
||||||
|
): Int {
|
||||||
|
lastMemberId = memberId
|
||||||
|
lastCanViewAdultContent = canViewAdultContent
|
||||||
|
lastOnlyOriginal = onlyOriginal
|
||||||
|
lastDayOfWeek = dayOfWeek
|
||||||
|
return 10
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findSeries(
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
sort: ContentSort,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
onlyOriginal: Boolean,
|
||||||
|
dayOfWeek: SeriesPublishedDaysOfWeek?,
|
||||||
|
locale: String
|
||||||
|
): List<MainContentAllSeries> {
|
||||||
|
lastListKind = "series"
|
||||||
|
lastMemberId = memberId
|
||||||
|
lastCanViewAdultContent = canViewAdultContent
|
||||||
|
lastSort = sort
|
||||||
|
lastOffset = offset
|
||||||
|
lastLimit = limit
|
||||||
|
lastOnlyOriginal = onlyOriginal
|
||||||
|
lastDayOfWeek = dayOfWeek
|
||||||
|
lastLocale = locale
|
||||||
|
return listOf(
|
||||||
|
MainContentAllSeries(
|
||||||
|
seriesId = 1L,
|
||||||
|
title = "series",
|
||||||
|
coverImageUrl = null,
|
||||||
|
creatorNickname = "creator",
|
||||||
|
isOriginal = onlyOriginal,
|
||||||
|
isAdult = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.content.all.domain
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
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.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class MainContentAllQueryPolicyTest {
|
||||||
|
private val policy = MainContentAllQueryPolicy()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("전체 탭 요청 기본값과 fallback을 보정한다")
|
||||||
|
fun shouldResolveDefaultsAndFallbacks() {
|
||||||
|
assertEquals(MainContentAllType.AUDIO, policy.resolveType(null))
|
||||||
|
assertEquals(MainContentAllType.AUDIO, policy.resolveType("UNKNOWN"))
|
||||||
|
assertEquals(ContentSort.LATEST, policy.resolveSort(null))
|
||||||
|
assertEquals(ContentSort.LATEST, policy.resolveSort("UNKNOWN"))
|
||||||
|
assertEquals(ContentSort.LATEST, policy.resolveSort("OWNED"))
|
||||||
|
assertEquals(ContentSort.POPULAR, policy.resolveSort("POPULAR"))
|
||||||
|
assertEquals(MainContentPage(page = 0, size = 20), policy.createPage(page = null, size = null))
|
||||||
|
assertEquals(MainContentPage(page = 0, size = 20), policy.createPage(page = -1, size = 1))
|
||||||
|
assertEquals(MainContentPage(page = 2, size = 50), policy.createPage(page = 2, size = 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("요일 조건은 SERIES 타입에만 적용한다")
|
||||||
|
fun shouldResolveDayOfWeekOnlyForSeriesType() {
|
||||||
|
assertEquals(SeriesPublishedDaysOfWeek.MON, policy.resolveDayOfWeek(MainContentAllType.SERIES, "MON"))
|
||||||
|
assertEquals(SeriesPublishedDaysOfWeek.RANDOM, policy.resolveDayOfWeek(MainContentAllType.SERIES, "RANDOM"))
|
||||||
|
assertNull(policy.resolveDayOfWeek(MainContentAllType.SERIES, "INVALID"))
|
||||||
|
assertNull(policy.resolveDayOfWeek(MainContentAllType.ORIGINAL, "MON"))
|
||||||
|
assertNull(policy.resolveDayOfWeek(MainContentAllType.AUDIO, "MON"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("limit + 1 조회 결과에서 응답 목록과 hasNext를 계산한다")
|
||||||
|
fun shouldLimitItemsAndResolveHasNext() {
|
||||||
|
val items = listOf(1, 2, 3)
|
||||||
|
|
||||||
|
assertEquals(listOf(1, 2), policy.limitItems(items, MainContentPage(page = 0, size = 2)))
|
||||||
|
assertTrue(policy.hasNext(items, MainContentPage(page = 0, size = 2)))
|
||||||
|
assertFalse(policy.hasNext(listOf(1, 2), MainContentPage(page = 0, size = 2)))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user