Compare commits
16 Commits
1240f00ea2
...
998dd10311
| Author | SHA1 | Date | |
|---|---|---|---|
| 998dd10311 | |||
| 652c955356 | |||
| 338f5c29bc | |||
| 7651fd83ea | |||
| 67fe0ec497 | |||
| a67322b7fd | |||
| 25330e30c0 | |||
| dd68e64628 | |||
| e8b8287968 | |||
| 6c4df431b9 | |||
| c39f339a86 | |||
| 2ebc728656 | |||
| 3d88dc7b8a | |||
| 7183e5f0ca | |||
| 04579ccc0c | |||
| 99ee234b46 |
@@ -101,6 +101,7 @@ tasks.withType<KotlinCompile> {
|
|||||||
|
|
||||||
tasks.withType<Test> {
|
tasks.withType<Test> {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
|
maxHeapSize = "1536m"
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.getByName<Jar>("jar") {
|
tasks.getByName<Jar>("jar") {
|
||||||
|
|||||||
500
docs/20260620_크리에이터_채널_시리즈_탭_API/plan-task.md
Normal file
500
docs/20260620_크리에이터_채널_시리즈_탭_API/plan-task.md
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
# 크리에이터 채널 시리즈 탭 API Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
||||||
|
|
||||||
|
**Goal:** 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/series`로 크리에이터 채널 시리즈 탭의 전체 시리즈 개수와 정렬/페이징된 시리즈 목록을 조회할 수 있게 한다.
|
||||||
|
|
||||||
|
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.series` 조립 계층에 둔다. 시리즈 탭 조회 service, 순수 fallback/page/rate/day-of-week 정책, tab domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.series` 하위에 두고 `v2.api.*`에 의존하지 않는다. 기존 오디오 탭의 `ContentSort`, `CreatorChannelPage`, 인증/차단/성인 콘텐츠 노출 정책 흐름을 재사용하되, 홈 API의 `CreatorChannelSeries`는 확장하지 않는다.
|
||||||
|
|
||||||
|
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 구현 전 확정 사항
|
||||||
|
|
||||||
|
- API endpoint: `GET /api/v2/creator-channels/{creatorId}/series`
|
||||||
|
- 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다.
|
||||||
|
- request:
|
||||||
|
- path variable: `creatorId`
|
||||||
|
- query parameter: `sort`, `required = false`, 기본값/fallback `LATEST`
|
||||||
|
- query parameter: `page`, `required = false`, 기본값 `0`, `page < 0`이면 `0`으로 fallback
|
||||||
|
- query parameter: `size`, `required = false`, 기본값 `20`, `size < 20`이면 `20`, `size > 50`이면 `50`으로 fallback
|
||||||
|
- controller는 invalid `sort` fallback을 위해 `sort: String?`으로 받고 service/facade 경계에서 `ContentSort`로 보정한다.
|
||||||
|
- response:
|
||||||
|
- `seriesCount`: sort-bar에 표시할 조회 가능한 전체 시리즈 개수
|
||||||
|
- `series`: 시리즈 목록
|
||||||
|
- `sort`: 실제 적용한 `ContentSort`
|
||||||
|
- `page`: fallback 보정 후 실제 적용된 page index
|
||||||
|
- `size`: fallback 보정 후 실제 적용된 page size
|
||||||
|
- `hasNext`: 다음 page 존재 여부
|
||||||
|
- series item:
|
||||||
|
- `seriesId`, `title`, `coverImageUrl`, `publishedDaysOfWeek`, `isOriginal`, `isAdult`, `isProceeding`, `contentCount`
|
||||||
|
- 조회자가 해당 시리즈의 크리에이터가 아니면 `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate`를 계산한다.
|
||||||
|
- 조회자가 해당 시리즈의 크리에이터이면 `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate`는 `null`이다.
|
||||||
|
- `purchasedPaidContentRate`: `Int?`, 비크리에이터 조회 시 `paidContentCount == 0`이면 `0`, 그 외 `(purchasedContentCount * 100) / paidContentCount`로 계산하고 소수점 이하는 버린다.
|
||||||
|
- 공개 콘텐츠 기준: `AudioContent.isActive == true`, `AudioContent.duration != null`, `AudioContent.releaseDate != null`, `AudioContent.releaseDate <= now`.
|
||||||
|
- 공개 시리즈 기준: `Series.isActive == true`, `Series.member.id == creatorId`.
|
||||||
|
- `coverImageUrl`은 `Series.coverImage`를 `String?.toCdnUrl(cloudFrontHost)`로 변환한 값이다. 커버 이미지 경로가 없거나 blank이면 `null`로 내려준다.
|
||||||
|
- 성인 콘텐츠는 조회자의 성인 콘텐츠 노출 정책이 false이면 목록과 count에서 제외한다.
|
||||||
|
- 시리즈명은 `LangContext.lang.code` 기준으로 `SeriesTranslation`을 우선하고, 없거나 빈 문자열이면 `Series.title` 원문으로 fallback한다.
|
||||||
|
- 연재 요일:
|
||||||
|
- `RANDOM`이 포함되면 다른 요일을 무시하고 랜덤 문구만 반환한다.
|
||||||
|
- 랜덤 문구: `ko=랜덤`, `en=Random`, `ja=ランダム`
|
||||||
|
- 7개 요일이 모두 있으면 `ko=매일`, `en=Every day`, `ja=毎日`
|
||||||
|
- 그 외 `ko=매주 월, 목, 토`, `en=Every Mon, Thu, Sat`, `ja=毎週 月, 木, 土` 형식
|
||||||
|
- 정렬:
|
||||||
|
- `LATEST`: 대표 `releaseDate desc`, 대표 `price desc`, `series.id desc`
|
||||||
|
- `POPULAR`: 시리즈 콘텐츠의 `orders.can` 합계 desc, 대표 `releaseDate desc`, `series.id desc`; `orders.is_active = true`만 포함
|
||||||
|
- `OWNED`: 조회자가 유효하게 소장/대여 중인 시리즈 콘텐츠 개수 desc, 대표 `releaseDate desc`, `series.id desc`
|
||||||
|
- `PRICE_HIGH`: 대표 `price desc`, 대표 `releaseDate desc`, `series.id desc`
|
||||||
|
- `PRICE_LOW`: 대표 `price asc`, 대표 `releaseDate desc`, `series.id desc`
|
||||||
|
- 대표값:
|
||||||
|
- 대표 `releaseDate`: 각 시리즈의 조회 가능한 공개 콘텐츠 중 가장 최근 `releaseDate`
|
||||||
|
- `price desc` 대표값: 각 시리즈의 조회 가능한 공개 콘텐츠 중 가장 높은 가격
|
||||||
|
- `price asc` 대표값: 각 시리즈의 조회 가능한 공개 콘텐츠 중 가장 낮은 가격
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 파일 구조 계획
|
||||||
|
|
||||||
|
### 시리즈 탭 신규 API 조립 계층
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesController.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacade.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/dto/CreatorChannelSeriesTabResponse.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesEndToEndTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt`
|
||||||
|
|
||||||
|
### 시리즈 탭 도메인 조회 계층
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesTab.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicy.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/port/out/CreatorChannelSeriesQueryPort.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/CreatorChannelSeriesQueryRepository.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicyTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt`
|
||||||
|
|
||||||
|
### 기존 파일 확인/재사용
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSort.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioQueryPolicy.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.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/creator/admin/content/series/Series.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/SeriesContent.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt`
|
||||||
|
|
||||||
|
### 문서 산출물
|
||||||
|
- Create: `docs/20260620_크리에이터_채널_시리즈_탭_API/plan-task.md`
|
||||||
|
- Verify: `docs/20260620_크리에이터_채널_시리즈_탭_API/prd.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Response data class 초안
|
||||||
|
|
||||||
|
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/dto/CreatorChannelSeriesTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.series.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeries
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesTab
|
||||||
|
|
||||||
|
data class CreatorChannelSeriesTabResponse(
|
||||||
|
val seriesCount: Int,
|
||||||
|
val series: List<CreatorChannelSeriesResponse>,
|
||||||
|
val sort: ContentSort,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
@JsonProperty("hasNext")
|
||||||
|
val hasNext: Boolean
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(tab: CreatorChannelSeriesTab): CreatorChannelSeriesTabResponse {
|
||||||
|
return CreatorChannelSeriesTabResponse(
|
||||||
|
seriesCount = tab.seriesCount,
|
||||||
|
series = tab.series.map(CreatorChannelSeriesResponse::from),
|
||||||
|
sort = tab.sort,
|
||||||
|
page = tab.page.page,
|
||||||
|
size = tab.page.size,
|
||||||
|
hasNext = tab.hasNext
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelSeriesResponse(
|
||||||
|
val seriesId: Long,
|
||||||
|
val title: String,
|
||||||
|
val coverImageUrl: String?,
|
||||||
|
val publishedDaysOfWeek: String,
|
||||||
|
@JsonProperty("isOriginal")
|
||||||
|
val isOriginal: Boolean,
|
||||||
|
@JsonProperty("isAdult")
|
||||||
|
val isAdult: Boolean,
|
||||||
|
@JsonProperty("isProceeding")
|
||||||
|
val isProceeding: Boolean,
|
||||||
|
val contentCount: Int,
|
||||||
|
val purchasedContentCount: Int?,
|
||||||
|
val paidContentCount: Int?,
|
||||||
|
val purchasedPaidContentRate: Int?
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(series: CreatorChannelSeries): CreatorChannelSeriesResponse {
|
||||||
|
return CreatorChannelSeriesResponse(
|
||||||
|
seriesId = series.seriesId,
|
||||||
|
title = series.title,
|
||||||
|
coverImageUrl = series.coverImageUrl,
|
||||||
|
publishedDaysOfWeek = series.publishedDaysOfWeek,
|
||||||
|
isOriginal = series.isOriginal,
|
||||||
|
isAdult = series.isAdult,
|
||||||
|
isProceeding = series.isProceeding,
|
||||||
|
contentCount = series.contentCount,
|
||||||
|
purchasedContentCount = series.purchasedContentCount,
|
||||||
|
paidContentCount = series.paidContentCount,
|
||||||
|
purchasedPaidContentRate = series.purchasedPaidContentRate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Domain / Port 초안
|
||||||
|
|
||||||
|
구현 시 아래 형태를 기준으로 추가한다. API DTO가 domain model을 참조하지만 domain/port는 API DTO를 참조하지 않는다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.series.domain
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
|
||||||
|
|
||||||
|
data class CreatorChannelSeriesTab(
|
||||||
|
val seriesCount: Int,
|
||||||
|
val series: List<CreatorChannelSeries>,
|
||||||
|
val sort: ContentSort,
|
||||||
|
val page: CreatorChannelPage,
|
||||||
|
val hasNext: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelSeries(
|
||||||
|
val seriesId: Long,
|
||||||
|
val title: String,
|
||||||
|
val coverImageUrl: String?,
|
||||||
|
val publishedDaysOfWeek: String,
|
||||||
|
val isOriginal: Boolean,
|
||||||
|
val isAdult: Boolean,
|
||||||
|
val isProceeding: Boolean,
|
||||||
|
val contentCount: Int,
|
||||||
|
val purchasedContentCount: Int?,
|
||||||
|
val paidContentCount: Int?,
|
||||||
|
val purchasedPaidContentRate: Int?
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.series.port.out
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
interface CreatorChannelSeriesQueryPort {
|
||||||
|
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelSeriesCreatorRecord?
|
||||||
|
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
|
||||||
|
fun countSeries(creatorId: Long, now: LocalDateTime, canViewAdultContent: Boolean): Int
|
||||||
|
fun findSeries(
|
||||||
|
creatorId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
now: LocalDateTime,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
sort: ContentSort,
|
||||||
|
locale: String,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelSeriesRecord>
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelSeriesCreatorRecord(
|
||||||
|
val creatorId: Long,
|
||||||
|
val role: MemberRole,
|
||||||
|
val nickname: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelSeriesRecord(
|
||||||
|
val seriesId: Long,
|
||||||
|
val title: String,
|
||||||
|
val coverImagePath: String?,
|
||||||
|
val publishedDaysOfWeek: Set<SeriesPublishedDaysOfWeek>,
|
||||||
|
val isOriginal: Boolean,
|
||||||
|
val isAdult: Boolean,
|
||||||
|
val state: SeriesState,
|
||||||
|
val contentCount: Int,
|
||||||
|
val purchasedContentCount: Int?,
|
||||||
|
val paidContentCount: Int?
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 작업 계획
|
||||||
|
|
||||||
|
### Phase 1: 순수 정책과 도메인 모델 추가
|
||||||
|
|
||||||
|
- [x] **Task 1.1: `CreatorChannelSeriesQueryPolicy` 테스트 작성**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicyTest.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicy.kt`
|
||||||
|
- RED: 아래 케이스를 테스트로 먼저 작성한다.
|
||||||
|
- `sort == null`, `UNKNOWN`은 `ContentSort.LATEST`로 fallback한다.
|
||||||
|
- `page = -1`, `size = 10`은 `page=0`, `size=20`, `fetchLimit=21`이 된다.
|
||||||
|
- `page = 2`, `size = 100`은 `page=2`, `size=50`, `offset=100`, `fetchLimit=51`이 된다.
|
||||||
|
- `limitItems`는 `size`만큼만 남기고 `hasNext`는 `fetched.size > size`로 계산한다.
|
||||||
|
- 구매율은 `paidContentCount == 0`이면 `0`, `paid=4`, `purchased=3`이면 `75`, `paid=3`, `purchased=2`이면 `66`이다.
|
||||||
|
- `publishedDaysOfWeek`는 `RANDOM` 포함 시 다른 요일을 무시하고 locale별 랜덤 문구를 반환한다.
|
||||||
|
- 7개 요일은 locale별 매일 문구를 반환한다.
|
||||||
|
- 일부 요일은 `SUN`부터 `SAT` 순서로 locale별 `매주/Every/毎週` 문구를 반환한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest`
|
||||||
|
- GREEN: `CreatorChannelAudioQueryPolicy`와 같은 page/sort/list 정책을 구현하되 `purchaseRate`는 `Int`를 반환한다. `publishedDaysOfWeekText(days, locale)`는 `ko`, `en`, `ja` 명시 매핑으로 구현한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest`
|
||||||
|
- REFACTOR: `CreatorChannelAudioQueryPolicy`를 수정하지 않는다. 중복 제거는 이번 범위에서 하지 않는다.
|
||||||
|
- 구현 기록(2026-06-20): `CreatorChannelSeriesQueryPolicyTest`를 추가해 sort/page/list/구매율/연재 요일 정책을 문서 명세대로 고정했다.
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest` 실행 시 신규 `CreatorChannelSeriesQueryPolicy`, domain, port 타입 부재로 `compileTestKotlin` 실패를 확인했다.
|
||||||
|
- GREEN: `CreatorChannelSeriesQueryPolicy`를 추가하고 동일 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
|
||||||
|
- [x] **Task 1.2: 시리즈 탭 domain model과 port record 추가**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesTab.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/port/out/CreatorChannelSeriesQueryPort.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicyTest.kt`
|
||||||
|
- RED: Task 1.1 테스트 컴파일이 새 domain/port 타입 부재로 실패하는 상태를 확인한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest`
|
||||||
|
- GREEN: 문서의 Domain / Port 초안 그대로 타입을 추가한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest`
|
||||||
|
- REFACTOR: domain/port가 `kr.co.vividnext.sodalive.v2.api.*` 패키지를 import하지 않는지 확인한다.
|
||||||
|
- 구현 기록(2026-06-20): 문서의 Domain / Port 초안 기준으로 `CreatorChannelSeriesTab`, `CreatorChannelSeriesQueryPort`와 관련 record를 추가했다.
|
||||||
|
- 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 의존성 확인: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series` 실행 결과 출력이 없어 domain/port의 API 패키지 의존이 없음을 확인했다.
|
||||||
|
|
||||||
|
### Phase 2: API 조립 계층 추가
|
||||||
|
|
||||||
|
- [x] **Task 2.1: 응답 DTO mapper 테스트와 DTO 추가**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/dto/CreatorChannelSeriesTabResponse.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt`
|
||||||
|
- RED: facade 테스트 또는 DTO mapper 테스트에서 `CreatorChannelSeriesTabResponse.from` 결과가 `seriesCount`, `series`, `sort`, `page`, `size`, `hasNext`, `coverImageUrl`, `purchasedPaidContentRate: Int?`를 그대로 매핑하는지 기대한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest`
|
||||||
|
- GREEN: Response data class 초안대로 DTO와 mapper를 추가한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest`
|
||||||
|
- REFACTOR: Jackson boolean property는 `@JsonProperty("isOriginal")`, `@JsonProperty("isAdult")`, `@JsonProperty("isProceeding")`, `@JsonProperty("hasNext")`로 명시한다.
|
||||||
|
- 구현 기록(2026-06-20): `CreatorChannelSeriesFacadeTest`에 DTO mapper 검증을 추가하고 `CreatorChannelSeriesTabResponse`, `CreatorChannelSeriesResponse`를 초안대로 추가했다.
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest` 실행 시 DTO/facade/query service 타입 부재로 `compileTestKotlin` 실패를 확인했다.
|
||||||
|
- GREEN: 동일 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
|
||||||
|
- [x] **Task 2.2: Facade 추가**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacade.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt`
|
||||||
|
- RED: `CreatorChannelSeriesFacade.getSeriesTab(creatorId, viewer, sort, page, size, now)`가 query service 호출 결과를 `CreatorChannelSeriesTabResponse`로 변환하는 테스트를 작성한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest`
|
||||||
|
- GREEN: `CreatorChannelAudioFacade`와 같은 형태로 read-only service를 추가한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest`
|
||||||
|
- REFACTOR: facade는 HTTP 예외 처리와 repository 세부 사항을 알지 않도록 query service에 위임한다.
|
||||||
|
- 구현 기록(2026-06-20): `CreatorChannelSeriesFacade.getSeriesTab`을 추가해 query service 결과를 공개 DTO로 변환하도록 했다.
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest` 실행 시 facade/query service 타입 부재로 `compileTestKotlin` 실패를 확인했다.
|
||||||
|
- GREEN: 동일 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
|
||||||
|
- [x] **Task 2.3: Controller 추가**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesController.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt`
|
||||||
|
- RED: MockMvc 테스트를 작성한다.
|
||||||
|
- `GET /api/v2/creator-channels/{creatorId}/series?sort=POPULAR&page=1&size=20` 요청이 facade에 `sort="POPULAR"`, `page=1`, `size=20`을 전달한다.
|
||||||
|
- 응답 JSON에 `seriesCount`, `series[0].seriesId`, `series[0].coverImageUrl`, `series[0].publishedDaysOfWeek`, `series[0].purchasedPaidContentRate`, `sort`, `page`, `size`, `hasNext`가 있다.
|
||||||
|
- 비회원 요청은 `common.error.bad_credentials` 계열 오류를 반환한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest`
|
||||||
|
- GREEN: `CreatorChannelAudioController`와 같은 `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/series")`, `requireMember` 구조로 controller를 추가한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest`
|
||||||
|
- REFACTOR: `sort`는 `String?`으로 받고 `ContentSort` enum binding 오류가 발생하지 않게 한다.
|
||||||
|
- 구현 기록(2026-06-20): `CreatorChannelSeriesController`와 MockMvc 테스트를 추가해 인증 회원 요청, query parameter 전달, invalid sort 전달, 비회원 거부를 검증했다.
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest` 실행 시 Kotlin incremental cache 손상(`Malformed input`)으로 중단되어 controller 부재 메시지까지 도달하지 못했다.
|
||||||
|
- GREEN: `./gradlew clean test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
|
||||||
|
### Phase 3: 도메인 조회 서비스 추가
|
||||||
|
|
||||||
|
- [x] **Task 3.1: QueryService 인증/차단/creator 검증 테스트 작성**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt`
|
||||||
|
- RED: 아래 서비스 테스트를 작성한다.
|
||||||
|
- `findCreator`가 `null`이면 `member.validation.user_not_found` 예외를 던진다.
|
||||||
|
- creator role이 `CREATOR`가 아니면 `member.validation.creator_not_found` 예외를 던진다.
|
||||||
|
- 차단 관계가 있으면 기존 크리에이터 채널과 같은 blocked access 예외를 던진다.
|
||||||
|
- 정상 조회 시 policy가 보정한 sort/page를 사용해 port를 호출한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest`
|
||||||
|
- GREEN: `CreatorChannelAudioQueryService` 흐름을 기준으로 `ObjectProvider<CreatorChannelSeriesQueryPort>`, `MemberContentPreferenceService`, `SodaMessageSource`, `LangContext`, `cloud.aws.cloud-front.host`를 주입받는 service를 추가한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest`
|
||||||
|
- REFACTOR: 서비스는 repository record의 `coverImagePath`를 `String?.toCdnUrl(cloudFrontHost)`로 변환해 domain의 `coverImageUrl`에 채운다.
|
||||||
|
- 구현 기록(2026-06-20): `CreatorChannelSeriesQueryServiceTest`에 creator 조회 실패, creator role 검증, 차단 예외, sort/page fallback과 port 호출 검증을 추가하고 service를 구현했다.
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest` 실행 시 query service 타입 부재로 `compileTestKotlin` 실패를 확인했다.
|
||||||
|
- GREEN: 동일 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
|
||||||
|
- [x] **Task 3.2: QueryService 응답 조립 테스트 작성**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt`
|
||||||
|
- RED: 아래 조립 테스트를 추가한다.
|
||||||
|
- 조회자가 creator 본인이면 각 series item의 `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate`가 `null`이다.
|
||||||
|
- 조회자가 creator가 아니면 `paidContentCount`, `purchasedContentCount`로 `purchasedPaidContentRate` 정수값을 계산한다.
|
||||||
|
- `coverImagePath`가 상대 경로이면 `cloudFrontHost`가 붙은 `coverImageUrl`로 변환되고, blank이면 `coverImageUrl == null`이다.
|
||||||
|
- `fetched.size == size + 1`이면 `hasNext == true`이고 응답 목록은 `size`개만 남는다.
|
||||||
|
- `publishedDaysOfWeek`는 policy의 locale별 문자열로 변환된다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest`
|
||||||
|
- GREEN: service에서 `countSeries`, `findSeries` 결과를 조립하고 creator 본인 여부에 따라 구매 통계 필드를 null 처리한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest`
|
||||||
|
- REFACTOR: 구매율 계산은 service에 직접 두지 않고 `CreatorChannelSeriesQueryPolicy.purchaseRate`를 사용한다.
|
||||||
|
- 구현 기록(2026-06-20): service에서 `countSeries`, `findSeries`, CDN URL, 연재 요일 문자열, hasNext/list limit, creator 본인 구매 통계 null 처리, 비크리에이터 구매율 계산을 조립하도록 했다.
|
||||||
|
- RED: 신규 조립 테스트 작성 후 query service 타입 부재로 `compileTestKotlin` 실패를 확인했다.
|
||||||
|
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
|
||||||
|
### Phase 4: QueryDSL repository 추가
|
||||||
|
|
||||||
|
- [x] **Task 4.1: Repository creator/차단/count 테스트 작성**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/CreatorChannelSeriesQueryRepository.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt`
|
||||||
|
- RED: repository 테스트를 작성한다.
|
||||||
|
- active creator를 `CreatorChannelSeriesCreatorRecord`로 조회한다.
|
||||||
|
- viewer와 creator 사이 차단 관계가 있으면 `existsBlockedBetween == true`다.
|
||||||
|
- `countSeries`는 `series.isActive == true`, `series.member.id == creatorId`, 성인 콘텐츠 노출 정책을 반영한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`
|
||||||
|
- GREEN: 오디오 탭 repository의 creator/차단 조회 패턴을 복사해 series 패키지용 repository를 추가한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`
|
||||||
|
- REFACTOR: count 쿼리는 목록 쿼리와 같은 공개 시리즈/성인 정책을 공유하는 private condition을 사용한다.
|
||||||
|
- 구현 기록(2026-06-20): `CreatorChannelSeriesQueryRepository`, `DefaultCreatorChannelSeriesQueryRepository`, repository 테스트를 추가해 creator 조회, 양방향 차단, series count의 활성/creator/성인 정책을 검증했다.
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` 실행 시 `DefaultCreatorChannelSeriesQueryRepository` 타입 부재로 `compileTestKotlin` 실패를 확인했다.
|
||||||
|
- GREEN: 동일 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
|
||||||
|
- [x] **Task 4.2: Repository 목록 필드/번역/통계 테스트 작성**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt`
|
||||||
|
- RED: `findSeries` 테스트를 작성한다.
|
||||||
|
- locale에 맞는 `SeriesTranslation` title이 있으면 번역명을 반환하고, 없거나 빈 문자열이면 원문 title을 반환한다.
|
||||||
|
- `coverImagePath`는 `Series.coverImage` 값을 반환한다.
|
||||||
|
- `contentCount`는 공개 콘텐츠 기준으로 계산한다.
|
||||||
|
- `paidContentCount`는 공개 콘텐츠 중 `price > 0`만 계산한다.
|
||||||
|
- `purchasedContentCount`는 viewer의 active 소장 주문과 만료되지 않은 active 대여 주문을 중복 없이 계산한다.
|
||||||
|
- 예약 공개 전 콘텐츠와 `releaseDate == null` 콘텐츠는 통계에서 제외한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`
|
||||||
|
- GREEN: `seriesContent`와 `audioContent`를 기준으로 시리즈 목록을 조회하고, 목록의 series id 묶음에 대해 콘텐츠 통계를 bulk 조회해 record에 채운다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`
|
||||||
|
- REFACTOR: N+1 조회가 생기지 않도록 `seriesIds` 기반 bulk map을 사용한다.
|
||||||
|
- 구현 기록(2026-06-20): `findSeries`가 시리즈 필드, `SeriesTranslation` title fallback, 공개 콘텐츠 기준 `contentCount`/`paidContentCount`, 유효 KEEP/RENTAL 기반 distinct `purchasedContentCount`를 반환하도록 구현했다.
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` 실행 시 `findSeries` 빈 목록으로 `NoSuchElementException` 실패를 확인했다.
|
||||||
|
- GREEN: 동일 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
|
||||||
|
- [x] **Task 4.3: Repository 정렬 테스트 작성**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt`
|
||||||
|
- RED: 각 정렬별 순서 테스트를 작성한다.
|
||||||
|
- `LATEST`: 시리즈별 `max(audioContent.releaseDate) desc`, `max(audioContent.price) desc`, `series.id desc`
|
||||||
|
- `POPULAR`: `sum(orders.can) desc`, `max(audioContent.releaseDate) desc`, `series.id desc`; inactive order 제외
|
||||||
|
- `OWNED`: viewer의 유효 소장/대여 콘텐츠 개수 desc, `max(audioContent.releaseDate) desc`, `series.id desc`
|
||||||
|
- `PRICE_HIGH`: `max(audioContent.price) desc`, `max(audioContent.releaseDate) desc`, `series.id desc`
|
||||||
|
- `PRICE_LOW`: `min(audioContent.price) asc`, `max(audioContent.releaseDate) desc`, `series.id desc`
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`
|
||||||
|
- GREEN: `groupBy(series.id)` 기반 QueryDSL 정렬을 구현한다. 정렬 대표값은 공개 콘텐츠 조건을 적용한 조인 결과에서 계산한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`
|
||||||
|
- REFACTOR: 콘텐츠가 없는 시리즈는 대표값이 없는 항목으로 같은 정렬 내 마지막에 오도록 null 정렬 처리를 테스트와 구현에 고정한다.
|
||||||
|
- 구현 기록(2026-06-20): `LATEST`, `POPULAR`, `OWNED`, `PRICE_HIGH`, `PRICE_LOW` 정렬 테스트를 추가하고 공개 콘텐츠 대표값 및 주문 조건 기반 QueryDSL group 정렬을 구현했다.
|
||||||
|
- RED/GREEN: 정렬 테스트 추가 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` 실행 결과 기존 구현이 정렬 계약을 만족해 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 리뷰 보완: `OWNED` 정렬이 구매 개수가 아닌 공개 콘텐츠 개수로 정렬될 수 있는 문제를 발견해, 미구매 공개 콘텐츠가 더 많은 시리즈 fixture를 추가했다. RED로 `AssertionFailedError`를 확인한 뒤 `ownedOrder.audioContent.id.countDistinct()` 기준으로 수정하고 동일 명령 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
|
||||||
|
### Phase 5: API 통합 검증
|
||||||
|
|
||||||
|
- [x] **Task 5.1: End-to-End 테스트 추가**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesEndToEndTest.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesController.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt`
|
||||||
|
- RED: 실제 Spring context 기반 테스트를 작성한다.
|
||||||
|
- `GET /api/v2/creator-channels/{creatorId}/series`가 성공하고 PRD의 전체 응답 필드를 반환한다.
|
||||||
|
- invalid `sort`, 음수 `page`, 작은 `size`가 fallback되어 응답의 `sort/page/size`에 반영된다.
|
||||||
|
- 비크리에이터 viewer는 구매 통계 정수 비율을 받는다.
|
||||||
|
- creator 본인은 구매 통계 필드가 `null`이다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest`
|
||||||
|
- GREEN: controller, facade, service, repository wiring 누락을 보완한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest`
|
||||||
|
- REFACTOR: API 응답 필드명이 PRD와 다르면 PRD 또는 코드를 먼저 맞춘 뒤 테스트를 갱신한다.
|
||||||
|
- 구현 기록(2026-06-20): `CreatorChannelSeriesEndToEndTest`를 추가해 실제 Spring context에서 controller-service-repository 경로를 검증했다.
|
||||||
|
- 검증 시나리오: 인증 회원의 전체 응답 필드, invalid `sort`/음수 `page`/작은 `size` fallback, 비크리에이터 구매 통계 정수 비율, creator 본인 구매 통계 `null` 응답을 확인했다.
|
||||||
|
- RED/GREEN: 신규 E2E 테스트 파일 추가 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest` 실행 결과 기존 wiring이 계약을 만족해 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 보완: controller/facade/service/repository production code 수정은 필요하지 않았다.
|
||||||
|
|
||||||
|
- [x] **Task 5.2: 회귀 검증과 문서 검증 기록**
|
||||||
|
- Files:
|
||||||
|
- Modify: `docs/20260620_크리에이터_채널_시리즈_탭_API/plan-task.md`
|
||||||
|
- Verify: `docs/20260620_크리에이터_채널_시리즈_탭_API/prd.md`
|
||||||
|
- RED: 문서와 코드 계약 차이를 확인한다.
|
||||||
|
- `rg -n "CreatorChannelHome.kt에 있는 CreatorChannelSeries|purchasedPaidContentRate|GET /api/v2/creator-channels/.*/series|PRICE_LOW|RANDOM" docs/20260620_크리에이터_채널_시리즈_탭_API`
|
||||||
|
- 실패 확인: 문서와 구현 계약이 불일치하면 해당 task를 완료하지 않는다.
|
||||||
|
- GREEN: 단일 테스트와 관련 회귀 테스트를 실행한다.
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest`
|
||||||
|
- 통과 확인: `./gradlew test`
|
||||||
|
- REFACTOR: Kotlin 포맷 검증은 `./gradlew ktlintCheck`로 확인한다.
|
||||||
|
- 문서 기록: 구현 완료 시 각 task 아래에 실행 명령, 성공/실패 결과, 수정 내용을 한국어로 누적 기록한다.
|
||||||
|
- 검증 기록(2026-06-20): 문서 계약 검색과 Phase 5 focused 회귀를 실행했다.
|
||||||
|
- 문서 계약 검색: `rg -n "CreatorChannelHome.kt에 있는 CreatorChannelSeries|purchasedPaidContentRate|GET /api/v2/creator-channels/.*/series|PRICE_LOW|RANDOM" docs/20260620_크리에이터_채널_시리즈_탭_API` 실행으로 PRD/plan의 endpoint, 구매 통계, `PRICE_LOW`, `RANDOM` 계약 기재를 확인했다.
|
||||||
|
- 통과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 통과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 통과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`는 병렬 실행 중 XML 결과 파일 동시 쓰기 실패가 발생했으나, 동일 명령 순차 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 통과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 통과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- OOM 원인 보완: 기본 `./gradlew test`에서 test worker가 `-Xmx512m`로 실행되어 full Spring context 누적 시 `Gradle Test Executor`의 `Java heap space` 실패가 발생했다. `build.gradle.kts`의 `tasks.withType<Test>`에 `maxHeapSize = "1536m"`를 명시해 test worker heap을 1.5g로 고정했다.
|
||||||
|
- context 재사용 보완: `CreatorChannelSeriesEndToEndTest`의 H2 datasource URL을 기존 creator-channel E2E와 같은 `creator-channel-live-e2e`로 맞춰 `audio/live/series` E2E가 Spring context를 공유하도록 했다.
|
||||||
|
- 통과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioEndToEndTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveEndToEndTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest --info` 실행 결과 test worker가 `-Xmx1536m`로 실행되고 `HikariPool-1`만 생성되는 것을 확인했으며 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 통과: 기본 `./gradlew test` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 통과: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 전체 검증 명령
|
||||||
|
|
||||||
|
구현 완료 후 아래 순서로 실행한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest
|
||||||
|
./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest
|
||||||
|
./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest
|
||||||
|
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest
|
||||||
|
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest
|
||||||
|
./gradlew test
|
||||||
|
./gradlew ktlintCheck
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 계획 자체 검토
|
||||||
|
|
||||||
|
- PRD의 endpoint, request, response data class, 커버 이미지 URL, 정렬, 페이징, 구매 통계, 연재 요일 다국어, creator 본인/비본인 분기 요구사항을 task에 반영했다.
|
||||||
|
- 공개 API 조립 계층과 도메인 조회 계층을 분리했다.
|
||||||
|
- 기존 홈 API의 `CreatorChannelSeries` 확장은 계획에 포함하지 않았다.
|
||||||
|
- `purchasedPaidContentRate`는 `Int?`로 고정했다.
|
||||||
|
- `RANDOM` 포함 시 다른 요일을 무시하는 정책을 테스트 task에 포함했다.
|
||||||
|
- 시리즈별 정렬 대표값은 `max(releaseDate)`, `max(price)`, `min(price)`로 명시했다.
|
||||||
|
- Open Questions는 PRD 기준 없음.
|
||||||
262
docs/20260620_크리에이터_채널_시리즈_탭_API/prd.md
Normal file
262
docs/20260620_크리에이터_채널_시리즈_탭_API/prd.md
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
# PRD: 크리에이터 채널 시리즈 탭 API
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
크리에이터 채널의 시리즈 탭에서 정렬별 시리즈 개수와 시리즈 목록을 페이징 조회하는 API를 제공한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problem
|
||||||
|
- 크리에이터 채널 시리즈 탭은 전체 시리즈 개수, 정렬 상태, 시리즈 목록을 함께 표시해야 한다.
|
||||||
|
- 기존 홈 API의 `CreatorChannelSeries`는 홈 화면용 요약 모델이라 시리즈 탭에서 필요한 연재 요일, 연재 상태, 콘텐츠 개수, 구매/유료 콘텐츠 통계를 모두 표현하지 못한다.
|
||||||
|
- 클라이언트는 시리즈 탭 진입과 추가 로딩 시 별도 API 조합 없이 일관된 계약으로 시리즈 목록을 받아야 한다.
|
||||||
|
- 연재 요일 문구는 서버에서 조합하고, 호출 유저의 언어에 맞게 반환해야 한다.
|
||||||
|
- 기존 크리에이터 채널 홈/라이브/오디오 탭 API endpoint와 응답 필드의 의미는 변경하지 않아야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
- 크리에이터 채널 시리즈 탭 조회 API를 제공한다.
|
||||||
|
- 클라이언트에서 호출하는 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.series` 하위 조립 계층에 둔다.
|
||||||
|
- 시리즈 목록, 시리즈 개수, 구매/유료 콘텐츠 통계, 연재 요일 조합처럼 재사용 가능한 조회 책임은 API 패키지 밖의 도메인 패키지에 둔다.
|
||||||
|
- 기존 홈 API의 `CreatorChannelSeries`는 확장하지 않고, 시리즈 탭 전용 도메인 모델과 응답 DTO를 새로 둔다.
|
||||||
|
- 요청은 `creatorId`, 정렬 순서, 페이징 값을 받는다.
|
||||||
|
- 정렬 순서를 보내지 않으면 최신순을 기본값으로 사용한다.
|
||||||
|
- 페이징 동작은 크리에이터 채널 오디오 탭 API와 같은 방식으로 처리한다.
|
||||||
|
- 응답에는 전체 시리즈 개수, 시리즈 목록, 실제 적용된 정렬 순서, page, size, hasNext를 포함한다.
|
||||||
|
- 시리즈 목록 item에는 시리즈 id, 제목, 커버 이미지 URL, 연재 요일 문구, 오리지널 여부, 19금 여부, 연재 중 여부, 전체 콘텐츠 개수를 포함한다.
|
||||||
|
- 조회자가 해당 시리즈의 크리에이터가 아닌 경우에는 구매한 콘텐츠 개수, 유료 콘텐츠 개수, 유료 콘텐츠 중 구매한 콘텐츠 비율도 포함한다.
|
||||||
|
- 시리즈 제목과 연재 요일 문구는 호출 유저 언어코드에 맞게 반환한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
- 이번 범위는 크리에이터 채널 `시리즈` 탭 조회 API만 포함한다.
|
||||||
|
- 기존 크리에이터 채널 홈 API, 라이브 탭 API, 오디오 탭 API endpoint와 응답 필드의 의미는 변경하지 않는다.
|
||||||
|
- 시리즈 상세 조회 API는 포함하지 않는다.
|
||||||
|
- 시리즈 생성/수정/삭제 API는 포함하지 않는다.
|
||||||
|
- 오디오 콘텐츠 구매, 소장, 대여, 결제 API는 포함하지 않는다.
|
||||||
|
- 시리즈 번역 데이터가 없는 항목을 새로 번역하거나 생성하는 배치 작업은 포함하지 않는다.
|
||||||
|
- 앱 표시용 날짜 포맷, 가격 단위 표시는 서버에서 처리하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Target Users
|
||||||
|
- 회원: 크리에이터 채널 시리즈 탭에서 크리에이터의 시리즈를 탐색하는 사용자
|
||||||
|
- 앱 클라이언트: 시리즈 탭 구성에 필요한 개수/목록/구매 통계를 단일 API 응답으로 표시하려는 클라이언트
|
||||||
|
- 크리에이터: 자신의 시리즈가 정렬 기준에 따라 적절히 노출되기를 원하는 사용자
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. User Stories
|
||||||
|
- 사용자는 크리에이터 채널 시리즈 탭에 들어가면 전체 시리즈 개수를 확인하고 싶다.
|
||||||
|
- 사용자는 최신순, 인기순, 소장순, 높은 가격순, 낮은 가격순으로 시리즈 목록을 바꿔 보고 싶다.
|
||||||
|
- 사용자는 시리즈의 연재 요일과 연재 중 여부를 확인하고 싶다.
|
||||||
|
- 사용자는 유료 콘텐츠 중 자신이 구매한 콘텐츠 비율을 확인하고 싶다.
|
||||||
|
- 앱 클라이언트는 현재 적용된 정렬 순서를 응답에서 확인해 화면 상태와 서버 조회 결과를 맞추고 싶다.
|
||||||
|
- 앱 클라이언트는 호출 유저 언어코드에 맞는 시리즈 제목과 연재 요일 문구를 받아 화면에 표시하고 싶다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Core Features
|
||||||
|
|
||||||
|
### Feature A. 크리에이터 채널 시리즈 탭 조회 API
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 신규 API는 크리에이터 채널 전용 v2 API로 작성한다.
|
||||||
|
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/series`를 기본안으로 한다.
|
||||||
|
- `creatorId`는 path variable로 받는다.
|
||||||
|
- 정렬 순서는 query parameter로 받는다.
|
||||||
|
- 정렬 순서 query parameter 이름은 `sort`를 기본안으로 한다.
|
||||||
|
- `sort`를 보내지 않으면 `LATEST`를 기본값으로 사용한다.
|
||||||
|
- `sort` 값이 없거나 기존 `ContentSort` enum 값에 없으면 `LATEST`로 fallback한다.
|
||||||
|
- 시리즈 추가 로딩을 위해 `page`, `size` query parameter를 받는다.
|
||||||
|
- `page`는 0부터 시작하는 page index로 처리한다.
|
||||||
|
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
|
||||||
|
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
|
||||||
|
- `page`가 0보다 작으면 `0`으로 fallback한다.
|
||||||
|
- `size`가 20보다 작으면 `20`으로 fallback한다.
|
||||||
|
- `size`가 50보다 크면 `50`으로 fallback한다.
|
||||||
|
- API는 인증 회원만 조회할 수 있어야 한다.
|
||||||
|
- 비회원이 조회하면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
|
||||||
|
- 조회 대상 회원이 존재하지 않으면 기존 정책과 동일하게 `member.validation.user_not_found` 계열 오류를 반환한다.
|
||||||
|
- 조회 대상 회원이 크리에이터가 아니면 기존 정책과 동일하게 `member.validation.creator_not_found` 계열 오류를 반환한다.
|
||||||
|
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
|
||||||
|
- 공개된 시리즈가 없어도 전체 API는 성공 처리한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다.
|
||||||
|
- 알 수 없는 `sort` 값은 400 오류를 반환하지 않고 `LATEST`로 fallback한다.
|
||||||
|
- `page`가 0보다 작거나 `size`가 허용 범위를 벗어나도 400 오류를 반환하지 않고 실제 적용값으로 보정한다.
|
||||||
|
|
||||||
|
### Feature B. 응답 스키마
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 응답 DTO는 구현 전에 명시하고 공개 API 계약으로 관리한다.
|
||||||
|
- 응답 최상위 DTO 이름은 `CreatorChannelSeriesTabResponse`를 기본안으로 한다.
|
||||||
|
- 응답에는 다음 값을 포함한다.
|
||||||
|
- `seriesCount`: 조회 가능한 전체 시리즈 개수
|
||||||
|
- `series`: 시리즈 목록
|
||||||
|
- `sort`: 시리즈 조회에 실제 적용한 정렬 순서
|
||||||
|
- `page`: 현재 응답의 page index
|
||||||
|
- `size`: 현재 응답의 page size
|
||||||
|
- `hasNext`: 다음 page 존재 여부
|
||||||
|
- `seriesCount`는 sort-bar에 표시할 전체 개수이며, 콘텐츠 개수가 아니라 시리즈 개수다.
|
||||||
|
- `sort`는 요청값이 없거나 알 수 없는 값이면 실제 적용값인 `LATEST`를 내려준다.
|
||||||
|
- `page`, `size`는 fallback 보정 이후 실제 적용된 값을 내려준다.
|
||||||
|
- `hasNext`는 같은 정렬 조건에서 다음 page에 노출할 시리즈가 있으면 `true`로 내려준다.
|
||||||
|
- 조회자가 해당 시리즈의 크리에이터인 경우 `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate`는 `null`로 내려준다.
|
||||||
|
- 조회자가 해당 시리즈의 크리에이터가 아닌 경우 `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate`를 계산해 내려준다.
|
||||||
|
- `purchasedPaidContentRate`는 정수 퍼센트 값으로 내려준다.
|
||||||
|
- `purchasedPaidContentRate`는 `paidContentCount == 0`이면 `0`으로 내려준다.
|
||||||
|
- `purchasedPaidContentRate`는 `(purchasedContentCount * 100) / paidContentCount`를 기준으로 계산하고 소수점 이하는 버린다.
|
||||||
|
- 응답 스키마 예시는 다음과 같다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
data class CreatorChannelSeriesTabResponse(
|
||||||
|
val seriesCount: Int,
|
||||||
|
val series: List<CreatorChannelSeriesResponse>,
|
||||||
|
val sort: ContentSort,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
val hasNext: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelSeriesResponse(
|
||||||
|
val seriesId: Long,
|
||||||
|
val title: String,
|
||||||
|
val coverImageUrl: String?,
|
||||||
|
val publishedDaysOfWeek: String,
|
||||||
|
val isOriginal: Boolean,
|
||||||
|
val isAdult: Boolean,
|
||||||
|
val isProceeding: Boolean,
|
||||||
|
val contentCount: Int,
|
||||||
|
val purchasedContentCount: Int?,
|
||||||
|
val paidContentCount: Int?,
|
||||||
|
val purchasedPaidContentRate: Int?
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class ContentSort {
|
||||||
|
LATEST,
|
||||||
|
POPULAR,
|
||||||
|
OWNED,
|
||||||
|
PRICE_HIGH,
|
||||||
|
PRICE_LOW
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 공개된 시리즈가 없으면 `seriesCount`는 `0`, `series`는 빈 배열, `hasNext`는 `false`로 내려준다.
|
||||||
|
- 요청한 page 범위에 시리즈가 없으면 `series`는 빈 배열, `hasNext`는 `false`로 내려주되 `seriesCount`는 전체 개수를 유지한다.
|
||||||
|
- 무료 콘텐츠만 포함한 시리즈는 비크리에이터 조회 시 `paidContentCount`를 `0`, `purchasedContentCount`를 `0`, `purchasedPaidContentRate`를 `0`으로 내려준다.
|
||||||
|
|
||||||
|
### Feature C. 시리즈 목록과 필드
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 조회 대상은 지정한 `creatorId`의 시리즈로 제한한다.
|
||||||
|
- 공개 가능한 활성 시리즈만 조회한다.
|
||||||
|
- 조회자의 성인 콘텐츠 노출 정책이 false이면 19금 시리즈는 목록과 개수에서 제외한다.
|
||||||
|
- 시리즈에 속한 콘텐츠 통계는 공개된 오디오 콘텐츠만 기준으로 계산한다.
|
||||||
|
- 예약 공개 전 콘텐츠는 콘텐츠 통계에서 제외한다.
|
||||||
|
- `releaseDate == null`인 오디오 콘텐츠는 삭제/미공개 데이터로 보고 콘텐츠 통계에서 제외한다.
|
||||||
|
- 시리즈 제목은 호출 유저 언어코드에 맞는 번역값이 있으면 번역명을 사용하고, 없으면 원문 시리즈명을 사용한다.
|
||||||
|
- `coverImageUrl`은 시리즈의 커버 이미지 경로를 CDN URL로 변환해 내려준다.
|
||||||
|
- 시리즈 커버 이미지 경로가 없거나 빈 문자열이면 `coverImageUrl`은 `null`로 내려준다.
|
||||||
|
- 호출 유저 언어코드는 기존 `LangContext.lang.code` 값을 사용한다.
|
||||||
|
- 지원 언어코드는 `ko`, `en`, `ja`를 기준으로 한다.
|
||||||
|
- `isProceeding`은 `SeriesState.PROCEEDING`이면 `true`, 그 외 상태이면 `false`로 내려준다.
|
||||||
|
- `contentCount`는 조회 가능한 공개 콘텐츠 개수다.
|
||||||
|
- `paidContentCount`는 같은 필터를 적용한 콘텐츠 중 `price > 0`인 콘텐츠 개수다.
|
||||||
|
- `purchasedContentCount`는 같은 필터를 적용한 유료 콘텐츠 중 조회자가 유효하게 소장하거나 대여 중인 콘텐츠 개수다.
|
||||||
|
- 대여 중인 콘텐츠는 구매한 콘텐츠 개수와 `purchasedPaidContentRate` 계산에 포함한다.
|
||||||
|
- 유효 구매/대여 조건은 기존 오디오 탭과 동일하게 `orders.is_active = true`이며, 대여는 만료되지 않은 주문만 포함한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 제목 번역 데이터는 있지만 빈 문자열이면 원문 시리즈명을 fallback으로 사용한다.
|
||||||
|
- 콘텐츠가 없는 활성 시리즈는 시리즈 목록에 포함하되 `contentCount`, `paidContentCount`, `purchasedContentCount`를 `0`으로 계산한다.
|
||||||
|
- 일반적으로 동일 콘텐츠의 소장과 대여가 동시에 발생하지 않지만, 데이터상 동시에 유효한 소장/대여 주문이 있으면 콘텐츠 1개로 중복 없이 계산한다.
|
||||||
|
|
||||||
|
### Feature D. 연재 요일 문구
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- `publishedDaysOfWeek`는 서버에서 조합한 문자열로 내려준다.
|
||||||
|
- 일요일부터 토요일까지 7개 요일이 모두 있으면 호출 유저 언어에 맞는 `매일` 문구를 내려준다.
|
||||||
|
- 7개 요일이 모두 있지 않으면 호출 유저 언어에 맞는 `매주 {요일 목록}` 문구를 내려준다.
|
||||||
|
- 요일 목록은 `SUN`, `MON`, `TUE`, `WED`, `THU`, `FRI`, `SAT` 순서로 정렬한다.
|
||||||
|
- 한국어 예시는 `매일`, `매주 월, 목, 토`다.
|
||||||
|
- 영어 예시는 `Every day`, `Every Mon, Thu, Sat`다.
|
||||||
|
- 일본어 예시는 `毎日`, `毎週 月, 木, 土`다.
|
||||||
|
- `SeriesPublishedDaysOfWeek.RANDOM`이 포함된 경우에는 다른 요일 값을 모두 무시하고 호출 유저 언어에 맞는 랜덤 문구만 내려준다.
|
||||||
|
- 랜덤 문구도 다국어 처리한다.
|
||||||
|
- 랜덤 문구는 한국어 `랜덤`, 영어 `Random`, 일본어 `ランダム`을 기본안으로 한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 연재 요일이 비어 있으면 빈 문자열 대신 호출 유저 언어에 맞는 랜덤 문구를 fallback으로 내려준다.
|
||||||
|
- `RANDOM`과 다른 요일이 동시에 저장된 데이터는 `RANDOM`을 우선해 다른 요일을 제거한 것과 같은 결과로 랜덤 문구만 내려준다.
|
||||||
|
|
||||||
|
### Feature E. 시리즈 정렬
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 정렬 순서는 기존 공용 `ContentSort` enum을 사용한다.
|
||||||
|
- 공개 요청/응답 값은 다음을 사용한다.
|
||||||
|
- `LATEST`: 최신순, 기본값
|
||||||
|
- `POPULAR`: 인기순
|
||||||
|
- `OWNED`: 소장순
|
||||||
|
- `PRICE_HIGH`: 높은 가격순
|
||||||
|
- `PRICE_LOW`: 낮은 가격순
|
||||||
|
- `LATEST`는 시리즈에 속한 콘텐츠의 `releaseDate desc`를 1차 정렬로 사용한다.
|
||||||
|
- `LATEST`의 2차 정렬은 시리즈에 속한 콘텐츠의 `price desc`다.
|
||||||
|
- `LATEST`의 3차 정렬은 `series.id desc`다.
|
||||||
|
- `POPULAR`은 시리즈에 속한 콘텐츠에 순수하게 결제된 캔 매출 합계(`orders.can`)가 높은 시리즈를 먼저 노출한다.
|
||||||
|
- `POPULAR`의 매출 합계에는 `orders.is_active = true`인 주문만 포함한다.
|
||||||
|
- `POPULAR`의 2차 정렬은 시리즈에 속한 콘텐츠의 `releaseDate desc`다.
|
||||||
|
- `POPULAR`의 3차 정렬은 `series.id desc`다.
|
||||||
|
- `OWNED`는 조회자가 시리즈에 속한 콘텐츠 중 유효하게 소장하거나 대여 중인 콘텐츠 개수가 많은 시리즈를 먼저 노출한다.
|
||||||
|
- `OWNED`의 2차 정렬은 시리즈에 속한 콘텐츠의 `releaseDate desc`다.
|
||||||
|
- `OWNED`의 3차 정렬은 `series.id desc`다.
|
||||||
|
- `PRICE_HIGH`는 시리즈에 속한 콘텐츠의 `price desc`를 1차 정렬로 사용한다.
|
||||||
|
- `PRICE_HIGH`의 2차 정렬은 시리즈에 속한 콘텐츠의 `releaseDate desc`다.
|
||||||
|
- `PRICE_HIGH`의 3차 정렬은 `series.id desc`다.
|
||||||
|
- `PRICE_LOW`는 시리즈에 속한 콘텐츠의 `price asc`를 1차 정렬로 사용한다.
|
||||||
|
- `PRICE_LOW`의 2차 정렬은 시리즈에 속한 콘텐츠의 `releaseDate desc`다.
|
||||||
|
- `PRICE_LOW`의 3차 정렬은 `series.id desc`다.
|
||||||
|
- 시리즈에 여러 콘텐츠가 속한 경우 정렬은 시리즈 단위 집계 대표값을 사용한다.
|
||||||
|
- 정렬용 `releaseDate`는 항상 내림차순 정렬에만 사용하므로 각 시리즈에 속한 공개 콘텐츠 중 가장 최근 `releaseDate`를 대표값으로 사용한다.
|
||||||
|
- `price desc` 정렬에 사용하는 가격 대표값은 각 시리즈에 속한 공개 콘텐츠 중 가장 높은 가격이다.
|
||||||
|
- `price asc` 정렬에 사용하는 가격 대표값은 각 시리즈에 속한 공개 콘텐츠 중 가장 낮은 가격이다.
|
||||||
|
- 따라서 `LATEST`의 2차 정렬과 `PRICE_HIGH`의 1차 정렬은 시리즈별 최고 가격을 사용하고, `PRICE_LOW`의 1차 정렬은 시리즈별 최저 가격을 사용한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 매출이 없는 시리즈의 인기순 매출값은 0으로 처리한다.
|
||||||
|
- 조회자가 유효하게 소장하거나 대여 중인 콘텐츠가 없으면 `OWNED` 정렬은 최신순 + `series.id desc` 보조 정렬과 같은 결과가 될 수 있다.
|
||||||
|
- 콘텐츠가 없는 시리즈는 정렬 대표값이 없는 항목으로 처리해 같은 정렬 내 마지막에 노출한다.
|
||||||
|
- 가격이 같은 시리즈는 각 정렬의 2차/3차 기준을 따른다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Technical Constraints
|
||||||
|
- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다.
|
||||||
|
- Kotlin + Spring Boot 2.7.14 기존 스타일을 따른다.
|
||||||
|
- 신규 공개 API 스키마는 구현 전에 PRD와 구현 계획/TASK 문서에 명시한다.
|
||||||
|
- 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.series` 하위에 둔다.
|
||||||
|
- API 조립 계층은 HTTP 계약과 공개 응답 변환만 담당한다.
|
||||||
|
- 도메인 조회 코드는 `kr.co.vividnext.sodalive.v2.creator.channel.series` 또는 재사용 범위가 더 넓은 기존 도메인 패키지 하위에 둔다.
|
||||||
|
- 도메인 패키지는 `kr.co.vividnext.sodalive.v2.api.*` 패키지에 의존하지 않는다.
|
||||||
|
- 기존 홈 API의 `CreatorChannelSeries`는 홈 응답 전용 요약 모델로 유지하고, 시리즈 탭 API에서는 별도 `CreatorChannelSeriesTab`, `CreatorChannelSeries` 계열 모델을 둔다.
|
||||||
|
- 기존 `ContentSort` enum을 재사용하고, API binding, service 정책, 테스트에서 같은 타입을 사용한다.
|
||||||
|
- 기존 크리에이터 채널 홈/라이브/오디오 API의 인증, 예외, 성인 콘텐츠 노출, 차단 관계 정책은 재사용한다.
|
||||||
|
- 페이징 응답은 기존 오디오 탭 API와 같은 `page`, `size`, `hasNext` 패턴을 따른다.
|
||||||
|
- 시리즈명 다국어 처리는 기존 `SeriesTranslation` 구조를 따른다.
|
||||||
|
- 연재 요일 문구 다국어 처리는 서버 코드의 명시적 매핑으로 처리한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Metrics
|
||||||
|
- 시리즈 탭 API 성공/실패 건수
|
||||||
|
- 시리즈 탭 API 응답 시간
|
||||||
|
- 정렬 기준별 조회 건수
|
||||||
|
- 시리즈 탭에서 추가 로딩 요청 건수
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Open Questions
|
||||||
|
- 없음.
|
||||||
@@ -23,7 +23,16 @@ import java.time.Duration
|
|||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableCaching
|
@EnableCaching
|
||||||
@EnableRedisRepositories
|
@EnableRedisRepositories(
|
||||||
|
basePackages = [
|
||||||
|
"kr.co.vividnext.sodalive.content.playlist",
|
||||||
|
"kr.co.vividnext.sodalive.live.room.info",
|
||||||
|
"kr.co.vividnext.sodalive.live.room.kickout",
|
||||||
|
"kr.co.vividnext.sodalive.live.room.menu",
|
||||||
|
"kr.co.vividnext.sodalive.live.roulette",
|
||||||
|
"kr.co.vividnext.sodalive.member.token"
|
||||||
|
]
|
||||||
|
)
|
||||||
class RedisConfig(
|
class RedisConfig(
|
||||||
@Value("\${spring.redis.host}")
|
@Value("\${spring.redis.host}")
|
||||||
private val host: String,
|
private val host: String,
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.`in`.web
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacade
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v2/creator-channels")
|
||||||
|
class CreatorChannelSeriesController(
|
||||||
|
private val creatorChannelSeriesFacade: CreatorChannelSeriesFacade
|
||||||
|
) {
|
||||||
|
@GetMapping("/{creatorId}/series")
|
||||||
|
fun getSeriesTab(
|
||||||
|
@PathVariable creatorId: Long,
|
||||||
|
@RequestParam(required = false) sort: String?,
|
||||||
|
@RequestParam(required = false) page: Int?,
|
||||||
|
@RequestParam(required = false) size: Int?,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
ApiResponse.ok(
|
||||||
|
creatorChannelSeriesFacade.getSeriesTab(
|
||||||
|
creatorId = creatorId,
|
||||||
|
viewer = requireMember(member),
|
||||||
|
sort = sort,
|
||||||
|
page = page,
|
||||||
|
size = size
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireMember(member: Member?): Member {
|
||||||
|
return member ?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.series.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.series.dto.CreatorChannelSeriesTabResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryService
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
class CreatorChannelSeriesFacade(
|
||||||
|
private val creatorChannelSeriesQueryService: CreatorChannelSeriesQueryService
|
||||||
|
) {
|
||||||
|
fun getSeriesTab(
|
||||||
|
creatorId: Long,
|
||||||
|
viewer: Member,
|
||||||
|
sort: String?,
|
||||||
|
page: Int?,
|
||||||
|
size: Int?,
|
||||||
|
now: LocalDateTime = LocalDateTime.now()
|
||||||
|
): CreatorChannelSeriesTabResponse {
|
||||||
|
return CreatorChannelSeriesTabResponse.from(
|
||||||
|
creatorChannelSeriesQueryService.getSeriesTab(
|
||||||
|
creatorId = creatorId,
|
||||||
|
viewer = viewer,
|
||||||
|
sort = sort,
|
||||||
|
page = page,
|
||||||
|
size = size,
|
||||||
|
now = now
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.series.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeries
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesTab
|
||||||
|
|
||||||
|
data class CreatorChannelSeriesTabResponse(
|
||||||
|
val seriesCount: Int,
|
||||||
|
val series: List<CreatorChannelSeriesResponse>,
|
||||||
|
val sort: ContentSort,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
@JsonProperty("hasNext")
|
||||||
|
val hasNext: Boolean
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(tab: CreatorChannelSeriesTab): CreatorChannelSeriesTabResponse {
|
||||||
|
return CreatorChannelSeriesTabResponse(
|
||||||
|
seriesCount = tab.seriesCount,
|
||||||
|
series = tab.series.map(CreatorChannelSeriesResponse::from),
|
||||||
|
sort = tab.sort,
|
||||||
|
page = tab.page.page,
|
||||||
|
size = tab.page.size,
|
||||||
|
hasNext = tab.hasNext
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelSeriesResponse(
|
||||||
|
val seriesId: Long,
|
||||||
|
val title: String,
|
||||||
|
val coverImageUrl: String?,
|
||||||
|
val publishedDaysOfWeek: String,
|
||||||
|
@JsonProperty("isOriginal")
|
||||||
|
val isOriginal: Boolean,
|
||||||
|
@JsonProperty("isAdult")
|
||||||
|
val isAdult: Boolean,
|
||||||
|
@JsonProperty("isProceeding")
|
||||||
|
val isProceeding: Boolean,
|
||||||
|
val contentCount: Int,
|
||||||
|
val purchasedContentCount: Int?,
|
||||||
|
val paidContentCount: Int?,
|
||||||
|
val purchasedPaidContentRate: Int?
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(series: CreatorChannelSeries): CreatorChannelSeriesResponse {
|
||||||
|
return CreatorChannelSeriesResponse(
|
||||||
|
seriesId = series.seriesId,
|
||||||
|
title = series.title,
|
||||||
|
coverImageUrl = series.coverImageUrl,
|
||||||
|
publishedDaysOfWeek = series.publishedDaysOfWeek,
|
||||||
|
isOriginal = series.isOriginal,
|
||||||
|
isAdult = series.isAdult,
|
||||||
|
isProceeding = series.isProceeding,
|
||||||
|
contentCount = series.contentCount,
|
||||||
|
purchasedContentCount = series.purchasedContentCount,
|
||||||
|
paidContentCount = series.paidContentCount,
|
||||||
|
purchasedPaidContentRate = series.purchasedPaidContentRate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesQueryPort
|
||||||
|
|
||||||
|
interface CreatorChannelSeriesQueryRepository : CreatorChannelSeriesQueryPort
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence
|
||||||
|
|
||||||
|
import com.querydsl.core.Tuple
|
||||||
|
import com.querydsl.core.types.Projections
|
||||||
|
import com.querydsl.core.types.dsl.BooleanExpression
|
||||||
|
import com.querydsl.core.types.dsl.CaseBuilder
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||||
|
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||||
|
import kr.co.vividnext.sodalive.content.order.QOrder
|
||||||
|
import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
||||||
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
|
import kr.co.vividnext.sodalive.member.block.QBlockMember
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesCreatorRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesRecord
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class DefaultCreatorChannelSeriesQueryRepository(
|
||||||
|
private val queryFactory: JPAQueryFactory
|
||||||
|
) : CreatorChannelSeriesQueryRepository {
|
||||||
|
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelSeriesCreatorRecord? {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
Projections.constructor(
|
||||||
|
CreatorChannelSeriesCreatorRecord::class.java,
|
||||||
|
member.id,
|
||||||
|
member.role,
|
||||||
|
member.nickname
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(member)
|
||||||
|
.where(
|
||||||
|
member.id.eq(creatorId),
|
||||||
|
member.isActive.isTrue
|
||||||
|
)
|
||||||
|
.fetchFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean {
|
||||||
|
val blockMember = QBlockMember("creatorChannelSeriesBlockMember")
|
||||||
|
return queryFactory
|
||||||
|
.select(blockMember.id)
|
||||||
|
.from(blockMember)
|
||||||
|
.where(
|
||||||
|
blockMember.isActive.isTrue,
|
||||||
|
blockMember.member.id.eq(viewerId).and(blockMember.blockedMember.id.eq(creatorId))
|
||||||
|
.or(blockMember.member.id.eq(creatorId).and(blockMember.blockedMember.id.eq(viewerId)))
|
||||||
|
)
|
||||||
|
.fetchFirst() != null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun countSeries(creatorId: Long, now: LocalDateTime, canViewAdultContent: Boolean): Int {
|
||||||
|
return queryFactory
|
||||||
|
.select(series.id.count())
|
||||||
|
.from(series)
|
||||||
|
.where(seriesCondition(creatorId, canViewAdultContent))
|
||||||
|
.fetchOne()
|
||||||
|
?.toInt()
|
||||||
|
?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findSeries(
|
||||||
|
creatorId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
now: LocalDateTime,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
sort: ContentSort,
|
||||||
|
locale: String,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelSeriesRecord> {
|
||||||
|
val seriesIds = findSeriesIds(creatorId, viewerId, now, canViewAdultContent, sort, offset, limit)
|
||||||
|
if (seriesIds.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val seriesTranslation = QSeriesTranslation("creatorChannelSeriesTranslation")
|
||||||
|
val rows = findSeriesRows(seriesIds, locale, seriesTranslation)
|
||||||
|
val contentStats = contentStatsBySeriesIds(seriesIds, now, canViewAdultContent)
|
||||||
|
val purchaseStats = purchaseStatsBySeriesIds(seriesIds, viewerId, now, canViewAdultContent)
|
||||||
|
|
||||||
|
return rows.sortedBy { seriesIds.indexOf(it.get(series)!!.id!!) }
|
||||||
|
.map { row ->
|
||||||
|
val targetSeries = row.get(series)!!
|
||||||
|
val translatedTitle = row.get(seriesTranslation)
|
||||||
|
?.renderedPayload
|
||||||
|
?.title
|
||||||
|
val contentStat = contentStats[targetSeries.id] ?: SeriesContentStats()
|
||||||
|
CreatorChannelSeriesRecord(
|
||||||
|
seriesId = targetSeries.id!!,
|
||||||
|
title = translatedTitle.takeUnless(String?::isNullOrBlank) ?: targetSeries.title,
|
||||||
|
coverImagePath = targetSeries.coverImage,
|
||||||
|
publishedDaysOfWeek = targetSeries.publishedDaysOfWeek,
|
||||||
|
isOriginal = targetSeries.isOriginal,
|
||||||
|
isAdult = targetSeries.isAdult,
|
||||||
|
state = targetSeries.state,
|
||||||
|
contentCount = contentStat.contentCount,
|
||||||
|
purchasedContentCount = purchaseStats[targetSeries.id] ?: 0,
|
||||||
|
paidContentCount = contentStat.paidContentCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findSeriesIds(
|
||||||
|
creatorId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
now: LocalDateTime,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
sort: ContentSort,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<Long> {
|
||||||
|
val revenueOrder = QOrder("seriesRevenueOrder")
|
||||||
|
val ownedOrder = QOrder("seriesOwnedOrder")
|
||||||
|
val latestReleaseDate = audioContent.releaseDate.max()
|
||||||
|
val highestPrice = audioContent.price.max()
|
||||||
|
val lowestPrice = audioContent.price.min()
|
||||||
|
val revenue = revenueOrder.can.sum().coalesce(0)
|
||||||
|
val ownedCount = ownedOrder.audioContent.id.countDistinct()
|
||||||
|
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)
|
||||||
|
.leftJoin(seriesContent).on(seriesContent.series.id.eq(series.id))
|
||||||
|
.leftJoin(audioContent).on(
|
||||||
|
seriesContent.content.id.eq(audioContent.id),
|
||||||
|
publicAudioContentCondition(now, canViewAdultContent)
|
||||||
|
)
|
||||||
|
.where(seriesCondition(creatorId, canViewAdultContent))
|
||||||
|
.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.OWNED -> {
|
||||||
|
query
|
||||||
|
.leftJoin(ownedOrder)
|
||||||
|
.on(
|
||||||
|
ownedOrder.audioContent.id.eq(audioContent.id),
|
||||||
|
ownedOrder.member.id.eq(viewerId),
|
||||||
|
ownedOrder.isActive.isTrue,
|
||||||
|
validPurchasedOrderCondition(ownedOrder, now)
|
||||||
|
)
|
||||||
|
.orderBy(ownedCount.desc(), latestReleaseDate.desc(), series.id.desc())
|
||||||
|
}
|
||||||
|
ContentSort.LATEST -> query.orderBy(
|
||||||
|
latestReleaseDateNullLast.asc(),
|
||||||
|
latestReleaseDate.desc(),
|
||||||
|
highestPrice.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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.offset(offset).limit(limit.toLong()).fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findSeriesRows(
|
||||||
|
seriesIds: List<Long>,
|
||||||
|
locale: String,
|
||||||
|
seriesTranslation: QSeriesTranslation
|
||||||
|
): List<Tuple> {
|
||||||
|
return queryFactory
|
||||||
|
.select(series, seriesTranslation)
|
||||||
|
.from(series)
|
||||||
|
.leftJoin(seriesTranslation)
|
||||||
|
.on(
|
||||||
|
seriesTranslation.seriesId.eq(series.id),
|
||||||
|
seriesTranslation.locale.eq(locale)
|
||||||
|
)
|
||||||
|
.where(series.id.`in`(seriesIds))
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun contentStatsBySeriesIds(
|
||||||
|
seriesIds: List<Long>,
|
||||||
|
now: LocalDateTime,
|
||||||
|
canViewAdultContent: Boolean
|
||||||
|
): Map<Long, SeriesContentStats> {
|
||||||
|
val paidContentCount = CaseBuilder()
|
||||||
|
.`when`(audioContent.price.gt(0))
|
||||||
|
.then(audioContent.id)
|
||||||
|
.otherwise(null as Long?)
|
||||||
|
.countDistinct()
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
seriesContent.series.id,
|
||||||
|
audioContent.id.countDistinct(),
|
||||||
|
paidContentCount
|
||||||
|
)
|
||||||
|
.from(seriesContent)
|
||||||
|
.innerJoin(seriesContent.content, audioContent)
|
||||||
|
.where(
|
||||||
|
seriesContent.series.id.`in`(seriesIds),
|
||||||
|
publicAudioContentCondition(now, canViewAdultContent)
|
||||||
|
)
|
||||||
|
.groupBy(seriesContent.series.id)
|
||||||
|
.fetch()
|
||||||
|
.associate {
|
||||||
|
it.get(seriesContent.series.id)!! to SeriesContentStats(
|
||||||
|
contentCount = it.get(audioContent.id.countDistinct())?.toInt() ?: 0,
|
||||||
|
paidContentCount = it.get(paidContentCount)?.toInt() ?: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun purchaseStatsBySeriesIds(
|
||||||
|
seriesIds: List<Long>,
|
||||||
|
viewerId: Long,
|
||||||
|
now: LocalDateTime,
|
||||||
|
canViewAdultContent: Boolean
|
||||||
|
): Map<Long, Int> {
|
||||||
|
val purchasedOrder = QOrder("seriesPurchasedOrder")
|
||||||
|
return queryFactory
|
||||||
|
.select(seriesContent.series.id, audioContent.id.countDistinct())
|
||||||
|
.from(seriesContent)
|
||||||
|
.innerJoin(seriesContent.content, audioContent)
|
||||||
|
.innerJoin(purchasedOrder)
|
||||||
|
.on(purchasedOrder.audioContent.id.eq(audioContent.id))
|
||||||
|
.where(
|
||||||
|
seriesContent.series.id.`in`(seriesIds),
|
||||||
|
publicAudioContentCondition(now, canViewAdultContent),
|
||||||
|
audioContent.price.gt(0),
|
||||||
|
purchasedOrder.member.id.eq(viewerId),
|
||||||
|
purchasedOrder.isActive.isTrue,
|
||||||
|
validPurchasedOrderCondition(purchasedOrder, now)
|
||||||
|
)
|
||||||
|
.groupBy(seriesContent.series.id)
|
||||||
|
.fetch()
|
||||||
|
.associate { it.get(seriesContent.series.id)!! to (it.get(audioContent.id.countDistinct())?.toInt() ?: 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun seriesCondition(creatorId: Long, canViewAdultContent: Boolean): BooleanExpression {
|
||||||
|
return series.member.id.eq(creatorId)
|
||||||
|
.and(series.isActive.isTrue)
|
||||||
|
.and(adultSeriesCondition(canViewAdultContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun adultSeriesCondition(canViewAdultContent: Boolean): BooleanExpression? {
|
||||||
|
return if (canViewAdultContent) null else series.isAdult.isFalse
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publicAudioContentCondition(now: LocalDateTime, canViewAdultContent: Boolean): BooleanExpression {
|
||||||
|
return audioContent.isActive.isTrue
|
||||||
|
.and(audioContent.duration.isNotNull)
|
||||||
|
.and(audioContent.releaseDate.isNotNull)
|
||||||
|
.and(audioContent.releaseDate.loe(now))
|
||||||
|
.and(adultAudioCondition(canViewAdultContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
|
||||||
|
return if (canViewAdultContent) null else audioContent.isAdult.isFalse
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validPurchasedOrderCondition(targetOrder: QOrder, now: LocalDateTime): BooleanExpression {
|
||||||
|
return targetOrder.type.eq(OrderType.KEEP)
|
||||||
|
.or(targetOrder.type.eq(OrderType.RENTAL).and(targetOrder.endDate.after(now)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class SeriesContentStats(
|
||||||
|
val contentCount: Int = 0,
|
||||||
|
val paidContentCount: Int = 0
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.series.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
|
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeries
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicy
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesTab
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesCreatorRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesQueryPort
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesRecord
|
||||||
|
import org.springframework.beans.factory.ObjectProvider
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
class CreatorChannelSeriesQueryService(
|
||||||
|
private val queryPortProvider: ObjectProvider<CreatorChannelSeriesQueryPort>,
|
||||||
|
private val queryPolicy: CreatorChannelSeriesQueryPolicy,
|
||||||
|
private val memberContentPreferenceService: MemberContentPreferenceService,
|
||||||
|
private val messageSource: SodaMessageSource,
|
||||||
|
private val langContext: LangContext,
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val cloudFrontHost: String
|
||||||
|
) {
|
||||||
|
fun getSeriesTab(
|
||||||
|
creatorId: Long,
|
||||||
|
viewer: Member,
|
||||||
|
sort: String?,
|
||||||
|
page: Int?,
|
||||||
|
size: Int?,
|
||||||
|
now: LocalDateTime = LocalDateTime.now()
|
||||||
|
): CreatorChannelSeriesTab {
|
||||||
|
val resolvedSort = queryPolicy.resolveSort(sort)
|
||||||
|
val seriesPage = queryPolicy.createPage(page, size)
|
||||||
|
val queryPort = queryPortProvider.getObject()
|
||||||
|
val viewerId = viewer.id!!
|
||||||
|
val creator = queryPort.findCreator(creatorId, viewerId)
|
||||||
|
?: throw SodaException(messageKey = "member.validation.user_not_found")
|
||||||
|
|
||||||
|
if (queryPort.existsBlockedBetween(viewerId, creatorId)) {
|
||||||
|
val messageTemplate = messageSource
|
||||||
|
.getMessage("explorer.creator.blocked_access", langContext.lang)
|
||||||
|
.orEmpty()
|
||||||
|
throw SodaException(message = String.format(messageTemplate, creator.nickname))
|
||||||
|
}
|
||||||
|
|
||||||
|
validateCreatorRole(creator)
|
||||||
|
|
||||||
|
val preference = memberContentPreferenceService.getStoredPreference(viewer)
|
||||||
|
val canViewAdultContent = isAdultVisibleByPolicy(viewer, preference.isAdultContentVisible)
|
||||||
|
val locale = langContext.lang.code
|
||||||
|
val fetchedSeries = queryPort.findSeries(
|
||||||
|
creatorId = creatorId,
|
||||||
|
viewerId = viewerId,
|
||||||
|
now = now,
|
||||||
|
canViewAdultContent = canViewAdultContent,
|
||||||
|
sort = resolvedSort,
|
||||||
|
locale = locale,
|
||||||
|
offset = seriesPage.offset,
|
||||||
|
limit = seriesPage.fetchLimit
|
||||||
|
)
|
||||||
|
|
||||||
|
return CreatorChannelSeriesTab(
|
||||||
|
seriesCount = queryPort.countSeries(
|
||||||
|
creatorId = creatorId,
|
||||||
|
now = now,
|
||||||
|
canViewAdultContent = canViewAdultContent
|
||||||
|
),
|
||||||
|
series = queryPolicy.limitItems(fetchedSeries, seriesPage).map { it.toDomain(creatorId, viewerId, locale) },
|
||||||
|
sort = resolvedSort,
|
||||||
|
page = seriesPage,
|
||||||
|
hasNext = queryPolicy.hasNext(fetchedSeries, seriesPage)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateCreatorRole(creator: CreatorChannelSeriesCreatorRecord) {
|
||||||
|
when (creator.role) {
|
||||||
|
MemberRole.CREATOR -> return
|
||||||
|
else -> throw SodaException(messageKey = "member.validation.creator_not_found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CreatorChannelSeriesRecord.toDomain(creatorId: Long, viewerId: Long, locale: String): CreatorChannelSeries {
|
||||||
|
val isCreatorSelf = viewerId == creatorId
|
||||||
|
val domainPurchasedContentCount = if (isCreatorSelf) null else purchasedContentCount
|
||||||
|
val domainPaidContentCount = if (isCreatorSelf) null else paidContentCount
|
||||||
|
return CreatorChannelSeries(
|
||||||
|
seriesId = seriesId,
|
||||||
|
title = title,
|
||||||
|
coverImageUrl = coverImagePath.toCdnUrl(cloudFrontHost),
|
||||||
|
publishedDaysOfWeek = queryPolicy.publishedDaysOfWeekText(publishedDaysOfWeek, locale),
|
||||||
|
isOriginal = isOriginal,
|
||||||
|
isAdult = isAdult,
|
||||||
|
isProceeding = state == SeriesState.PROCEEDING,
|
||||||
|
contentCount = contentCount,
|
||||||
|
purchasedContentCount = domainPurchasedContentCount,
|
||||||
|
paidContentCount = domainPaidContentCount,
|
||||||
|
purchasedPaidContentRate = if (isCreatorSelf) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
queryPolicy.purchaseRate(domainPaidContentCount ?: 0, domainPurchasedContentCount ?: 0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.series.domain
|
||||||
|
|
||||||
|
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.creator.channel.live.domain.CreatorChannelPage
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class CreatorChannelSeriesQueryPolicy {
|
||||||
|
fun resolveSort(sort: String?): ContentSort {
|
||||||
|
return runCatching { ContentSort.valueOf(sort ?: ContentSort.LATEST.name) }
|
||||||
|
.getOrDefault(ContentSort.LATEST)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createPage(page: Int?, size: Int?): CreatorChannelPage {
|
||||||
|
return CreatorChannelPage(
|
||||||
|
page = page?.coerceAtLeast(MIN_PAGE) ?: DEFAULT_PAGE,
|
||||||
|
size = size?.coerceIn(MIN_PAGE_SIZE, MAX_PAGE_SIZE) ?: DEFAULT_PAGE_SIZE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> limitItems(fetched: List<T>, page: CreatorChannelPage): List<T> {
|
||||||
|
return fetched.take(page.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasNext(fetched: List<*>, page: CreatorChannelPage): Boolean {
|
||||||
|
return fetched.size > page.size
|
||||||
|
}
|
||||||
|
|
||||||
|
fun purchaseRate(paidContentCount: Int, purchasedContentCount: Int): Int {
|
||||||
|
if (paidContentCount == 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return purchasedContentCount * 100 / paidContentCount
|
||||||
|
}
|
||||||
|
|
||||||
|
fun publishedDaysOfWeekText(days: Set<SeriesPublishedDaysOfWeek>, locale: String): String {
|
||||||
|
if (days.isEmpty()) {
|
||||||
|
return randomText(locale)
|
||||||
|
}
|
||||||
|
if (days.contains(SeriesPublishedDaysOfWeek.RANDOM)) {
|
||||||
|
return randomText(locale)
|
||||||
|
}
|
||||||
|
if (days.containsAll(WEEKDAYS)) {
|
||||||
|
return everyDayText(locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
val dayText = WEEKDAYS
|
||||||
|
.filter(days::contains)
|
||||||
|
.joinToString(", ") { dayText(it, locale) }
|
||||||
|
|
||||||
|
return weeklyText(dayText, locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun randomText(locale: String): String {
|
||||||
|
return when (locale) {
|
||||||
|
"en" -> "Random"
|
||||||
|
"ja" -> "ランダム"
|
||||||
|
else -> "랜덤"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun everyDayText(locale: String): String {
|
||||||
|
return when (locale) {
|
||||||
|
"en" -> "Every day"
|
||||||
|
"ja" -> "毎日"
|
||||||
|
else -> "매일"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun weeklyText(dayText: String, locale: String): String {
|
||||||
|
return when (locale) {
|
||||||
|
"en" -> "Every $dayText"
|
||||||
|
"ja" -> "毎週 $dayText"
|
||||||
|
else -> "매주 $dayText"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dayText(day: SeriesPublishedDaysOfWeek, locale: String): String {
|
||||||
|
return when (locale) {
|
||||||
|
"en" -> EN_DAY_TEXTS.getValue(day)
|
||||||
|
"ja" -> JA_DAY_TEXTS.getValue(day)
|
||||||
|
else -> KO_DAY_TEXTS.getValue(day)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val DEFAULT_PAGE = 0
|
||||||
|
private const val DEFAULT_PAGE_SIZE = 20
|
||||||
|
private const val MIN_PAGE = 0
|
||||||
|
private const val MIN_PAGE_SIZE = 20
|
||||||
|
private const val MAX_PAGE_SIZE = 50
|
||||||
|
|
||||||
|
private val WEEKDAYS = listOf(
|
||||||
|
SeriesPublishedDaysOfWeek.SUN,
|
||||||
|
SeriesPublishedDaysOfWeek.MON,
|
||||||
|
SeriesPublishedDaysOfWeek.TUE,
|
||||||
|
SeriesPublishedDaysOfWeek.WED,
|
||||||
|
SeriesPublishedDaysOfWeek.THU,
|
||||||
|
SeriesPublishedDaysOfWeek.FRI,
|
||||||
|
SeriesPublishedDaysOfWeek.SAT
|
||||||
|
)
|
||||||
|
private val KO_DAY_TEXTS = mapOf(
|
||||||
|
SeriesPublishedDaysOfWeek.SUN to "일",
|
||||||
|
SeriesPublishedDaysOfWeek.MON to "월",
|
||||||
|
SeriesPublishedDaysOfWeek.TUE to "화",
|
||||||
|
SeriesPublishedDaysOfWeek.WED to "수",
|
||||||
|
SeriesPublishedDaysOfWeek.THU to "목",
|
||||||
|
SeriesPublishedDaysOfWeek.FRI to "금",
|
||||||
|
SeriesPublishedDaysOfWeek.SAT to "토"
|
||||||
|
)
|
||||||
|
private val EN_DAY_TEXTS = mapOf(
|
||||||
|
SeriesPublishedDaysOfWeek.SUN to "Sun",
|
||||||
|
SeriesPublishedDaysOfWeek.MON to "Mon",
|
||||||
|
SeriesPublishedDaysOfWeek.TUE to "Tue",
|
||||||
|
SeriesPublishedDaysOfWeek.WED to "Wed",
|
||||||
|
SeriesPublishedDaysOfWeek.THU to "Thu",
|
||||||
|
SeriesPublishedDaysOfWeek.FRI to "Fri",
|
||||||
|
SeriesPublishedDaysOfWeek.SAT to "Sat"
|
||||||
|
)
|
||||||
|
private val JA_DAY_TEXTS = mapOf(
|
||||||
|
SeriesPublishedDaysOfWeek.SUN to "日",
|
||||||
|
SeriesPublishedDaysOfWeek.MON to "月",
|
||||||
|
SeriesPublishedDaysOfWeek.TUE to "火",
|
||||||
|
SeriesPublishedDaysOfWeek.WED to "水",
|
||||||
|
SeriesPublishedDaysOfWeek.THU to "木",
|
||||||
|
SeriesPublishedDaysOfWeek.FRI to "金",
|
||||||
|
SeriesPublishedDaysOfWeek.SAT to "土"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.series.domain
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
|
||||||
|
|
||||||
|
data class CreatorChannelSeriesTab(
|
||||||
|
val seriesCount: Int,
|
||||||
|
val series: List<CreatorChannelSeries>,
|
||||||
|
val sort: ContentSort,
|
||||||
|
val page: CreatorChannelPage,
|
||||||
|
val hasNext: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelSeries(
|
||||||
|
val seriesId: Long,
|
||||||
|
val title: String,
|
||||||
|
val coverImageUrl: String?,
|
||||||
|
val publishedDaysOfWeek: String,
|
||||||
|
val isOriginal: Boolean,
|
||||||
|
val isAdult: Boolean,
|
||||||
|
val isProceeding: Boolean,
|
||||||
|
val contentCount: Int,
|
||||||
|
val purchasedContentCount: Int?,
|
||||||
|
val paidContentCount: Int?,
|
||||||
|
val purchasedPaidContentRate: Int?
|
||||||
|
)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.series.port.out
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
interface CreatorChannelSeriesQueryPort {
|
||||||
|
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelSeriesCreatorRecord?
|
||||||
|
|
||||||
|
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
|
||||||
|
|
||||||
|
fun countSeries(creatorId: Long, now: LocalDateTime, canViewAdultContent: Boolean): Int
|
||||||
|
|
||||||
|
fun findSeries(
|
||||||
|
creatorId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
now: LocalDateTime,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
sort: ContentSort,
|
||||||
|
locale: String,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelSeriesRecord>
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelSeriesCreatorRecord(
|
||||||
|
val creatorId: Long,
|
||||||
|
val role: MemberRole,
|
||||||
|
val nickname: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelSeriesRecord(
|
||||||
|
val seriesId: Long,
|
||||||
|
val title: String,
|
||||||
|
val coverImagePath: String?,
|
||||||
|
val publishedDaysOfWeek: Set<SeriesPublishedDaysOfWeek>,
|
||||||
|
val isOriginal: Boolean,
|
||||||
|
val isAdult: Boolean,
|
||||||
|
val state: SeriesState,
|
||||||
|
val contentCount: Int,
|
||||||
|
val purchasedContentCount: Int?,
|
||||||
|
val paidContentCount: Int?
|
||||||
|
)
|
||||||
@@ -5,13 +5,16 @@ import org.springframework.context.ConfigurableApplicationContext
|
|||||||
import redis.embedded.RedisServer
|
import redis.embedded.RedisServer
|
||||||
|
|
||||||
class EmbeddedRedisInitializer : ApplicationContextInitializer<ConfigurableApplicationContext> {
|
class EmbeddedRedisInitializer : ApplicationContextInitializer<ConfigurableApplicationContext> {
|
||||||
|
companion object {
|
||||||
|
const val PORT = 16379
|
||||||
|
}
|
||||||
|
|
||||||
override fun initialize(applicationContext: ConfigurableApplicationContext) {
|
override fun initialize(applicationContext: ConfigurableApplicationContext) {
|
||||||
EmbeddedRedisHolder.start()
|
EmbeddedRedisHolder.start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private object EmbeddedRedisHolder {
|
private object EmbeddedRedisHolder {
|
||||||
private const val PORT = 16379
|
|
||||||
private var redisServer: RedisServer? = null
|
private var redisServer: RedisServer? = null
|
||||||
private var shutdownHookRegistered = false
|
private var shutdownHookRegistered = false
|
||||||
|
|
||||||
@@ -22,7 +25,7 @@ private object EmbeddedRedisHolder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
redisServer = RedisServer.newRedisServer()
|
redisServer = RedisServer.newRedisServer()
|
||||||
.port(PORT)
|
.port(EmbeddedRedisInitializer.PORT)
|
||||||
.setting("bind 127.0.0.1")
|
.setting("bind 127.0.0.1")
|
||||||
.setting("daemonize no")
|
.setting("daemonize no")
|
||||||
.setting("appendonly no")
|
.setting("appendonly no")
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package kr.co.vividnext.sodalive.support
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
|
||||||
|
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||||
|
import org.springframework.boot.test.context.TestConfiguration
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.data.redis.connection.RedisConnectionFactory
|
||||||
|
import org.springframework.data.redis.connection.RedisStandaloneConfiguration
|
||||||
|
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate
|
||||||
|
import org.springframework.data.redis.listener.RedisMessageListenerContainer
|
||||||
|
|
||||||
|
@TestConfiguration
|
||||||
|
class EmbeddedRedisTestConfiguration {
|
||||||
|
@Bean
|
||||||
|
fun redisConnectionFactory(): RedisConnectionFactory {
|
||||||
|
return LettuceConnectionFactory(RedisStandaloneConfiguration("127.0.0.1", EmbeddedRedisInitializer.PORT))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun stringRedisTemplate(redisConnectionFactory: RedisConnectionFactory): StringRedisTemplate {
|
||||||
|
return StringRedisTemplate(redisConnectionFactory)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun redisMessageListenerContainer(redisConnectionFactory: RedisConnectionFactory): RedisMessageListenerContainer {
|
||||||
|
val container = RedisMessageListenerContainer()
|
||||||
|
container.setConnectionFactory(redisConnectionFactory)
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun objectMapper(): ObjectMapper {
|
||||||
|
return ObjectMapper()
|
||||||
|
.registerModule(KotlinModule.Builder().build())
|
||||||
|
.registerModule(JavaTimeModule())
|
||||||
|
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.`in`.web
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.CountryContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacade
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.series.dto.CreatorChannelSeriesResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.series.dto.CreatorChannelSeriesTabResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||||
|
import org.springframework.boot.test.context.TestConfiguration
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||||
|
import org.springframework.security.web.SecurityFilterChain
|
||||||
|
import org.springframework.security.web.authentication.HttpStatusEntryPoint
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import javax.servlet.http.HttpServletResponse
|
||||||
|
|
||||||
|
@WebMvcTest(CreatorChannelSeriesController::class)
|
||||||
|
@Import(CreatorChannelSeriesControllerTest.TestSecurityConfig::class)
|
||||||
|
class CreatorChannelSeriesControllerTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc
|
||||||
|
) {
|
||||||
|
@MockBean
|
||||||
|
private lateinit var facade: CreatorChannelSeriesFacade
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var countryContext: CountryContext
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var langContext: LangContext
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var sodaMessageSource: SodaMessageSource
|
||||||
|
|
||||||
|
@TestConfiguration
|
||||||
|
class TestSecurityConfig {
|
||||||
|
@Bean
|
||||||
|
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||||
|
return http
|
||||||
|
.csrf().disable()
|
||||||
|
.authorizeRequests()
|
||||||
|
.anyRequest().authenticated()
|
||||||
|
.and()
|
||||||
|
.exceptionHandling()
|
||||||
|
.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
|
||||||
|
.accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) }
|
||||||
|
.and()
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("크리에이터 채널 시리즈 탭 조회는 비회원 요청을 거부한다")
|
||||||
|
fun shouldRejectAnonymousCreatorChannelSeriesRequest() {
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/1/series")
|
||||||
|
.with(anonymous())
|
||||||
|
)
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("크리에이터 채널 시리즈 탭 조회는 query parameter를 facade에 전달하고 성공 응답을 반환한다")
|
||||||
|
fun shouldReturnCreatorChannelSeriesTabForAuthenticatedMember() {
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
Mockito.doReturn(createResponse(sort = ContentSort.POPULAR, page = 1, size = 20)).`when`(facade).getSeriesTab(
|
||||||
|
eqValue(1L),
|
||||||
|
eqValue(viewer),
|
||||||
|
eqValue("POPULAR"),
|
||||||
|
eqValue(1),
|
||||||
|
eqValue(20),
|
||||||
|
anyValue(LocalDateTime.now())
|
||||||
|
)
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/1/series")
|
||||||
|
.param("sort", "POPULAR")
|
||||||
|
.param("page", "1")
|
||||||
|
.param("size", "20")
|
||||||
|
.with(user(MemberAdapter(viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.seriesCount").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.series").isArray)
|
||||||
|
.andExpect(jsonPath("$.data.series[0].seriesId").value(101))
|
||||||
|
.andExpect(jsonPath("$.data.series[0].coverImageUrl").value("https://cdn.test/cover.png"))
|
||||||
|
.andExpect(jsonPath("$.data.series[0].publishedDaysOfWeek").value("Every Mon, Thu"))
|
||||||
|
.andExpect(jsonPath("$.data.series[0].purchasedPaidContentRate").value(75))
|
||||||
|
.andExpect(jsonPath("$.data.series[0].isOriginal").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.series[0].isAdult").value(false))
|
||||||
|
.andExpect(jsonPath("$.data.series[0].isProceeding").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.sort").value("POPULAR"))
|
||||||
|
.andExpect(jsonPath("$.data.page").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.size").value(20))
|
||||||
|
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||||
|
|
||||||
|
Mockito.verify(facade).getSeriesTab(
|
||||||
|
eqValue(1L),
|
||||||
|
eqValue(viewer),
|
||||||
|
eqValue("POPULAR"),
|
||||||
|
eqValue(1),
|
||||||
|
eqValue(20),
|
||||||
|
anyValue(LocalDateTime.now())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("크리에이터 채널 시리즈 탭 조회는 잘못된 sort도 controller에서 거부하지 않고 facade에 전달한다")
|
||||||
|
fun shouldPassInvalidSortToFacade() {
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
Mockito.doReturn(createResponse(sort = ContentSort.LATEST, page = 0, size = 50)).`when`(facade).getSeriesTab(
|
||||||
|
eqValue(1L),
|
||||||
|
eqValue(viewer),
|
||||||
|
eqValue("INVALID"),
|
||||||
|
eqValue(-1),
|
||||||
|
eqValue(100),
|
||||||
|
anyValue(LocalDateTime.now())
|
||||||
|
)
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/1/series")
|
||||||
|
.param("sort", "INVALID")
|
||||||
|
.param("page", "-1")
|
||||||
|
.param("size", "100")
|
||||||
|
.with(user(MemberAdapter(viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.sort").value("LATEST"))
|
||||||
|
.andExpect(jsonPath("$.data.page").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.size").value(50))
|
||||||
|
|
||||||
|
Mockito.verify(facade).getSeriesTab(
|
||||||
|
eqValue(1L),
|
||||||
|
eqValue(viewer),
|
||||||
|
eqValue("INVALID"),
|
||||||
|
eqValue(-1),
|
||||||
|
eqValue(100),
|
||||||
|
anyValue(LocalDateTime.now())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> eqValue(value: T): T {
|
||||||
|
return Mockito.eq(value) ?: value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> anyValue(fallback: T): T {
|
||||||
|
return Mockito.any<T>() ?: fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMember(id: Long): Member {
|
||||||
|
return Member(
|
||||||
|
email = "viewer$id@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "viewer$id",
|
||||||
|
role = MemberRole.USER
|
||||||
|
).apply { this.id = id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createResponse(
|
||||||
|
sort: ContentSort = ContentSort.LATEST,
|
||||||
|
page: Int = 0,
|
||||||
|
size: Int = 20
|
||||||
|
): CreatorChannelSeriesTabResponse {
|
||||||
|
return CreatorChannelSeriesTabResponse(
|
||||||
|
seriesCount = 2,
|
||||||
|
series = listOf(
|
||||||
|
CreatorChannelSeriesResponse(
|
||||||
|
seriesId = 101L,
|
||||||
|
title = "series",
|
||||||
|
coverImageUrl = "https://cdn.test/cover.png",
|
||||||
|
publishedDaysOfWeek = "Every Mon, Thu",
|
||||||
|
isOriginal = true,
|
||||||
|
isAdult = false,
|
||||||
|
isProceeding = true,
|
||||||
|
contentCount = 5,
|
||||||
|
purchasedContentCount = 3,
|
||||||
|
paidContentCount = 4,
|
||||||
|
purchasedPaidContentRate = 75
|
||||||
|
)
|
||||||
|
),
|
||||||
|
sort = sort,
|
||||||
|
page = page,
|
||||||
|
size = size,
|
||||||
|
hasNext = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.series.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.SeriesContent
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import org.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.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@SpringBootTest(
|
||||||
|
properties = [
|
||||||
|
"cloud.aws.cloud-front.host=https://cdn.test",
|
||||||
|
"spring.cache.type=none",
|
||||||
|
"spring.datasource.url=jdbc:h2:mem:creator-channel-live-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
class CreatorChannelSeriesEndToEndTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc,
|
||||||
|
private val entityManager: EntityManager,
|
||||||
|
private val transactionTemplate: TransactionTemplate
|
||||||
|
) {
|
||||||
|
@Test
|
||||||
|
@DisplayName("시리즈 탭 API는 controller-service-repository를 거쳐 전체 응답 필드를 반환한다")
|
||||||
|
fun shouldReturnSeriesTabThroughControllerServiceAndRepository() {
|
||||||
|
val fixture = createFixture("series-e2e-success")
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/${fixture.creatorId}/series")
|
||||||
|
.param("sort", "LATEST")
|
||||||
|
.param("page", "0")
|
||||||
|
.param("size", "20")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.seriesCount").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.series[0].seriesId").value(fixture.seriesId))
|
||||||
|
.andExpect(jsonPath("$.data.series[0].title").value("series-e2e-success-series"))
|
||||||
|
.andExpect(jsonPath("$.data.series[0].coverImageUrl").value("https://cdn.test/series-e2e-success-cover.png"))
|
||||||
|
.andExpect(jsonPath("$.data.series[0].publishedDaysOfWeek").value("매주 월, 목"))
|
||||||
|
.andExpect(jsonPath("$.data.series[0].isOriginal").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.series[0].isAdult").value(false))
|
||||||
|
.andExpect(jsonPath("$.data.series[0].isProceeding").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.series[0].contentCount").value(2))
|
||||||
|
.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("시리즈 탭 API는 잘못된 sort와 page size를 fallback하고 비크리에이터 구매 통계를 반환한다")
|
||||||
|
fun shouldFallbackRequestAndReturnPurchaseStatsForNonCreator() {
|
||||||
|
val fixture = createFixture("series-e2e-fallback")
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/${fixture.creatorId}/series")
|
||||||
|
.param("sort", "INVALID")
|
||||||
|
.param("page", "-1")
|
||||||
|
.param("size", "10")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.series[0].purchasedContentCount").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.series[0].paidContentCount").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.series[0].purchasedPaidContentRate").value(50))
|
||||||
|
.andExpect(jsonPath("$.data.sort").value("LATEST"))
|
||||||
|
.andExpect(jsonPath("$.data.page").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.size").value(20))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("시리즈 탭 API는 creator 본인 조회 시 구매 통계 필드를 null로 반환한다")
|
||||||
|
fun shouldHidePurchaseStatsForCreatorSelf() {
|
||||||
|
val fixture = createFixture("series-e2e-self")
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/${fixture.creatorId}/series")
|
||||||
|
.with(user(MemberAdapter(fixture.creator)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.series[0].purchasedContentCount").value(nullValue()))
|
||||||
|
.andExpect(jsonPath("$.data.series[0].paidContentCount").value(nullValue()))
|
||||||
|
.andExpect(jsonPath("$.data.series[0].purchasedPaidContentRate").value(nullValue()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createFixture(prefix: String): Fixture {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val now = LocalDateTime.now()
|
||||||
|
val viewer = saveMember("$prefix-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("$prefix-creator", MemberRole.CREATOR)
|
||||||
|
val theme = saveTheme("$prefix-theme")
|
||||||
|
val series = saveSeries("$prefix-series", creator, "$prefix-cover.png")
|
||||||
|
val purchasedPaid = saveAudioContent(creator, theme, now.minusHours(2), price = 300)
|
||||||
|
val unpurchasedPaid = saveAudioContent(creator, theme, now.minusHours(1), price = 200)
|
||||||
|
saveSeriesContent(series, purchasedPaid)
|
||||||
|
saveSeriesContent(series, unpurchasedPaid)
|
||||||
|
saveOrder(viewer, creator, purchasedPaid, OrderType.KEEP)
|
||||||
|
entityManager.flush()
|
||||||
|
|
||||||
|
Fixture(
|
||||||
|
viewer = viewer,
|
||||||
|
creator = creator,
|
||||||
|
creatorId = creator.id!!,
|
||||||
|
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 saveSeries(title: String, creator: Member, coverImage: String): Series {
|
||||||
|
val series = Series(
|
||||||
|
title = title,
|
||||||
|
introduction = "introduction",
|
||||||
|
languageCode = "ko",
|
||||||
|
state = SeriesState.PROCEEDING,
|
||||||
|
isAdult = false,
|
||||||
|
isOriginal = true
|
||||||
|
)
|
||||||
|
series.member = creator
|
||||||
|
series.genre = saveSeriesGenre(title)
|
||||||
|
series.coverImage = coverImage
|
||||||
|
series.publishedDaysOfWeek.addAll(setOf(SeriesPublishedDaysOfWeek.MON, SeriesPublishedDaysOfWeek.THU))
|
||||||
|
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 saveAudioContent(
|
||||||
|
creator: Member,
|
||||||
|
theme: AudioContentTheme,
|
||||||
|
releaseDate: LocalDateTime,
|
||||||
|
price: Int
|
||||||
|
): AudioContent {
|
||||||
|
val content = AudioContent(
|
||||||
|
title = "audio-$releaseDate",
|
||||||
|
detail = "detail",
|
||||||
|
languageCode = "ko",
|
||||||
|
releaseDate = releaseDate,
|
||||||
|
price = price,
|
||||||
|
isAdult = false,
|
||||||
|
isPointAvailable = true
|
||||||
|
)
|
||||||
|
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 saveSeriesContent(series: Series, content: AudioContent): SeriesContent {
|
||||||
|
val seriesContent = SeriesContent()
|
||||||
|
seriesContent.series = series
|
||||||
|
seriesContent.content = content
|
||||||
|
entityManager.persist(seriesContent)
|
||||||
|
return seriesContent
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveOrder(member: Member, creator: Member, content: AudioContent, type: OrderType): Order {
|
||||||
|
val order = Order(type = type, isActive = true)
|
||||||
|
order.member = member
|
||||||
|
order.creator = creator
|
||||||
|
order.audioContent = content
|
||||||
|
entityManager.persist(order)
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Fixture(
|
||||||
|
val viewer: Member,
|
||||||
|
val creator: Member,
|
||||||
|
val creatorId: Long,
|
||||||
|
val seriesId: Long
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.series.application
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.series.dto.CreatorChannelSeriesTabResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryService
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeries
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesTab
|
||||||
|
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
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
class CreatorChannelSeriesFacadeTest {
|
||||||
|
@Test
|
||||||
|
@DisplayName("시리즈 탭 응답 DTO는 domain tab 값을 공개 응답 필드로 그대로 매핑한다")
|
||||||
|
fun shouldMapSeriesTabDomainToPublicResponse() {
|
||||||
|
val response = CreatorChannelSeriesTabResponse.from(createTab())
|
||||||
|
|
||||||
|
assertEquals(2, response.seriesCount)
|
||||||
|
assertEquals(101L, response.series.first().seriesId)
|
||||||
|
assertEquals("series", response.series.first().title)
|
||||||
|
assertEquals("https://cdn.test/cover.png", response.series.first().coverImageUrl)
|
||||||
|
assertEquals("Every Mon, Thu", response.series.first().publishedDaysOfWeek)
|
||||||
|
assertTrue(response.series.first().isOriginal)
|
||||||
|
assertFalse(response.series.first().isAdult)
|
||||||
|
assertTrue(response.series.first().isProceeding)
|
||||||
|
assertEquals(5, response.series.first().contentCount)
|
||||||
|
assertEquals(3, response.series.first().purchasedContentCount)
|
||||||
|
assertEquals(4, response.series.first().paidContentCount)
|
||||||
|
assertEquals(75, response.series.first().purchasedPaidContentRate)
|
||||||
|
assertEquals(ContentSort.OWNED, response.sort)
|
||||||
|
assertEquals(1, response.page)
|
||||||
|
assertEquals(20, response.size)
|
||||||
|
assertTrue(response.hasNext)
|
||||||
|
|
||||||
|
val mapper = ObjectMapper().registerModule(KotlinModule.Builder().build())
|
||||||
|
val json = mapper.readTree(mapper.writeValueAsString(response))
|
||||||
|
assertTrue(json["hasNext"].asBoolean())
|
||||||
|
assertTrue(json["series"][0]["isOriginal"].asBoolean())
|
||||||
|
assertFalse(json["series"][0]["isAdult"].asBoolean())
|
||||||
|
assertTrue(json["series"][0]["isProceeding"].asBoolean())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("시리즈 탭 facade는 query service 결과를 공개 응답 DTO로 변환한다")
|
||||||
|
fun shouldMapSeriesTabQueryResultToPublicResponse() {
|
||||||
|
val service = Mockito.mock(CreatorChannelSeriesQueryService::class.java)
|
||||||
|
val facade = CreatorChannelSeriesFacade(service)
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
val now = LocalDateTime.of(2026, 6, 20, 10, 0)
|
||||||
|
Mockito.doReturn(createTab()).`when`(service).getSeriesTab(
|
||||||
|
creatorId = 1L,
|
||||||
|
viewer = viewer,
|
||||||
|
sort = "OWNED",
|
||||||
|
page = 1,
|
||||||
|
size = 20,
|
||||||
|
now = now
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = facade.getSeriesTab(
|
||||||
|
creatorId = 1L,
|
||||||
|
viewer = viewer,
|
||||||
|
sort = "OWNED",
|
||||||
|
page = 1,
|
||||||
|
size = 20,
|
||||||
|
now = now
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(2, response.seriesCount)
|
||||||
|
assertEquals(101L, response.series.first().seriesId)
|
||||||
|
assertEquals(75, response.series.first().purchasedPaidContentRate)
|
||||||
|
assertEquals(ContentSort.OWNED, response.sort)
|
||||||
|
assertEquals(1, response.page)
|
||||||
|
assertEquals(20, response.size)
|
||||||
|
assertTrue(response.hasNext)
|
||||||
|
assertNull(response.series.last().purchasedPaidContentRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMember(id: Long): Member {
|
||||||
|
return Member(
|
||||||
|
email = "viewer$id@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "viewer$id",
|
||||||
|
role = MemberRole.USER
|
||||||
|
).apply { this.id = id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createTab(): CreatorChannelSeriesTab {
|
||||||
|
return CreatorChannelSeriesTab(
|
||||||
|
seriesCount = 2,
|
||||||
|
series = listOf(
|
||||||
|
CreatorChannelSeries(
|
||||||
|
seriesId = 101L,
|
||||||
|
title = "series",
|
||||||
|
coverImageUrl = "https://cdn.test/cover.png",
|
||||||
|
publishedDaysOfWeek = "Every Mon, Thu",
|
||||||
|
isOriginal = true,
|
||||||
|
isAdult = false,
|
||||||
|
isProceeding = true,
|
||||||
|
contentCount = 5,
|
||||||
|
purchasedContentCount = 3,
|
||||||
|
paidContentCount = 4,
|
||||||
|
purchasedPaidContentRate = 75
|
||||||
|
),
|
||||||
|
CreatorChannelSeries(
|
||||||
|
seriesId = 102L,
|
||||||
|
title = "creator series",
|
||||||
|
coverImageUrl = null,
|
||||||
|
publishedDaysOfWeek = "Random",
|
||||||
|
isOriginal = false,
|
||||||
|
isAdult = false,
|
||||||
|
isProceeding = false,
|
||||||
|
contentCount = 1,
|
||||||
|
purchasedContentCount = null,
|
||||||
|
paidContentCount = null,
|
||||||
|
purchasedPaidContentRate = null
|
||||||
|
)
|
||||||
|
),
|
||||||
|
sort = ContentSort.OWNED,
|
||||||
|
page = CreatorChannelPage(page = 1, size = 20),
|
||||||
|
hasNext = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.series.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.creator.admin.content.series.SeriesState
|
||||||
|
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.assertNull
|
||||||
|
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 DefaultCreatorChannelSeriesQueryRepositoryTest @Autowired constructor(
|
||||||
|
private val entityManager: EntityManager,
|
||||||
|
queryFactory: JPAQueryFactory
|
||||||
|
) {
|
||||||
|
private val repository = DefaultCreatorChannelSeriesQueryRepository(queryFactory)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("활성 creator와 양방향 차단 관계를 조회한다")
|
||||||
|
fun shouldFindCreatorAndBlockedRelationship() {
|
||||||
|
val viewer = saveMember("series-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("series-creator", MemberRole.CREATOR)
|
||||||
|
val inactiveCreator = saveMember("inactive-series-creator", MemberRole.CREATOR, isActive = false)
|
||||||
|
saveBlock(creator, viewer)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val record = repository.findCreator(creator.id!!, viewer.id!!)
|
||||||
|
val inactiveRecord = repository.findCreator(inactiveCreator.id!!, viewer.id!!)
|
||||||
|
|
||||||
|
assertEquals(creator.id, record!!.creatorId)
|
||||||
|
assertEquals(MemberRole.CREATOR, record.role)
|
||||||
|
assertEquals("series-creator", record.nickname)
|
||||||
|
assertNull(inactiveRecord)
|
||||||
|
assertTrue(repository.existsBlockedBetween(viewer.id!!, creator.id!!))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("시리즈 count는 활성 시리즈, creator, 성인 노출 정책을 반영한다")
|
||||||
|
fun shouldCountSeriesWithCreatorAndAdultVisibilityFilters() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 20, 12, 0)
|
||||||
|
val creator = saveMember("count-series-creator", MemberRole.CREATOR)
|
||||||
|
val otherCreator = saveMember("count-series-other-creator", MemberRole.CREATOR)
|
||||||
|
saveSeries("public-series", creator, isAdult = false)
|
||||||
|
saveSeries("adult-series", creator, isAdult = true)
|
||||||
|
saveSeries("inactive-series", creator, isAdult = false).isActive = false
|
||||||
|
saveSeries("other-creator-series", otherCreator, isAdult = false)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
assertEquals(1, repository.countSeries(creator.id!!, now, canViewAdultContent = false))
|
||||||
|
assertEquals(2, repository.countSeries(creator.id!!, now, canViewAdultContent = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("목록은 시리즈 필드, 번역 fallback, 공개 콘텐츠 통계와 구매 통계를 반환한다")
|
||||||
|
fun shouldFindSeriesWithFieldsTranslationsAndStats() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 20, 12, 0)
|
||||||
|
val viewer = saveMember("field-series-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("field-series-creator", MemberRole.CREATOR)
|
||||||
|
val theme = saveTheme("field-theme")
|
||||||
|
val translated = saveSeries("translated-series", creator, isOriginal = true, state = SeriesState.PROCEEDING).apply {
|
||||||
|
publishedDaysOfWeek.addAll(setOf(SeriesPublishedDaysOfWeek.MON, SeriesPublishedDaysOfWeek.THU))
|
||||||
|
}
|
||||||
|
val blankTranslated = saveSeries("blank-fallback-series", creator, state = SeriesState.COMPLETE).apply {
|
||||||
|
publishedDaysOfWeek.add(SeriesPublishedDaysOfWeek.RANDOM)
|
||||||
|
}
|
||||||
|
val publicPaid = saveAudioContent(creator, theme, now.minusDays(3), isAdult = false, price = 300)
|
||||||
|
val publicFree = saveAudioContent(creator, theme, now.minusDays(2), isAdult = false, price = 0)
|
||||||
|
val future = saveAudioContent(creator, theme, now.plusDays(1), isAdult = false, price = 100)
|
||||||
|
val nullRelease = saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 100).apply {
|
||||||
|
releaseDate = null
|
||||||
|
}
|
||||||
|
val noDuration = saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 100).apply {
|
||||||
|
duration = null
|
||||||
|
}
|
||||||
|
val adultContent = saveAudioContent(creator, theme, now.minusDays(1), isAdult = true, price = 100)
|
||||||
|
saveSeriesContent(translated, publicPaid)
|
||||||
|
saveSeriesContent(translated, publicFree)
|
||||||
|
saveSeriesContent(translated, future)
|
||||||
|
saveSeriesContent(translated, nullRelease)
|
||||||
|
saveSeriesContent(translated, noDuration)
|
||||||
|
saveSeriesContent(translated, adultContent)
|
||||||
|
saveSeriesContent(blankTranslated, saveAudioContent(creator, theme, now.minusDays(4), isAdult = false, price = 100))
|
||||||
|
saveSeriesTranslation(translated, "en", "Translated Series")
|
||||||
|
saveSeriesTranslation(blankTranslated, "en", " ")
|
||||||
|
saveOrder(viewer, creator, publicPaid, OrderType.KEEP)
|
||||||
|
saveOrder(viewer, creator, publicPaid, OrderType.RENTAL, endDate = now.plusDays(1))
|
||||||
|
saveOrder(viewer, creator, future, OrderType.KEEP)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val records = repository.findSeries(
|
||||||
|
creator.id!!,
|
||||||
|
viewer.id!!,
|
||||||
|
now,
|
||||||
|
canViewAdultContent = false,
|
||||||
|
ContentSort.LATEST,
|
||||||
|
"en",
|
||||||
|
offset = 0,
|
||||||
|
limit = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
val translatedRecord = records.first { it.seriesId == translated.id }
|
||||||
|
val blankRecord = records.first { it.seriesId == blankTranslated.id }
|
||||||
|
assertEquals("Translated Series", translatedRecord.title)
|
||||||
|
assertEquals("translated-series.png", translatedRecord.coverImagePath)
|
||||||
|
assertEquals(setOf(SeriesPublishedDaysOfWeek.MON, SeriesPublishedDaysOfWeek.THU), translatedRecord.publishedDaysOfWeek)
|
||||||
|
assertEquals(true, translatedRecord.isOriginal)
|
||||||
|
assertEquals(false, translatedRecord.isAdult)
|
||||||
|
assertEquals(SeriesState.PROCEEDING, translatedRecord.state)
|
||||||
|
assertEquals(2, translatedRecord.contentCount)
|
||||||
|
assertEquals(1, translatedRecord.paidContentCount)
|
||||||
|
assertEquals(1, translatedRecord.purchasedContentCount)
|
||||||
|
assertEquals("blank-fallback-series", blankRecord.title)
|
||||||
|
assertEquals(1, blankRecord.contentCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("목록은 최신순과 가격순 대표값 정렬을 적용한다")
|
||||||
|
fun shouldSortSeriesByLatestAndPriceRepresentatives() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 20, 12, 0)
|
||||||
|
val viewer = saveMember("sort-representative-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("sort-representative-creator", MemberRole.CREATOR)
|
||||||
|
val theme = saveTheme("sort-representative-theme")
|
||||||
|
val oldHigh = saveSeries("old-high", creator)
|
||||||
|
val recentLow = saveSeries("recent-low", creator)
|
||||||
|
val sameDateHigh = saveSeries("same-date-high", creator)
|
||||||
|
saveSeriesContent(oldHigh, saveAudioContent(creator, theme, now.minusDays(5), isAdult = false, price = 500))
|
||||||
|
saveSeriesContent(recentLow, saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 100))
|
||||||
|
saveSeriesContent(sameDateHigh, saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 300))
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val latest = findSortedSeriesIds(creator.id!!, viewer.id!!, now, ContentSort.LATEST)
|
||||||
|
val priceHigh = findSortedSeriesIds(creator.id!!, viewer.id!!, now, ContentSort.PRICE_HIGH)
|
||||||
|
val priceLow = findSortedSeriesIds(creator.id!!, viewer.id!!, now, ContentSort.PRICE_LOW)
|
||||||
|
|
||||||
|
assertEquals(listOf(sameDateHigh.id, recentLow.id, oldHigh.id), latest)
|
||||||
|
assertEquals(listOf(oldHigh.id, sameDateHigh.id, recentLow.id), priceHigh)
|
||||||
|
assertEquals(listOf(recentLow.id, sameDateHigh.id, oldHigh.id), priceLow)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("목록은 인기순 can 합계와 소장순 유효 구매 개수 정렬을 적용한다")
|
||||||
|
fun shouldSortSeriesByPopularRevenueAndOwnedCount() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 20, 12, 0)
|
||||||
|
val viewer = saveMember("sort-order-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("sort-order-creator", MemberRole.CREATOR)
|
||||||
|
val theme = saveTheme("sort-order-theme")
|
||||||
|
val popular = saveSeries("popular-series", creator)
|
||||||
|
val owned = saveSeries("owned-series", creator)
|
||||||
|
val manyUnowned = saveSeries("many-unowned-series", creator)
|
||||||
|
val inactiveRevenue = saveSeries("inactive-revenue-series", creator)
|
||||||
|
val popularContent = saveAudioContent(creator, theme, now.minusDays(3), isAdult = false, price = 100)
|
||||||
|
val ownedKeep = saveAudioContent(creator, theme, now.minusDays(2), isAdult = false, price = 100)
|
||||||
|
val ownedRental = saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 100)
|
||||||
|
val expiredRental = saveAudioContent(creator, theme, now.minusDays(4), isAdult = false, price = 100)
|
||||||
|
val inactiveRevenueContent = saveAudioContent(creator, theme, now.minusDays(5), isAdult = false, price = 100)
|
||||||
|
saveSeriesContent(popular, popularContent)
|
||||||
|
saveSeriesContent(owned, ownedKeep)
|
||||||
|
saveSeriesContent(owned, ownedRental)
|
||||||
|
saveSeriesContent(owned, expiredRental)
|
||||||
|
saveSeriesContent(manyUnowned, saveAudioContent(creator, theme, now.minusHours(1), isAdult = false, price = 100))
|
||||||
|
saveSeriesContent(manyUnowned, saveAudioContent(creator, theme, now.minusHours(2), isAdult = false, price = 100))
|
||||||
|
saveSeriesContent(manyUnowned, saveAudioContent(creator, theme, now.minusHours(3), isAdult = false, price = 100))
|
||||||
|
saveSeriesContent(manyUnowned, saveAudioContent(creator, theme, now.minusHours(4), isAdult = false, price = 100))
|
||||||
|
saveSeriesContent(inactiveRevenue, inactiveRevenueContent)
|
||||||
|
saveOrder(viewer, creator, popularContent, OrderType.KEEP, can = 900)
|
||||||
|
saveOrder(viewer, creator, inactiveRevenueContent, OrderType.KEEP, isActive = false, can = 1000)
|
||||||
|
saveOrder(viewer, creator, ownedKeep, OrderType.KEEP)
|
||||||
|
saveOrder(viewer, creator, ownedRental, OrderType.RENTAL, endDate = now.plusDays(1))
|
||||||
|
saveOrder(viewer, creator, expiredRental, OrderType.RENTAL, endDate = now.minusDays(1))
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val popularSorted = findSortedSeriesIds(creator.id!!, viewer.id!!, now, ContentSort.POPULAR)
|
||||||
|
val ownedSorted = findSortedSeriesIds(creator.id!!, viewer.id!!, now, ContentSort.OWNED)
|
||||||
|
|
||||||
|
assertEquals(popular.id, popularSorted.first())
|
||||||
|
assertEquals(listOf(owned.id, popular.id, manyUnowned.id, inactiveRevenue.id), ownedSorted)
|
||||||
|
assertEquals(inactiveRevenue.id, popularSorted.last())
|
||||||
|
}
|
||||||
|
|
||||||
|
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): AudioContentTheme {
|
||||||
|
val theme = AudioContentTheme(theme = name, image = "$name.png", isActive = true, orders = 1)
|
||||||
|
entityManager.persist(theme)
|
||||||
|
return theme
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveAudioContent(
|
||||||
|
creator: Member,
|
||||||
|
theme: AudioContentTheme,
|
||||||
|
releaseDate: LocalDateTime,
|
||||||
|
isAdult: Boolean,
|
||||||
|
price: Int = 0
|
||||||
|
): AudioContent {
|
||||||
|
val content = AudioContent(
|
||||||
|
title = "audio-${creator.nickname}-$releaseDate",
|
||||||
|
detail = "detail",
|
||||||
|
languageCode = "ko",
|
||||||
|
releaseDate = releaseDate,
|
||||||
|
isAdult = isAdult,
|
||||||
|
price = price
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
state: SeriesState = SeriesState.PROCEEDING
|
||||||
|
): Series {
|
||||||
|
val series = Series(
|
||||||
|
title = title,
|
||||||
|
introduction = "introduction",
|
||||||
|
languageCode = "ko",
|
||||||
|
state = state,
|
||||||
|
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,
|
||||||
|
type: OrderType,
|
||||||
|
isActive: Boolean = true,
|
||||||
|
endDate: LocalDateTime? = null,
|
||||||
|
can: Int? = null
|
||||||
|
): Order {
|
||||||
|
val order = Order(type = type, isActive = isActive)
|
||||||
|
order.member = member
|
||||||
|
order.creator = creator
|
||||||
|
order.audioContent = content
|
||||||
|
can?.let { order.can = it }
|
||||||
|
entityManager.persist(order)
|
||||||
|
if (endDate != null) {
|
||||||
|
entityManager.flush()
|
||||||
|
order.endDate = endDate
|
||||||
|
}
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findSortedSeriesIds(
|
||||||
|
creatorId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
now: LocalDateTime,
|
||||||
|
sort: ContentSort
|
||||||
|
): List<Long> {
|
||||||
|
return repository.findSeries(
|
||||||
|
creatorId,
|
||||||
|
viewerId,
|
||||||
|
now,
|
||||||
|
canViewAdultContent = false,
|
||||||
|
sort,
|
||||||
|
"ko",
|
||||||
|
offset = 0,
|
||||||
|
limit = 20
|
||||||
|
).map { it.seriesId }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun flushAndClear() {
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.series.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.content.ContentType
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberProvider
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
|
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicy
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesCreatorRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesQueryPort
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesRecord
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.beans.factory.ObjectProvider
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
class CreatorChannelSeriesQueryServiceTest {
|
||||||
|
@Test
|
||||||
|
@DisplayName("시리즈 탭 서비스는 요청 fallback과 조회 컨텍스트를 port에 전달하고 탭을 조립한다")
|
||||||
|
fun shouldResolveRequestFallbacksAndAssembleSeriesTab() {
|
||||||
|
val port = FakeCreatorChannelSeriesQueryPort().apply {
|
||||||
|
series = (1L..51L).map { seriesRecord(it) }
|
||||||
|
}
|
||||||
|
val service = createService(port, canViewAdultContent = false)
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
val now = LocalDateTime.of(2026, 6, 20, 10, 0)
|
||||||
|
|
||||||
|
val tab = service.getSeriesTab(
|
||||||
|
creatorId = 1L,
|
||||||
|
viewer = viewer,
|
||||||
|
sort = "UNKNOWN",
|
||||||
|
page = -1,
|
||||||
|
size = 100,
|
||||||
|
now = now
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(ContentSort.LATEST, tab.sort)
|
||||||
|
assertEquals(0, tab.page.page)
|
||||||
|
assertEquals(50, tab.page.size)
|
||||||
|
assertEquals(0L, port.listOffset)
|
||||||
|
assertEquals(51, port.listLimit)
|
||||||
|
assertEquals(ContentSort.LATEST, port.listSort)
|
||||||
|
assertEquals("en", port.listLocale)
|
||||||
|
assertEquals(false, port.listCanViewAdultContent)
|
||||||
|
assertEquals(60, tab.seriesCount)
|
||||||
|
assertEquals(50, tab.series.size)
|
||||||
|
assertTrue(tab.hasNext)
|
||||||
|
assertEquals("https://cdn.test/cover/1.png", tab.series.first().coverImageUrl)
|
||||||
|
assertEquals("Every Mon, Thu", tab.series.first().publishedDaysOfWeek)
|
||||||
|
assertEquals(75, tab.series.first().purchasedPaidContentRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("조회자가 creator 본인이면 시리즈 구매 통계 필드는 null이다")
|
||||||
|
fun shouldHidePurchaseStatsForCreatorSelf() {
|
||||||
|
val port = FakeCreatorChannelSeriesQueryPort().apply {
|
||||||
|
series = listOf(seriesRecord(1L))
|
||||||
|
}
|
||||||
|
val service = createService(port)
|
||||||
|
val viewer = createMember(id = 1L)
|
||||||
|
|
||||||
|
val tab = service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0))
|
||||||
|
|
||||||
|
assertNull(tab.series.first().purchasedContentCount)
|
||||||
|
assertNull(tab.series.first().paidContentCount)
|
||||||
|
assertNull(tab.series.first().purchasedPaidContentRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("blank cover와 0개 유료 콘텐츠 구매율은 null cover와 0으로 조립한다")
|
||||||
|
fun shouldAssembleBlankCoverAndZeroPurchaseRate() {
|
||||||
|
val port = FakeCreatorChannelSeriesQueryPort().apply {
|
||||||
|
series = listOf(seriesRecord(1L, coverImagePath = " ", paidContentCount = 0, purchasedContentCount = 3))
|
||||||
|
}
|
||||||
|
val service = createService(port)
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
|
||||||
|
val tab = service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0))
|
||||||
|
|
||||||
|
assertNull(tab.series.first().coverImageUrl)
|
||||||
|
assertEquals(0, tab.series.first().purchasedPaidContentRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다")
|
||||||
|
fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() {
|
||||||
|
val port = FakeCreatorChannelSeriesQueryPort().apply { creator = null }
|
||||||
|
val service = createService(port)
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("member.validation.user_not_found", exception.messageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("대상 회원 role이 CREATOR가 아니면 creator_not_found 예외를 던진다")
|
||||||
|
fun shouldThrowCreatorNotFoundWhenMemberIsNotCreator() {
|
||||||
|
val port = FakeCreatorChannelSeriesQueryPort().apply { creator = creator?.copy(role = MemberRole.USER) }
|
||||||
|
val service = createService(port)
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("member.validation.creator_not_found", exception.messageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("차단 관계가 있으면 기존 차단 메시지 예외를 던진다")
|
||||||
|
fun shouldThrowBlockedAccessWhenViewerAndTargetAreBlocked() {
|
||||||
|
val port = FakeCreatorChannelSeriesQueryPort().apply { blocked = true }
|
||||||
|
val service = createService(port)
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull(exception.messageKey)
|
||||||
|
assertEquals("Channel access is restricted at creator's request.", exception.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createService(
|
||||||
|
port: FakeCreatorChannelSeriesQueryPort,
|
||||||
|
canViewAdultContent: Boolean = true
|
||||||
|
): CreatorChannelSeriesQueryService {
|
||||||
|
val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||||
|
Mockito.`when`(
|
||||||
|
preferenceService.getStoredPreference(Mockito.any(Member::class.java) ?: createMember(id = 0L))
|
||||||
|
).thenReturn(
|
||||||
|
ViewerContentPreference(
|
||||||
|
countryCode = "US",
|
||||||
|
isAdultContentVisible = canViewAdultContent,
|
||||||
|
contentType = ContentType.ALL,
|
||||||
|
isAdult = canViewAdultContent
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val langContext = LangContext()
|
||||||
|
langContext.setLang(Lang.EN)
|
||||||
|
return CreatorChannelSeriesQueryService(
|
||||||
|
queryPortProvider = FixedCreatorChannelSeriesQueryPortProvider(port),
|
||||||
|
queryPolicy = CreatorChannelSeriesQueryPolicy(),
|
||||||
|
memberContentPreferenceService = preferenceService,
|
||||||
|
messageSource = SodaMessageSource(),
|
||||||
|
langContext = langContext,
|
||||||
|
cloudFrontHost = "https://cdn.test"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMember(id: Long): Member {
|
||||||
|
return Member(
|
||||||
|
email = "member$id@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "member$id",
|
||||||
|
provider = MemberProvider.EMAIL
|
||||||
|
).apply { this.id = id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FixedCreatorChannelSeriesQueryPortProvider(
|
||||||
|
private val port: CreatorChannelSeriesQueryPort
|
||||||
|
) : ObjectProvider<CreatorChannelSeriesQueryPort> {
|
||||||
|
override fun getObject(vararg args: Any?): CreatorChannelSeriesQueryPort = port
|
||||||
|
|
||||||
|
override fun getIfAvailable(): CreatorChannelSeriesQueryPort = port
|
||||||
|
|
||||||
|
override fun getIfUnique(): CreatorChannelSeriesQueryPort = port
|
||||||
|
|
||||||
|
override fun getObject(): CreatorChannelSeriesQueryPort = port
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeCreatorChannelSeriesQueryPort : CreatorChannelSeriesQueryPort {
|
||||||
|
var creator: CreatorChannelSeriesCreatorRecord? = CreatorChannelSeriesCreatorRecord(
|
||||||
|
creatorId = 1L,
|
||||||
|
role = MemberRole.CREATOR,
|
||||||
|
nickname = "creator"
|
||||||
|
)
|
||||||
|
var blocked = false
|
||||||
|
var seriesCount = 60
|
||||||
|
var series = (1L..21L).map { seriesRecord(it) }
|
||||||
|
var listSort: ContentSort? = null
|
||||||
|
var listLocale: String? = null
|
||||||
|
var listOffset: Long? = null
|
||||||
|
var listLimit: Int? = null
|
||||||
|
var listCanViewAdultContent: Boolean? = null
|
||||||
|
|
||||||
|
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelSeriesCreatorRecord? = creator
|
||||||
|
|
||||||
|
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean = blocked
|
||||||
|
override fun countSeries(creatorId: Long, now: LocalDateTime, canViewAdultContent: Boolean): Int = seriesCount
|
||||||
|
|
||||||
|
override fun findSeries(
|
||||||
|
creatorId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
now: LocalDateTime,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
sort: ContentSort,
|
||||||
|
locale: String,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelSeriesRecord> {
|
||||||
|
listSort = sort
|
||||||
|
listLocale = locale
|
||||||
|
listOffset = offset
|
||||||
|
listLimit = limit
|
||||||
|
listCanViewAdultContent = canViewAdultContent
|
||||||
|
return series
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun seriesRecord(
|
||||||
|
seriesId: Long,
|
||||||
|
coverImagePath: String? = "cover/$seriesId.png",
|
||||||
|
paidContentCount: Int? = 4,
|
||||||
|
purchasedContentCount: Int? = 3
|
||||||
|
): CreatorChannelSeriesRecord {
|
||||||
|
return CreatorChannelSeriesRecord(
|
||||||
|
seriesId = seriesId,
|
||||||
|
title = "series-$seriesId",
|
||||||
|
coverImagePath = coverImagePath,
|
||||||
|
publishedDaysOfWeek = setOf(SeriesPublishedDaysOfWeek.MON, SeriesPublishedDaysOfWeek.THU),
|
||||||
|
isOriginal = true,
|
||||||
|
isAdult = false,
|
||||||
|
state = SeriesState.PROCEEDING,
|
||||||
|
contentCount = 5,
|
||||||
|
purchasedContentCount = purchasedContentCount,
|
||||||
|
paidContentCount = paidContentCount
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.series.domain
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesCreatorRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesRecord
|
||||||
|
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 CreatorChannelSeriesQueryPolicyTest {
|
||||||
|
private val policy = CreatorChannelSeriesQueryPolicy()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("시리즈 탭 sort 정책은 null과 알 수 없는 값을 LATEST로 fallback한다")
|
||||||
|
fun shouldFallbackInvalidSortToLatest() {
|
||||||
|
assertEquals(ContentSort.LATEST, policy.resolveSort(null))
|
||||||
|
assertEquals(ContentSort.LATEST, policy.resolveSort("UNKNOWN"))
|
||||||
|
assertEquals(ContentSort.POPULAR, policy.resolveSort("POPULAR"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("시리즈 탭 page 정책은 page와 size를 fallback하고 fetch limit을 계산한다")
|
||||||
|
fun shouldFallbackPageAndSizeForSeriesTab() {
|
||||||
|
val minimumPage = policy.createPage(page = -1, size = 10)
|
||||||
|
val maximumPage = policy.createPage(page = 2, size = 100)
|
||||||
|
|
||||||
|
assertEquals(0, minimumPage.page)
|
||||||
|
assertEquals(20, minimumPage.size)
|
||||||
|
assertEquals(0L, minimumPage.offset)
|
||||||
|
assertEquals(21, minimumPage.fetchLimit)
|
||||||
|
assertEquals(2, maximumPage.page)
|
||||||
|
assertEquals(50, maximumPage.size)
|
||||||
|
assertEquals(100L, maximumPage.offset)
|
||||||
|
assertEquals(51, maximumPage.fetchLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("시리즈 탭 목록 정책은 요청 size만 남기고 다음 페이지 여부를 계산한다")
|
||||||
|
fun shouldLimitItemsAndCalculateHasNext() {
|
||||||
|
val page = policy.createPage(page = 0, size = 20)
|
||||||
|
val fetched = (1..21).toList()
|
||||||
|
|
||||||
|
val items = policy.limitItems(fetched, page)
|
||||||
|
|
||||||
|
assertEquals((1..20).toList(), items)
|
||||||
|
assertTrue(policy.hasNext(fetched, page))
|
||||||
|
assertFalse(policy.hasNext((1..20).toList(), page))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("시리즈 탭 구매율은 유료 콘텐츠가 없으면 0이고 있으면 정수 백분율로 계산한다")
|
||||||
|
fun shouldCalculatePurchaseRateAsInteger() {
|
||||||
|
assertEquals(0, policy.purchaseRate(paidContentCount = 0, purchasedContentCount = 3))
|
||||||
|
assertEquals(75, policy.purchaseRate(paidContentCount = 4, purchasedContentCount = 3))
|
||||||
|
assertEquals(66, policy.purchaseRate(paidContentCount = 3, purchasedContentCount = 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("시리즈 탭 연재 요일은 RANDOM 포함 시 다른 요일을 무시하고 locale별 랜덤 문구를 반환한다")
|
||||||
|
fun shouldReturnRandomTextWhenDaysContainRandom() {
|
||||||
|
val days = setOf(SeriesPublishedDaysOfWeek.RANDOM, SeriesPublishedDaysOfWeek.MON)
|
||||||
|
|
||||||
|
assertEquals("랜덤", policy.publishedDaysOfWeekText(days, "ko"))
|
||||||
|
assertEquals("Random", policy.publishedDaysOfWeekText(days, "en"))
|
||||||
|
assertEquals("ランダム", policy.publishedDaysOfWeekText(days, "ja"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("시리즈 탭 연재 요일은 비어 있으면 locale별 랜덤 문구로 fallback한다")
|
||||||
|
fun shouldReturnRandomTextWhenDaysAreEmpty() {
|
||||||
|
assertEquals("랜덤", policy.publishedDaysOfWeekText(emptySet(), "ko"))
|
||||||
|
assertEquals("Random", policy.publishedDaysOfWeekText(emptySet(), "en"))
|
||||||
|
assertEquals("ランダム", policy.publishedDaysOfWeekText(emptySet(), "ja"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("시리즈 탭 연재 요일은 7개 요일이면 locale별 매일 문구를 반환한다")
|
||||||
|
fun shouldReturnEveryDayTextWhenDaysContainAllWeekdays() {
|
||||||
|
val days = setOf(
|
||||||
|
SeriesPublishedDaysOfWeek.SUN,
|
||||||
|
SeriesPublishedDaysOfWeek.MON,
|
||||||
|
SeriesPublishedDaysOfWeek.TUE,
|
||||||
|
SeriesPublishedDaysOfWeek.WED,
|
||||||
|
SeriesPublishedDaysOfWeek.THU,
|
||||||
|
SeriesPublishedDaysOfWeek.FRI,
|
||||||
|
SeriesPublishedDaysOfWeek.SAT
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("매일", policy.publishedDaysOfWeekText(days, "ko"))
|
||||||
|
assertEquals("Every day", policy.publishedDaysOfWeekText(days, "en"))
|
||||||
|
assertEquals("毎日", policy.publishedDaysOfWeekText(days, "ja"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("시리즈 탭 연재 요일은 SUN부터 SAT 순서로 locale별 매주 문구를 반환한다")
|
||||||
|
fun shouldReturnWeeklyTextOrderedFromSundayToSaturday() {
|
||||||
|
val days = setOf(SeriesPublishedDaysOfWeek.SAT, SeriesPublishedDaysOfWeek.MON, SeriesPublishedDaysOfWeek.THU)
|
||||||
|
|
||||||
|
assertEquals("매주 월, 목, 토", policy.publishedDaysOfWeekText(days, "ko"))
|
||||||
|
assertEquals("Every Mon, Thu, Sat", policy.publishedDaysOfWeekText(days, "en"))
|
||||||
|
assertEquals("毎週 月, 木, 土", policy.publishedDaysOfWeekText(days, "ja"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("시리즈 탭 domain model과 port record는 Phase 1 계약 필드를 유지한다")
|
||||||
|
fun shouldKeepDomainAndPortContract() {
|
||||||
|
val tab = CreatorChannelSeriesTab(
|
||||||
|
seriesCount = 1,
|
||||||
|
series = listOf(
|
||||||
|
CreatorChannelSeries(
|
||||||
|
seriesId = 10L,
|
||||||
|
title = "title",
|
||||||
|
coverImageUrl = null,
|
||||||
|
publishedDaysOfWeek = "매일",
|
||||||
|
isOriginal = true,
|
||||||
|
isAdult = false,
|
||||||
|
isProceeding = true,
|
||||||
|
contentCount = 3,
|
||||||
|
purchasedContentCount = null,
|
||||||
|
paidContentCount = null,
|
||||||
|
purchasedPaidContentRate = null
|
||||||
|
)
|
||||||
|
),
|
||||||
|
sort = ContentSort.LATEST,
|
||||||
|
page = policy.createPage(page = 0, size = 20),
|
||||||
|
hasNext = false
|
||||||
|
)
|
||||||
|
val creatorRecord = CreatorChannelSeriesCreatorRecord(
|
||||||
|
creatorId = 1L,
|
||||||
|
role = MemberRole.CREATOR,
|
||||||
|
nickname = "creator"
|
||||||
|
)
|
||||||
|
val seriesRecord = CreatorChannelSeriesRecord(
|
||||||
|
seriesId = 10L,
|
||||||
|
title = "title",
|
||||||
|
coverImagePath = null,
|
||||||
|
publishedDaysOfWeek = setOf(SeriesPublishedDaysOfWeek.MON),
|
||||||
|
isOriginal = true,
|
||||||
|
isAdult = false,
|
||||||
|
state = SeriesState.PROCEEDING,
|
||||||
|
contentCount = 3,
|
||||||
|
purchasedContentCount = null,
|
||||||
|
paidContentCount = null
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, tab.seriesCount)
|
||||||
|
assertTrue(tab.series.first().isProceeding)
|
||||||
|
assertNull(tab.series.first().purchasedPaidContentRate)
|
||||||
|
assertEquals(MemberRole.CREATOR, creatorRecord.role)
|
||||||
|
assertEquals(setOf(SeriesPublishedDaysOfWeek.MON), seriesRecord.publishedDaysOfWeek)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisTestConfiguration
|
||||||
import org.junit.jupiter.api.AfterEach
|
import org.junit.jupiter.api.AfterEach
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
@@ -22,7 +23,15 @@ import java.time.Instant
|
|||||||
import java.util.concurrent.CountDownLatch
|
import java.util.concurrent.CountDownLatch
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest(
|
||||||
|
classes = [
|
||||||
|
EmbeddedRedisTestConfiguration::class,
|
||||||
|
UserCreatorChatPresenceService::class,
|
||||||
|
UserCreatorChatWebSocketSessionRegistry::class,
|
||||||
|
UserCreatorChatRoomMessageBroker::class,
|
||||||
|
UserCreatorChatWebSocketServerIdConfig::class
|
||||||
|
]
|
||||||
|
)
|
||||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
@TestPropertySource(properties = ["user-creator-chat.websocket.server-id=redis-test-server"])
|
@TestPropertySource(properties = ["user-creator-chat.websocket.server-id=redis-test-server"])
|
||||||
class UserCreatorChatRedisIntegrationTest {
|
class UserCreatorChatRedisIntegrationTest {
|
||||||
|
|||||||
Reference in New Issue
Block a user