Compare commits

...

16 Commits

Author SHA1 Message Date
998dd10311 docs(creator-channel): 시리즈 탭 Phase 5 기록을 갱신한다 2026-06-20 06:23:50 +09:00
652c955356 test(gradle): 테스트 워커 heap을 확장한다 2026-06-20 06:23:42 +09:00
338f5c29bc test(creator-channel): 시리즈 탭 E2E 검증을 추가한다 2026-06-20 06:23:35 +09:00
7651fd83ea docs(creator-channel): 시리즈 탭 Phase 4 기록을 갱신한다 2026-06-20 05:20:28 +09:00
67fe0ec497 feat(creator-channel): 시리즈 탭 repository를 추가한다 2026-06-20 05:20:22 +09:00
a67322b7fd docs(creator-channel): 시리즈 탭 Phase 2와 3 기록을 갱신한다 2026-06-20 04:36:44 +09:00
25330e30c0 feat(creator-channel): 시리즈 탭 controller를 추가한다 2026-06-20 04:36:19 +09:00
dd68e64628 feat(creator-channel): 시리즈 탭 응답 변환을 추가한다 2026-06-20 04:35:55 +09:00
e8b8287968 feat(creator-channel): 시리즈 탭 조회 서비스를 추가한다 2026-06-20 04:35:26 +09:00
6c4df431b9 fix(creator-channel): 빈 연재 요일 문구를 보완한다 2026-06-20 04:35:18 +09:00
c39f339a86 docs(creator-channel): 시리즈 탭 Phase 1 기록을 갱신한다 2026-06-20 03:20:28 +09:00
2ebc728656 feat(creator-channel): 시리즈 탭 조회 정책을 추가한다 2026-06-20 03:19:41 +09:00
3d88dc7b8a feat(creator-channel): 시리즈 탭 조회 계약을 추가한다 2026-06-20 03:19:27 +09:00
7183e5f0ca test(user-creator-chat): Redis 통합 테스트 컨텍스트를 축소한다
embedded Redis 포트를 테스트 설정과 공유하도록 공개한다.

Redis 통합 테스트 전용 Bean만 로드하도록 TestConfiguration을 추가한다.

UserCreatorChat Redis 통합 테스트가 필요한 클래스만 로드하게 제한한다.
2026-06-20 03:12:14 +09:00
04579ccc0c fix(redis): repository 스캔 범위를 제한한다
Redis repository 자동 스캔 대상을 실제 Redis repository 패키지로 제한한다.

불필요한 repository 후보 탐색을 줄여 테스트 컨텍스트 확장과 OOM 재발을 방지한다.
2026-06-20 03:12:03 +09:00
99ee234b46 docs(creator-channel): 시리즈 탭 API 계획을 기록한다 2026-06-20 01:57:18 +09:00
22 changed files with 2895 additions and 4 deletions

View File

@@ -101,6 +101,7 @@ tasks.withType<KotlinCompile> {
tasks.withType<Test> {
useJUnitPlatform()
maxHeapSize = "1536m"
}
tasks.getByName<Jar>("jar") {

View 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 기준 없음.

View 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
- 없음.

View File

@@ -23,7 +23,16 @@ import java.time.Duration
@Configuration
@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(
@Value("\${spring.redis.host}")
private val host: String,

View File

@@ -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")
}
}

View File

@@ -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
)
)
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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

View File

@@ -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
)
}

View File

@@ -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)
}
)
}
}

View File

@@ -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 ""
)
}
}

View File

@@ -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?
)

View File

@@ -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?
)

View File

@@ -5,13 +5,16 @@ import org.springframework.context.ConfigurableApplicationContext
import redis.embedded.RedisServer
class EmbeddedRedisInitializer : ApplicationContextInitializer<ConfigurableApplicationContext> {
companion object {
const val PORT = 16379
}
override fun initialize(applicationContext: ConfigurableApplicationContext) {
EmbeddedRedisHolder.start()
}
}
private object EmbeddedRedisHolder {
private const val PORT = 16379
private var redisServer: RedisServer? = null
private var shutdownHookRegistered = false
@@ -22,7 +25,7 @@ private object EmbeddedRedisHolder {
}
redisServer = RedisServer.newRedisServer()
.port(PORT)
.port(EmbeddedRedisInitializer.PORT)
.setting("bind 127.0.0.1")
.setting("daemonize no")
.setting("appendonly no")

View File

@@ -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)
}
}

View File

@@ -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
)
}
}

View File

@@ -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
)
}

View File

@@ -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
)
}
}

View File

@@ -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()
}
}

View File

@@ -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
)
}

View File

@@ -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)
}
}

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
import com.fasterxml.jackson.databind.ObjectMapper
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.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@@ -22,7 +23,15 @@ import java.time.Instant
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@SpringBootTest
@SpringBootTest(
classes = [
EmbeddedRedisTestConfiguration::class,
UserCreatorChatPresenceService::class,
UserCreatorChatWebSocketSessionRegistry::class,
UserCreatorChatRoomMessageBroker::class,
UserCreatorChatWebSocketServerIdConfig::class
]
)
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@TestPropertySource(properties = ["user-creator-chat.websocket.server-id=redis-test-server"])
class UserCreatorChatRedisIntegrationTest {