Compare commits

..

28 Commits

Author SHA1 Message Date
36bd5365e0 docs(creator): 채널 홈 Phase 4 기록을 갱신한다 2026-06-17 23:53:05 +09:00
d82c3561d5 docs(creator): 채널 홈 패키지 정렬 기록을 갱신한다 2026-06-17 23:39:00 +09:00
b3e43a79ef feat(creator): 채널 홈 조회 계층 패키지를 정렬한다 2026-06-17 23:38:29 +09:00
59c83138bb docs(creator): 채널 홈 구조 정렬 기록을 갱신한다 2026-06-17 23:07:14 +09:00
b5809bbce6 feat(creator): 채널 홈 controller 위치를 정렬한다 2026-06-17 23:06:34 +09:00
a1837e8933 feat(creator): 채널 홈 facade를 추가한다 2026-06-17 23:05:52 +09:00
fa57bd211a feat(creator): 채널 홈 응답 DTO 위치를 정렬한다 2026-06-17 23:05:33 +09:00
eded4ac39a docs(creator): 채널 홈 API 구조 정렬 계획을 추가한다 2026-06-17 22:22:41 +09:00
06713cb460 docs(creator): 채널 라이브 Phase 5 기록을 갱신한다 2026-06-17 21:43:18 +09:00
e525f9de64 test(creator): 채널 라이브 통합 응답 검증을 보강한다 2026-06-17 21:43:06 +09:00
08ba743066 test(creator): 채널 홈 주문 상태 회귀를 보강한다 2026-06-17 21:42:59 +09:00
9cdf51b17f docs(creator): 채널 라이브 Phase 4 기록을 갱신한다 2026-06-17 20:20:22 +09:00
85a331c28d feat(creator): 채널 라이브 탭 조회 API를 추가한다 2026-06-17 20:19:48 +09:00
f78772b613 feat(creator): 채널 라이브 탭 응답 조립을 추가한다 2026-06-17 20:19:38 +09:00
90c0af0c8b fix(creator): 라이브 다시듣기 첫 콘텐츠 기준을 보정한다 2026-06-17 19:17:26 +09:00
3d843ac5d6 feat(creator): 채널 라이브 다시듣기 저장소를 추가한다 2026-06-17 19:16:50 +09:00
108778d5d3 docs(creator): 채널 라이브 Phase 2 기록을 갱신한다 2026-06-17 18:21:18 +09:00
3e3642bb7f feat(creator): 채널 라이브 탭 조회 서비스를 추가한다 2026-06-17 18:20:52 +09:00
6a3ca5f44f feat(creator): 채널 라이브 탭 도메인 정책을 추가한다 2026-06-17 18:20:45 +09:00
2ea030e0d6 docs(creator): 채널 라이브 API 구조 계획을 갱신한다 2026-06-17 16:37:35 +09:00
04cedac1fb docs(creator): 채널 라이브 Phase 1 기록을 갱신한다 2026-06-17 16:08:22 +09:00
81978442b2 feat(creator): 채널 홈 오디오 주문 상태를 조회한다 2026-06-17 16:07:59 +09:00
fe19be90f9 feat(creator): 채널 홈 오디오 소장 필드를 추가한다 2026-06-17 16:06:08 +09:00
7e6ac283cb feat(common): 콘텐츠 정렬 타입을 추가한다 2026-06-17 16:05:55 +09:00
8f41198d91 docs(creator): 채널 라이브 API 계획을 추가한다 2026-06-17 15:35:50 +09:00
013f012a4b docs(agent): 검증 기록 계획을 갱신한다 2026-06-16 13:37:18 +09:00
be28e9f6d0 docs(agent): 검증 기록 위치 규칙을 보강한다 2026-06-16 13:37:13 +09:00
dbc48f2ec3 docs(agent): 검증 기록 요구사항을 보강한다 2026-06-16 13:37:08 +09:00
41 changed files with 4065 additions and 144 deletions

View File

@@ -0,0 +1,529 @@
# 크리에이터 채널 라이브 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}/live`로 현재 진행 중인 라이브와 라이브 다시듣기 콘텐츠를 페이징/정렬 조회할 수 있게 한다.
**Architecture:** 라이브 탭 공개 API는 기존 크리에이터 채널 홈 API 경계를 확장하지 않고 `kr.co.vividnext.sodalive.v2.api.creator.channel.live` 조립 계층에 둔다. Controller와 Facade, API 응답 DTO는 이 계층에서 관리하고, 라이브/콘텐츠/시리즈/주문 상태처럼 재사용 가능한 조회 책임은 API 패키지 밖의 도메인 패키지에서 제공한다. 공용 콘텐츠 정렬 enum은 채널 전용 이름을 피하고 `kr.co.vividnext.sodalive.v2.common.domain.ContentSort`로 둔다. 기존 홈 API는 이번 구현 중 구조 이동하지 않고, 마지막 Phase에 다음 범위 작업용 리팩토링 프롬프트만 남긴다.
**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}/live`
- 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다.
- request:
- path variable: `creatorId`
- query parameter: `sort`, 기본값 `LATEST`
- query parameter: `page`, 기본값 `0`, 0부터 시작
- query parameter: `size`, 기본값 `20`
- response:
- `liveReplayContentCount`: 같은 필터를 적용한 라이브 다시듣기 콘텐츠 전체 개수
- `currentLive`: 기존 `CreatorChannelLiveResponse`와 같은 필드/의미를 가진 라이브 탭 API 응답 DTO
- `liveReplayContents`: 기존 `CreatorChannelAudioContentResponse`와 같은 필드/의미에 `isOwned`, `isRented`를 포함한 라이브 탭 API 응답 DTO
- `sort`: 실제 적용한 `ContentSort`
- `page`: 이번 요청에 적용된 page index
- `size`: 이번 요청에 적용된 page size
- `hasNext`: 다음 page 존재 여부
- 라이브 탭 API 응답의 오디오 콘텐츠 item에는 `isOwned`, `isRented`를 포함한다.
- `isOwned`/`isRented` 판정은 주문 row를 각각 확인한다. 유효한 `KEEP` 주문이 있으면 `isOwned == true`, 유효한 `RENTAL` 주문이 있으면 `isRented == true`다.
- `isOwned == true``isRented == true`가 동시에 발생할 가능성은 없지만, 데이터상 동시에 유효한 소장/대여 주문이 있으면 둘 다 `true`로 내려준다.
- 라이브 다시듣기 콘텐츠 기준: `AudioContentTheme.theme == "다시듣기"`이고 `AudioContentTheme.isActive == true`인 공개 오디오 콘텐츠.
- 공개 콘텐츠 기준: `AudioContent.isActive == true`, `AudioContent.duration != null`, `AudioContent.releaseDate != null`, `AudioContent.releaseDate <= now`.
- 성인 콘텐츠는 조회자의 성인 콘텐츠 노출 정책이 false이면 목록과 count에서 제외한다.
- 현재 라이브 노출은 기존 홈 API의 `findCurrentLive` 정책을 재사용한다.
- 정렬:
- `LATEST`: `releaseDate desc`, `price desc`, `audioContent.id desc`
- `POPULAR`: 구매 매출 합계 desc, `releaseDate desc`, `audioContent.id desc`
- `OWNED`: 조회자 소장 여부 desc, `releaseDate desc`, `audioContent.id desc`
- `PRICE_HIGH`: `price desc`, `releaseDate desc`, `audioContent.id desc`
- `PRICE_LOW`: `price asc`, `releaseDate desc`, `audioContent.id desc`
- 인기순 매출은 대여/소장 여부와 관계없이 순수하게 결제된 캔 매출인 `orders.can` 합계를 사용한다. `orders.point`는 포함하지 않고, `orders.is_active = true`인 주문만 포함한다. 환불/비활성 주문은 제외한다.
- page/size validation은 service에서 명시적으로 수행한다. `page < 0`, `size < 20`, `size > 50`이면 기존 `common.error.invalid_request` 계열 오류를 사용한다.
---
## 1. 파일 구조 계획
### 공용 정렬 enum
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSort.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSortTest.kt`
### 기존 크리에이터 채널 DTO/domain 확장
> 이미 완료된 선행 범위다. 미완료 라이브 탭 구현은 아래 `v2.api.creator.channel.live` 조립 계층과 `v2.creator.channel.live` 도메인 조회 계층을 따른다.
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHome.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
### 라이브 탭 신규 API 조립 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/application/CreatorChannelLiveFacade.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/dto/CreatorChannelLiveTabResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/application/CreatorChannelLiveFacadeTest.kt`
### 라이브 탭 도메인 조회 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelLiveTab.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelLiveReplayQueryPolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/port/out/CreatorChannelLiveQueryPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/CreatorChannelLiveQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelLiveReplayQueryPolicyTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
### 문서 산출물
- Modify: `docs/20260617_크리에이터_채널_라이브_API/plan-task.md`
---
## 2. Response data class 초안
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/dto/CreatorChannelLiveTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
```kotlin
package kr.co.vividnext.sodalive.v2.api.creator.channel.live.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLive
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveTab
import java.time.LocalDateTime
import java.time.ZoneOffset
data class CreatorChannelLiveTabResponse(
val liveReplayContentCount: Int,
val currentLive: CreatorChannelLiveResponse?,
val liveReplayContents: List<CreatorChannelAudioContentResponse>,
val sort: ContentSort,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(tab: CreatorChannelLiveTab): CreatorChannelLiveTabResponse {
return CreatorChannelLiveTabResponse(
liveReplayContentCount = tab.liveReplayContentCount,
currentLive = tab.currentLive?.let(CreatorChannelLiveResponse::from),
liveReplayContents = tab.liveReplayContents.map(CreatorChannelAudioContentResponse::from),
sort = tab.sort,
page = tab.page.page,
size = tab.page.size,
hasNext = tab.hasNext
)
}
}
}
data class CreatorChannelAudioContentResponse(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
val seriesName: String?,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean?,
@JsonProperty("isOwned")
val isOwned: Boolean,
@JsonProperty("isRented")
val isRented: Boolean
) {
companion object {
fun from(content: CreatorChannelAudioContent): CreatorChannelAudioContentResponse {
return CreatorChannelAudioContentResponse(
audioContentId = content.audioContentId,
title = content.title,
duration = content.duration,
imageUrl = content.imageUrl,
price = content.price,
isAdult = content.isAdult,
isPointAvailable = content.isPointAvailable,
isFirstContent = content.isFirstContent,
seriesName = content.seriesName,
isOriginalSeries = content.isOriginalSeries,
isOwned = content.isOwned,
isRented = content.isRented
)
}
}
}
data class CreatorChannelLiveResponse(
val liveId: Long,
val title: String,
val coverImageUrl: String?,
val beginDateTimeUtc: String,
val price: Int,
@JsonProperty("isAdult")
val isAdult: Boolean
) {
companion object {
fun from(live: CreatorChannelLive): CreatorChannelLiveResponse {
return CreatorChannelLiveResponse(
liveId = live.liveId,
title = live.title,
coverImageUrl = live.coverImageUrl,
beginDateTimeUtc = live.beginDateTime.toUtcIso(),
price = live.price,
isAdult = live.isAdult
)
}
}
}
private fun LocalDateTime.toUtcIso(): String {
return atOffset(ZoneOffset.UTC).toInstant().toString()
}
```
> 위 예시는 라이브 탭 공개 API 응답 DTO 기준이다. 기존 `CreatorChannelHomeResponse` 파일은 이번 라이브 탭 구조 정렬 작업에서 이동하지 않는다.
---
### Phase 1: 공용 정렬 enum과 기존 오디오 응답 확장
- [x] **Task 1.1: 공용 `ContentSort` enum 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSort.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSortTest.kt`
- RED: `ContentSortTest`를 먼저 추가해 `LATEST`, `POPULAR`, `OWNED`, `PRICE_HIGH`, `PRICE_LOW` 값이 존재하는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.ContentSortTest`
- GREEN: `ContentSort` enum을 추가한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.ContentSortTest`
- REFACTOR: enum 이름에 크리에이터 채널 전용 의미가 남아 있지 않은지 `rg -n "CreatorChannel.*Sort|Live.*Sort" src/main/kotlin/kr/co/vividnext/sodalive/v2`로 확인한다.
- 검증 기록(2026-06-17): RED 단계에서 `ContentSort` 미존재 컴파일 실패를 확인했다. GREEN 단계에서 `ContentSort` enum 추가 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.ContentSortTest` 성공을 확인했다.
- [x] **Task 1.2: `CreatorChannelAudioContentResponse`에 소장/대여 필드 추가**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHome.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
- RED: controller 테스트에서 `latestAudioContent.isOwned`, `latestAudioContent.isRented`, `audioContents[0].isOwned`, `audioContents[0].isRented` JSON 필드를 기대하도록 추가한다.
- RED: service 테스트에서 `CreatorChannelAudioContentRecord``CreatorChannelAudioContent` 변환 시 `isOwned`, `isRented`가 유지되는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
- GREEN: domain model, record, response DTO, service 변환에 `isOwned`, `isRented`를 추가한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
- REFACTOR: 기존 홈 API 응답에 새 boolean 필드가 항상 존재하도록 null 불가능 `Boolean`으로 유지한다.
- 검증 기록(2026-06-17): RED 단계에서 `isOwned`/`isRented` 미존재 컴파일 실패를 확인했다. GREEN 단계에서 domain/record/response/service mapper를 확장하고 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest` 성공을 확인했다.
- [x] **Task 1.3: 기존 홈 오디오 조회에 주문 상태 bulk 판정 추가**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- RED: repository 테스트에 조회자가 `KEEP` 주문한 콘텐츠와 유효한 `RENTAL` 주문한 콘텐츠를 넣고, `findLatestAudioContent`, `findAudioContents` 결과의 `isOwned`/`isRented`가 각각 맞는지 검증한다.
- RED: 같은 콘텐츠에 `KEEP`과 유효한 `RENTAL`이 함께 있으면 `isOwned == true`, `isRented == true`를 기대한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- GREEN: `findLatestAudioContent`, `findAudioContents``viewerId`를 전달하고, 조회된 content id 묶음으로 주문 상태를 bulk 조회해 `CreatorChannelAudioContentRecord`에 채운다. 유효 대여 조건은 기존 주문 정책과 같이 `order.isActive == true`, `order.type == RENTAL`, `order.endDate > now`를 사용한다. 소장 조건은 `order.isActive == true`, `order.type == KEEP`이다. 소장/대여 상태는 서로 배타적으로 보정하지 않는다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- REFACTOR: 콘텐츠마다 `OrderRepository.isExistOrderedAndOrderType`를 반복 호출하지 않고 content id 목록 기반 bulk 조회를 유지한다.
- 검증 기록(2026-06-17): RED 단계에서 repository method signature와 `isOwned`/`isRented` 미존재 컴파일 실패를 확인했다. GREEN 단계에서 content id 목록 기반 bulk 주문 상태 조회를 추가하고 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 성공을 확인했다.
---
### Phase 2: 라이브 탭 domain/application 정책
- [x] **Task 2.1: 라이브 탭 domain model과 page 정책 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelLiveTab.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelLiveReplayQueryPolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelLiveReplayQueryPolicyTest.kt`
- RED: `page=0,size=20`이면 offset `0`, fetch limit `21`, 응답 items limit `20`, `hasNext == true` 판정이 되는 테스트를 작성한다.
- RED: `page < 0`, `size < 20`, `size > 50`이면 정책이 예외를 던지는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicyTest`
- GREEN: `CreatorChannelPage(page: Int, size: Int)``CreatorChannelLiveReplayQueryPolicy`를 추가한다. `offset = page * size`, `fetchLimit = size + 1`, `items = fetched.take(size)`, `hasNext = fetched.size > size`를 제공한다. `size`는 20 이상 50 이하로 검증해 `fetchLimit` overflow를 방지한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicyTest`
- REFACTOR: page/size 계산은 service나 repository에 중복 구현하지 않는다.
- 검증 기록(2026-06-17): RED 단계에서 `CreatorChannelLiveReplayQueryPolicy` 미존재 컴파일 실패를 확인했다. GREEN 단계에서 `CreatorChannelPage`, `CreatorChannelLiveTab`, `CreatorChannelLiveReplayQueryPolicy`를 추가하고 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicyTest` 성공을 확인했다. 추가 리뷰 반영으로 `size < 20`, `size > 50`, `size = Int.MAX_VALUE``common.error.invalid_request`를 던지고 `size = 50``fetchLimit`이 51인지 검증했다.
- [x] **Task 2.2: `CreatorChannelLiveQueryService` 골격 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/port/out/CreatorChannelLiveQueryPort.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryServiceTest.kt`
- RED: service 테스트에서 `getLiveTab(creatorId, viewer, sort = ContentSort.LATEST, page = 0, size = 20)` 호출 시 port의 `findCreator`, `existsBlockedBetween`, `findCurrentLive`, `countLiveReplayAudioContents`, `findLiveReplayAudioContents`가 필요한 인자로 호출되는지 fake port로 검증한다.
- RED: 조회 대상이 없으면 `member.validation.user_not_found`, 크리에이터가 아니면 `member.validation.creator_not_found`, 차단 관계이면 기존 차단 메시지 예외를 기대한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`
- GREEN: 기존 `CreatorChannelHomeQueryService`의 인증 회원 기준 정책을 따라 creator 검증, 차단 검증, adult visibility, effective gender 계산을 구현한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`
- REFACTOR: `CreatorChannelHomeQueryService`와 중복되는 private 변환/검증 코드가 과도해지면 같은 phase 안에서는 복사 유지하고, 추후 별도 공용 validator 추출은 구현 범위 밖으로 둔다.
- 검증 기록(2026-06-17): RED 단계에서 `CreatorChannelLiveQueryService``CreatorChannelLiveQueryPort` 미존재 컴파일 실패를 확인했다. GREEN 단계에서 홈 API 서비스의 creator 검증, 차단 검증, adult visibility, effective gender 전달 패턴을 반영하고 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest` 성공을 확인했다.
- [x] **Task 2.3: 라이브 탭 service 응답 조립 완성**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelLiveTab.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryServiceTest.kt`
- RED: fake port가 `size + 1`개 콘텐츠를 반환하면 service 응답의 `liveReplayContents.size == size`, `hasNext == true`, `page == 0`, `size == 20`, `sort == LATEST`인지 검증한다.
- RED: invalid `page`/`size` 요청은 port bean 조회 전에 `common.error.invalid_request`를 던지는지 검증한다.
- RED: `page` 범위에 콘텐츠가 없으면 `liveReplayContents`는 빈 배열이고 count는 port count 값을 유지하는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`
- GREEN: service에서 policy로 page/size를 먼저 검증하고, 검증 후 port를 조회해 count와 `size + 1` 조회 결과를 조립해 `CreatorChannelLiveTab`을 반환한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`
- REFACTOR: `ContentSort` 기본값은 controller가 기본값을 넣되, service 테스트에서도 명시 인자를 사용해 정책 위치를 분리한다.
- 검증 기록(2026-06-17): RED 단계에서 service 조립 대상 domain/port/service 미존재 컴파일 실패를 확인했다. GREEN 단계에서 count, 현재 라이브, `size + 1` 다시듣기 목록을 `CreatorChannelLiveTab`으로 조립하고 `hasNext`, page, sort, 소장/대여 상태 보존을 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`로 확인했다. 추가 리뷰 반영으로 invalid `page`/`size` 요청이 `ObjectProvider.getObject()`보다 먼저 `common.error.invalid_request`로 중단되는지 검증했다.
---
### Phase 3: 라이브 다시듣기 persistence adapter
- [x] **Task 3.1: 라이브 탭 repository 골격과 count 조회 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/CreatorChannelLiveQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
- RED: fixture로 `다시듣기` 테마 콘텐츠 2개, 다른 테마 콘텐츠 1개, 예약 공개 콘텐츠 1개, 비활성 콘텐츠 1개를 만들고 count가 공개 `다시듣기` 콘텐츠만 세는지 검증한다.
- RED: 성인 노출 불가이면 성인 `다시듣기` 콘텐츠가 count에서 제외되는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
- GREEN: `countLiveReplayAudioContents(creatorId, now, canViewAdultContent)`를 구현한다. 조건은 creator, active member, active content, active theme, `theme == "다시듣기"`, `duration != null`, `releaseDate != null`, `releaseDate <= now`, adult filter다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
- REFACTOR: `"다시듣기"` 문자열은 repository companion object의 `LIVE_REPLAY_THEME` 상수로 둔다.
- 검증 기록(2026-06-17): RED 단계에서 `DefaultCreatorChannelLiveQueryRepository` 미존재 컴파일 실패를 확인했다. GREEN 단계에서 live query repository interface/default 구현체와 `countLiveReplayAudioContents`를 추가하고, 공개 `다시듣기` 콘텐츠/성인 노출 정책 count를 `DefaultCreatorChannelLiveQueryRepositoryTest.shouldCountPublicLiveReplayAudioContentsOnly`로 검증했다.
- [x] **Task 3.2: 라이브 다시듣기 기본 목록과 페이징 조회 추가**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
- RED: `offset=20, limit=21` 인자를 전달하면 21개까지만 조회하고, 앞 page 콘텐츠가 제외되는지 검증한다.
- RED: `LATEST` 정렬에서 공개일 최신순, 같은 공개일이면 높은 가격순이 먼저인지 검증한다.
- RED: `다시듣기`보다 오래된 다른 테마 공개 오디오 콘텐츠가 있으면 `다시듣기` 목록 item의 `isFirstContent``false`인지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
- GREEN: `findLiveReplayAudioContents(creatorId, viewerId, now, canViewAdultContent, sort, offset, limit)`를 구현한다. 우선 `LATEST`, `PRICE_HIGH`, `PRICE_LOW` 정렬을 QueryDSL order specifier로 처리한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
- REFACTOR: content row 조회, first content id 조회, series summary 조회, order status 조회를 작은 private 함수로 분리한다.
- 검증 기록(2026-06-17): `findLiveReplayAudioContents`를 QueryDSL `orderBy`/`offset`/`limit` 기반으로 구현하고, `LATEST`의 공개일/가격 정렬, page offset/limit 적용, first content 및 series summary mapping을 `shouldFindLiveReplayAudioContentsWithPaginationAndLatestSort`로 확인했다. `PRICE_HIGH`, `PRICE_LOW` 정렬은 `shouldSortLiveReplayAudioContentsByPrice`로 확인했다.
- 보완 검증 기록(2026-06-17): `isFirstContent``다시듣기` 테마 안의 첫 콘텐츠가 아니라 크리에이터의 전체 공개 오디오 콘텐츠 중 첫 콘텐츠 기준이어야 하므로 `shouldMarkFirstContentByAllPublicAudioContentsNotLiveReplayTheme`를 추가했다. RED 단계에서 기존 구현이 `isFirstContent == true`를 반환해 실패하는 것을 확인했고, GREEN 단계에서 first content id 조회 조건에서 `다시듣기` 테마 필터를 제거해 기존 홈 API와 같은 전체 공개 오디오 기준으로 보정했다. 이후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`, `./gradlew ktlintCheck`, `git diff --check` 성공을 확인했다.
- [x] **Task 3.3: `POPULAR` 정렬 구현**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
- RED: 대여/소장 여부와 관계없이 `orders.can` 합계가 큰 콘텐츠가 먼저 나오고, 같은 매출이면 공개일 최신순이 먼저 나오는 테스트를 작성한다.
- RED: `orders.isActive == false` 주문과 `orders.point` 값은 매출 합계에서 제외되는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
- GREEN: orders left join 또는 집계 subquery로 콘텐츠별 활성 주문의 `can` 합계를 계산하고 `POPULAR` 정렬에 반영한다. `point`는 더하지 않는다. 매출이 없으면 0으로 처리한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
- REFACTOR: 인기순 집계가 기본 목록 count를 중복시키지 않도록 count query와 list query를 분리 유지한다.
- 검증 기록(2026-06-17): `POPULAR` 정렬은 활성 주문의 `orders.can` 합계를 left join/group by로 계산하도록 구현했다. `orders.point`와 비활성 주문이 정렬에 반영되지 않는지 `shouldSortLiveReplayAudioContentsByPopularCanRevenue`로 확인했다.
- [x] **Task 3.4: `OWNED` 정렬과 소장/대여 응답 상태 구현**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
- RED: `OWNED` 정렬에서 조회자가 `KEEP` 주문한 콘텐츠가 먼저 나오고, 나머지는 공개일 최신순으로 정렬되는지 검증한다.
- RED: 유효한 `RENTAL` 주문만 있는 콘텐츠는 `isRented == true`, `isOwned == false`인지 검증한다.
- RED: `KEEP`과 유효한 `RENTAL`이 모두 있으면 `isOwned == true`, `isRented == true`인지 검증한다.
- RED: 만료된 `RENTAL``isRented == false`인지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
- GREEN: 조회된 content id 목록 기준으로 주문 상태를 bulk 조회해 record에 채운다. `OWNED` 정렬은 `KEEP` 존재 여부를 1차 기준으로 삼는다. 응답의 `isOwned`, `isRented`는 각각의 주문 존재 여부를 그대로 반영하고 서로 배타적으로 보정하지 않는다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
- REFACTOR: 응답 상태 판정과 `OWNED` 정렬 판정의 의미가 어긋나지 않도록 동일한 유효 주문 조건을 사용한다.
- 검증 기록(2026-06-17): `OWNED` 정렬은 조회자의 활성 `KEEP` 주문 존재 여부를 QueryDSL group by 정렬 기준으로 삼고, 응답의 `isOwned`/`isRented`는 조회된 content id 목록 기준 bulk 조회로 채우도록 구현했다. 유효 대여, 만료 대여, 소장+대여 동시 존재를 `shouldSortLiveReplayAudioContentsByOwnedAndReturnOrderStates`로 확인했다.
- [x] **Task 3.5: 현재 라이브 조회 위임 구현**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
- RED: 현재 진행 중인 라이브 조회가 기존 홈 API와 같은 정책으로 성인 노출, 성별 제한, 크리에이터 입장 제한을 반영하는지 대표 케이스를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
- GREEN: `DefaultCreatorChannelLiveQueryRepository.findCurrentLive`는 기존 `DefaultCreatorChannelHomeQueryRepository.findCurrentLive`와 같은 조건을 사용한다. 코드 중복이 과하면 private helper를 복사하되, 동작 보존을 우선한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
- REFACTOR: 이후 공용 live query helper 추출은 이번 구현 범위에서 제외한다.
- 검증 기록(2026-06-17): 기존 홈 API의 현재 라이브 조건을 live tab repository에 복사해 성인 노출, 성별 제한, 크리에이터 입장 제한, 진행 중 라이브 정렬 정책을 맞췄다. `shouldFindCurrentLiveWithHomePolicy``shouldFindCreatorAndBlockedRelationship`으로 current live/creator/block port 계약을 확인했다.
---
### Phase 4: Controller와 공개 응답
- [x] **Task 4.1: 라이브 탭 controller endpoint 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/application/CreatorChannelLiveFacade.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/dto/CreatorChannelLiveTabResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/application/CreatorChannelLiveFacadeTest.kt`
- RED: `GET /api/v2/creator-channels/1/live`가 인증 회원, `creatorId`, 기본 `sort=LATEST`, 기본 `page=0`, 기본 `size=20`을 service에 전달하는 MockMvc 테스트를 작성한다.
- RED: 응답 JSON에 `liveReplayContentCount`, `currentLive`, `liveReplayContents`, `sort`, `page`, `size`, `hasNext`, `liveReplayContents[0].isOwned`, `liveReplayContents[0].isRented`가 존재하는지 검증한다.
- RED: anonymous 요청은 기존 홈 API와 같이 unauthorized가 되는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.application.CreatorChannelLiveFacadeTest`
- GREEN: `CreatorChannelLiveController``@GetMapping("/{creatorId}/live")`를 추가하고 `CreatorChannelLiveFacade`를 주입한다. query parameter는 `@RequestParam(defaultValue = "LATEST") sort: ContentSort`, `@RequestParam(defaultValue = "0") page: Int`, `@RequestParam(defaultValue = "20") size: Int`로 받는다. Facade는 `CreatorChannelLiveQueryService` 결과를 공개 API DTO로 변환한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.application.CreatorChannelLiveFacadeTest`
- REFACTOR: 기존 `CreatorChannelHomeController`에는 라이브 endpoint를 추가하지 않는다.
- 검증 기록(2026-06-17): RED 단계에서 `CreatorChannelLiveController`, `CreatorChannelLiveFacade`, 라이브 탭 공개 DTO 미존재 컴파일 실패를 확인했다. GREEN 단계에서 `v2.api.creator.channel.live` 하위 controller/facade/DTO를 추가하고, 인증 회원 기본 요청이 `sort=LATEST`, `page=0`, `size=20`을 facade에 전달하며 `liveReplayContentCount`, `currentLive`, `liveReplayContents`, `sort`, `page`, `size`, `hasNext`, `isOwned`, `isRented` 응답 필드를 반환하는지 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.application.CreatorChannelLiveFacadeTest`로 확인했다. 비회원 요청은 기존 홈 API와 같은 테스트 보안 설정에서 401로 거부됨을 확인했다.
- [x] **Task 4.2: 잘못된 page/size validation 표면 확인**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryServiceTest.kt`
- RED: `page=-1` 또는 `size=0` 요청이 기존 `SodaExceptionHandler` 오류 표면인 HTTP 200 + `success=false`로 처리되는지 controller/service 테스트를 추가한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`
- GREEN: service에서 `CreatorChannelLiveReplayQueryPolicy.validatePage(page, size)`를 호출하고 invalid request 예외를 던진다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`
- REFACTOR: Spring enum binding 실패(`sort=UNKNOWN`)는 별도 custom handling을 추가하지 않고 기존 MVC 오류 흐름을 사용한다.
- 검증 기록(2026-06-17): controller 테스트에 `page=-1`, `size=0` 요청 표면을 추가하고, 기존 `SodaExceptionHandler` 흐름에 맞춰 HTTP 200 + `success=false` 응답으로 확인했다. service invalid 요청은 Phase 2에서 port 조회 전 `common.error.invalid_request`로 중단되도록 구현되어 있어 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`로 controller 표면과 service validation 회귀를 함께 확인했다.
---
### Phase 5: 회귀 및 문서 동기화
- [x] **Task 5.1: 기존 홈 API 회귀 테스트 보강**
- Files:
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- RED: 기존 홈 API의 `latestAudioContent``audioContents`에 새 `isOwned`, `isRented` 필드가 내려오는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- GREEN: Phase 1 구현이 빠뜨린 변환/fixture를 보정한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- REFACTOR: test fixture의 `CreatorChannelAudioContent` 생성부가 반복되면 테스트 내부 helper만 추가하고 production abstraction은 만들지 않는다.
- 검증 기록(2026-06-17): 기존 controller/service 테스트의 `isOwned`/`isRented` 응답/변환 회귀에 더해, 홈 repository 통합 fixture에서 `latestAudioContent``KEEP` 주문과 `audioContents`의 유효 `RENTAL` 주문 상태를 함께 검증하도록 보강했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 성공을 확인했다.
- [x] **Task 5.2: 라이브 탭 통합 시나리오 검증**
- Files:
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveControllerTest.kt`
- RED: 실제 fixture 기반으로 현재 라이브 1개, 라이브 다시듣기 21개, 소장/대여/미구매 콘텐츠를 넣고 `page=0,size=20,sort=LATEST` 응답 표면을 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest`
- GREEN: 누락된 mapping, count, hasNext, sort 응답을 보정한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest`
- REFACTOR: 통합 테스트는 하나의 대표 시나리오만 유지하고 세부 정렬/상태 케이스는 repository 단위 테스트로 둔다.
- 검증 기록(2026-06-17): 기존 repository 테스트의 21개 조회/pagination/current live/order state 검증에 더해, controller 응답 표면에서 `liveReplayContentCount=21`, `liveReplayContents.length()==20`, `hasNext=true`, `sort=LATEST`, `page=0`, `size=20`, 소장/대여/미구매 상태가 JSON으로 내려오는 대표 시나리오를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest` 성공을 확인했다.
- [x] **Task 5.3: 라이브 탭 end-to-end 통합 테스트 추가**
- Files:
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveEndToEndTest.kt`
- TDD 예외 사유: production 동작 변경 없이 기존 구현의 controller-service-repository-DB-JSON 연결을 고정하는 회귀 테스트 추가 task다.
- RED: `@SpringBootTest + MockMvc` 기반으로 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/live?page=0&size=20&sort=LATEST`를 호출하는 실제 end-to-end 테스트를 추가한다.
- 검증 대상:
- 현재 라이브 1개가 `currentLive`로 내려온다.
- 공개 `다시듣기` 콘텐츠 21개 중 응답 목록은 20개만 내려온다.
- `liveReplayContentCount=21`, `hasNext=true`, `sort=LATEST`, `page=0`, `size=20`이 내려온다.
- 조회자의 `KEEP`, 유효 `RENTAL`, 미구매 콘텐츠 상태가 `isOwned`/`isRented` JSON으로 내려온다.
- 이미지 경로는 실제 facade/service mapping을 거쳐 CDN URL로 내려온다.
- 실행 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveEndToEndTest`
- GREEN: production 변경 없이 기존 구현이 통과하면 회귀 테스트로 유지한다. 실패하면 실패 원인이 테스트 fixture인지 실제 연결 결함인지 구분해 최소 수정한다.
- REFACTOR: fixture helper는 테스트 파일 내부에만 둔다. 기존 mock 기반 controller 테스트와 repository 세부 테스트는 유지한다.
- 검증 기록(2026-06-17): `CreatorChannelLiveEndToEndTest`를 추가해 실제 Spring context에서 `Controller -> Facade -> Service -> Repository -> DB -> Response JSON` 흐름을 검증했다. 테스트 fixture는 커밋된 DB 상태를 MockMvc 요청에서 조회하도록 `TransactionTemplate`으로 생성했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveEndToEndTest` 성공을 확인했다. 최초 성공 실행에서 H2 shutdown 경고가 있어 테스트 전용 datasource URL에 `DB_CLOSE_ON_EXIT=FALSE`를 추가했고, 동일 명령 재실행 성공을 확인했다.
- [x] **Task 5.4: 전체 회귀 검증과 문서 검증 기록 추가**
- Files:
- Modify: `docs/20260617_크리에이터_채널_라이브_API/plan-task.md`
- TDD 예외 사유: 문서 검증 기록 갱신 task로 production/test 코드 변경이 없다.
- 대체 검증 방법: 아래 명령 실행 결과를 이 task 아래와 문서 하단 검증 기록에 누적한다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.ContentSortTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicyTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveEndToEndTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.application.CreatorChannelLiveFacadeTest`
- `./gradlew ktlintCheck`
- 기대 결과: 모든 명령이 성공한다.
- REFACTOR: 실패가 발생하면 실패 task로 돌아가 문서의 체크박스를 완료 처리하지 않는다.
- 검증 기록(2026-06-17): Phase 5 최종 회귀로 아래 명령이 모두 성공함을 확인했다.
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.ContentSortTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicyTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveEndToEndTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.application.CreatorChannelLiveFacadeTest`
- `./gradlew ktlintCheck`
- `git diff --check`
---
### Phase 6: 다음 범위 홈 API 구조 정렬 인계
- [ ] **Task 6.1: 크리에이터 채널 홈 API 리팩토링 후속 프롬프트 보존**
- Files:
- Modify: `docs/20260617_크리에이터_채널_라이브_API/plan-task.md`
- TDD 예외 사유: 다음 범위 작업을 위한 인계 프롬프트 작성 task로 production/test 코드 변경이 없다.
- 대체 검증 방법:
- 문서 내 프롬프트가 이번 라이브 탭 구현을 다시 수정하라고 지시하지 않는지 확인한다.
- 프롬프트가 기존 홈 API endpoint와 공개 응답 계약 보존, 테스트 선행, 패키지 의존 방향을 명시하는지 확인한다.
- 후속 작업용 GPT-5.5 프롬프트:
```text
너는 /Users/klaus/Develop/sodalive/Server/sodalive 저장소에서 작업하는 GPT-5.5 기반 코딩 에이전트다.
목표:
기존 크리에이터 채널 홈 API 구현을 현재 v2 공개 API 설계와 맞게 `v2.api.*` 조립 계층 + API 패키지 밖 도메인 패키지 구조로 정렬한다.
반드시 지킬 규칙:
- 사용자와 저장소의 AGENTS.md, `docs/agent-guides/코드스타일.md`, `docs/agent-guides/테스트스타일.md`, `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`를 먼저 읽고 따른다.
- 구현 전 기존 PRD/plan-task 문서를 확인하고, 이 작업이 새 범위라면 `docs/[날짜]_크리에이터_채널_홈_API_구조정렬/prd.md`, `docs/[날짜]_크리에이터_채널_홈_API_구조정렬/plan-task.md`를 작성한다.
- 기존 공개 endpoint `GET /api/v2/creator-channels/{creatorId}/home`과 응답 필드명/의미를 변경하지 않는다.
- 리팩토링 목적은 파일 위치와 책임 경계 정렬이다. 기능 추가, 응답 스키마 확장, 불필요한 공용화는 하지 않는다.
- 클라이언트 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.home` 하위로 이동한다.
- 재사용 가능한 조회/정책/port/repository는 `kr.co.vividnext.sodalive.v2.creator.channel.home` 또는 더 적합한 도메인 패키지 하위에 둔다. 도메인 패키지는 `kr.co.vividnext.sodalive.v2.api.*`에 의존하지 않는다.
- 의존 방향은 항상 `v2.api.creator.channel.home -> 도메인 패키지`로 유지한다.
- 기존 `v2.creator.channel.adapter.in.web.CreatorChannelHomeController`를 새 API 조립 계층으로 옮길 때 Spring mapping 충돌이 생기지 않도록 기존 controller 제거/이동 범위를 명확히 한다.
- 테스트는 먼저 실패하도록 작성하거나 이동한 뒤 실패를 확인하고, 최소 구현으로 통과시킨다.
- 기존 홈 API 회귀 테스트를 유지한다. 최소 검증 대상은 controller, facade 또는 service, repository 단위 테스트와 `./gradlew ktlintCheck`다.
- 이번 라이브 탭 API 구현(`v2.api.creator.channel.live`, `v2.creator.channel.live`)은 리팩토링 대상이 아니다. 필요한 경우 import 관계 확인만 하고 동작 변경은 하지 않는다.
권장 진행 순서:
1. 기존 홈 API 파일과 테스트를 모두 찾고 현재 public contract를 문서화한다.
2. 새 PRD에 “동작 보존 리팩토링” 범위와 non-goal을 명시한다.
3. plan-task에 TDD 기준으로 파일 이동, controller/facade 분리, domain package 정렬, 회귀 검증 task를 작성한다.
4. Controller/DTO를 `v2.api.creator.channel.home`으로 이동하고, 기존 service/domain/port/repository는 API 패키지 밖에 유지하거나 `v2.creator.channel.home` 하위로 정렬한다.
5. `rg -n "kr\\.co\\.vividnext\\.sodalive\\.v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator src/main/kotlin/kr/co/vividnext/sodalive/v2/live src/main/kotlin/kr/co/vividnext/sodalive/v2/content src/main/kotlin/kr/co/vividnext/sodalive/v2/series`로 도메인 패키지가 API 패키지를 import하지 않는지 확인한다.
6. `GET /api/v2/creator-channels/{creatorId}/home` 회귀 테스트와 관련 단위 테스트를 실행하고, 검증 결과를 plan-task에 기록한다.
성공 기준:
- 홈 API endpoint와 응답 계약이 유지된다.
- 홈 API 공개 조립 계층은 `v2.api.creator.channel.home`에 있다.
- 도메인 패키지는 `v2.api.*`에 의존하지 않는다.
- 관련 테스트와 ktlint 검증 결과가 plan-task에 기록되어 있다.
```
---
## 3. 구현 순서 요약
1. `ContentSort` 공용 enum을 먼저 추가한다.
2. 기존 완료 범위인 `CreatorChannelAudioContentResponse`와 domain/record의 `isOwned`, `isRented` 확장 상태를 유지한다.
3. 라이브 탭 page 정책과 service 골격을 `v2.creator.channel.live` 하위에 만든다.
4. 라이브 다시듣기 count/list repository를 `v2.creator.channel.live` 하위에 구현한다.
5. controller/facade/응답 DTO를 `v2.api.creator.channel.live` 하위에 연결한다.
6. 기존 홈 API 회귀와 라이브 탭 통합 시나리오를 검증한다.
7. 다음 범위에서 홈 API 구조 정렬을 진행할 수 있도록 Phase 6 프롬프트를 보존한다.
---
## 4. 검증 기록
- 아직 구현 전 계획 문서 작성 단계다. 구현 task 완료 후 각 task 아래에 무엇을, 왜, 어떻게 검증했는지 실행 명령과 결과를 누적 기록한다.
- 2026-06-17 문서 갱신 검증: 라이브 탭 신규 구현을 `v2.api.creator.channel.live` 조립 계층과 `v2.creator.channel.live` 도메인 조회 계층으로 진행하도록 PRD/plan-task를 갱신했다. 기존 홈 API 구조 정렬은 Phase 6 후속 프롬프트로 분리했다. `git diff --check`로 whitespace 오류가 없음을 확인했고, `./gradlew tasks --all`로 Gradle 명령 유효성 확인에 성공했다.
- 2026-06-17 Phase 2 검증: `CreatorChannelLiveReplayQueryPolicyTest``CreatorChannelLiveQueryServiceTest`를 먼저 추가해 RED 단계에서 Phase 2 production 클래스 미존재 컴파일 실패를 확인했다. GREEN 단계에서 라이브 탭 domain/page 정책, port, service 조립을 추가하고 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`, `./gradlew ktlintCheck`, `git diff --check` 성공을 확인했다. Phase 3 port 구현 전 Spring context가 깨지지 않도록 service는 `ObjectProvider<CreatorChannelLiveQueryPort>`로 port를 지연 조회하게 했고, `./gradlew test --tests kr.co.vividnext.sodalive.support.SpringBootIntegrationSampleTest` 성공으로 대표 context 기동을 확인했다. 추가 리뷰 반영으로 `page >= 0`, `20 <= size <= 50` 검증과 invalid 요청 시 port 조회 전 중단을 보강했고, 동일 targeted 테스트와 `ktlintCheck`, `git diff --check` 성공을 확인했다. 전체 `./gradlew test`는 실행 중 기존 SpringBoot 통합 테스트들의 bean/OOM 계열 실패와 timeout이 발생해 완료하지 못했으며, Phase 2 직접 변경 범위는 위 targeted/context 검증으로 확인했다.
- 2026-06-17 Phase 3 검증: `DefaultCreatorChannelLiveQueryRepositoryTest`를 먼저 추가해 RED 단계에서 `DefaultCreatorChannelLiveQueryRepository` 미존재 컴파일 실패를 확인했다. GREEN 단계에서 live tab repository interface/default 구현체를 추가하고 count/list/pagination/sort/order state/current live policy를 구현했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest --tests kr.co.vividnext.sodalive.support.SpringBootIntegrationSampleTest`, `./gradlew ktlintCheck`, `git diff --check` 성공을 확인했다. 리뷰 게이트에서 Phase 3 persistence adapter 범위와 시나리오 검증에 대한 unconditional approval을 받았다.
- 2026-06-17 Phase 3 리뷰 보완 검증: `isFirstContent` 의미를 PRD에 명시하고, 라이브 다시듣기 repository 테스트에 다른 테마의 더 오래된 공개 오디오가 있을 때 `다시듣기` item의 `isFirstContent``false`인지 확인하는 회귀 테스트를 추가했다. RED 단계에서 기존 구현이 실패하는 것을 확인했고, first content id 조회를 기존 홈 API와 같은 전체 공개 오디오 기준으로 수정했다. 검증은 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`로 수행했고 모두 성공했다. 검증 중 Gradle 테스트 2개를 병렬 실행했을 때 한 세션에서 QueryDSL generated source compile race로 `compileJava`가 실패했으나, 단일 세션으로 재실행한 repository 전체 테스트는 성공했다.
- 2026-06-17 Phase 4 검증: 라이브 탭 공개 API 조립 계층을 `v2.api.creator.channel.live` 하위에 추가했다. RED 단계에서 controller/facade/DTO 미존재 컴파일 실패를 확인했고, GREEN 단계에서 `CreatorChannelLiveControllerTest`, `CreatorChannelLiveFacadeTest` 통과를 확인했다. invalid `page`/`size` 요청은 기존 오류 응답 표면인 HTTP 200 + `success=false`로 확인했고, `CreatorChannelLiveQueryServiceTest`와 함께 service validation 회귀를 확인했다. 검증은 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.application.CreatorChannelLiveFacadeTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`, `./gradlew ktlintCheck`, `git diff --check`로 수행했고 모두 성공했다.
- 2026-06-17 Phase 5 검증: 기존 홈 API 회귀와 라이브 탭 대표 응답 표면을 보강했다. 홈 repository 통합 fixture는 `latestAudioContent.isOwned/isRented``audioContents.isOwned/isRented`를 주문 상태 기반으로 검증하고, 라이브 탭 controller는 현재 라이브 1개, 다시듣기 20개 응답, 전체 count 21, `hasNext=true`, 소장/대여/미구매 상태를 확인한다. 추가로 `CreatorChannelLiveEndToEndTest`를 만들어 실제 Spring context에서 `Controller -> Facade -> Service -> Repository -> DB -> Response JSON` 흐름을 검증했다. Phase 5 지정 테스트와 `./gradlew ktlintCheck`, `git diff --check`가 모두 성공했다.

View File

@@ -0,0 +1,271 @@
# PRD: 크리에이터 채널 라이브 API
## 1. Overview
크리에이터 채널의 라이브 탭에서 현재 진행 중인 라이브와 `다시듣기` 콘텐츠를 한 번에 조회하는 API를 제공한다.
---
## 2. Problem
- 크리에이터 채널 홈 API는 홈 화면에 필요한 요약 데이터를 제공하지만, 라이브 탭은 현재 라이브와 `다시듣기` 콘텐츠 목록/개수를 함께 조회해야 한다.
- 클라이언트는 라이브 탭 진입 시 현재 진행 중인 라이브, `다시듣기` 콘텐츠 목록, 전체 개수, 적용된 정렬 순서를 일관된 계약으로 받아야 한다.
- `다시듣기` 콘텐츠 정렬 기준이 여러 개이고 이후 오디오 콘텐츠, 시리즈, 화보 등 채널 내 다른 콘텐츠 목록에서도 같은 정렬 기준을 사용할 예정이므로 서버와 클라이언트가 공유할 명시적인 enum 계약이 필요하다.
- 응답 필드는 기존 `CreatorChannelLiveResponse`, `CreatorChannelAudioContentResponse`와 의미가 어긋나지 않아야 한다.
---
## 3. Goals
- 크리에이터 채널 라이브 탭 조회 API를 제공한다.
- 클라이언트에서 호출하는 공개 API는 `kr.co.vividnext.sodalive.v2.api.creator.channel.live` 하위 조립 계층에 둔다.
- 라이브, 다시듣기 콘텐츠, 시리즈/소장 상태처럼 재사용 가능한 조회 책임은 API 패키지 밖의 도메인 패키지에 둔다.
- 요청은 `creatorId`와 정렬 순서를 받는다.
- 정렬 순서를 보내지 않으면 최신순을 기본값으로 사용한다.
- 응답에는 `다시듣기` 콘텐츠 개수, 현재 진행 중인 라이브, `다시듣기` 콘텐츠 목록, 실제 적용된 정렬 순서를 포함한다.
- 현재 진행 중인 라이브 응답은 기존 `CreatorChannelLiveResponse`와 동일한 필드/의미를 사용한다.
- `다시듣기` 콘텐츠 응답은 기존 `CreatorChannelAudioContentResponse`에 유료 콘텐츠의 소장/대여 상태를 추가해 사용한다.
- `다시듣기` 콘텐츠는 기존 프로젝트에서 사용하는 `AudioContentTheme.theme == "다시듣기"` 기준을 따른다.
- 정렬 순서는 enum으로 정의해 공개 API 계약을 고정한다.
---
## 4. Non-Goals
- 이번 범위는 크리에이터 채널 `라이브` 탭 조회 API만 포함한다.
- 기존 크리에이터 채널 홈 API endpoint와 기존 응답 필드의 의미는 변경하지 않는다.
- 기존 크리에이터 채널 홈 API를 `v2.api.*` 조립 계층 + 도메인 패키지 구조로 옮기는 리팩토링은 이번 범위에서 구현하지 않는다.
- 라이브 생성, 예약, 입장, 종료 API는 포함하지 않는다.
- 오디오 콘텐츠 구매, 소장, 대여, 결제 API는 포함하지 않는다.
- `다시듣기` 테마명 관리 화면 또는 테마 마이그레이션은 포함하지 않는다.
- 앱 표시용 다국어 문구, 날짜 포맷, 가격 단위 표시는 서버에서 처리하지 않는다.
---
## 5. Target Users
- 회원: 크리에이터 채널 라이브 탭에서 현재 라이브와 다시듣기 콘텐츠를 탐색하는 사용자
- 앱 클라이언트: 라이브 탭 구성에 필요한 데이터를 단일 API 응답으로 표시하려는 클라이언트
- 크리에이터: 자신의 현재 라이브와 다시듣기 콘텐츠가 적절한 정렬로 노출되기를 원하는 사용자
---
## 6. User Stories
- 사용자는 크리에이터 채널 라이브 탭에 들어가면 현재 진행 중인 라이브가 있는지 바로 확인하고 싶다.
- 사용자는 크리에이터의 `다시듣기` 콘텐츠를 최신순으로 보고 싶다.
- 사용자는 인기순, 소장순, 높은 가격순, 낮은 가격순으로 `다시듣기` 콘텐츠를 바꿔 보고 싶다.
- 앱 클라이언트는 현재 적용된 정렬 순서를 응답에서 확인해 화면 상태와 서버 조회 결과를 맞추고 싶다.
- 앱 클라이언트는 `다시듣기` 콘텐츠 전체 개수를 받아 탭/헤더/빈 상태 UI에 표시하고 싶다.
---
## 7. Core Features
### Feature A. 크리에이터 채널 라이브 조회 API
#### Requirements
- 신규 API는 크리에이터 채널 전용 v2 API로 작성한다.
- 클라이언트 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.live` 하위에 작성한다.
- API 조립 계층은 필요한 도메인 조회 서비스를 호출해 라이브 탭 응답을 조립한다.
- API 조립 계층이 호출하는 도메인 조회 코드는 `kr.co.vividnext.sodalive.v2.live`, `kr.co.vividnext.sodalive.v2.content`, `kr.co.vividnext.sodalive.v2.series` 또는 채널 문맥이 필요한 경우 `kr.co.vividnext.sodalive.v2.creator.channel.live` 하위에 둔다.
- 도메인 패키지는 `kr.co.vividnext.sodalive.v2.api.*` 패키지에 의존하지 않는다.
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/live`를 기본안으로 한다.
- `creatorId`는 path variable로 받는다.
- 정렬 순서는 query parameter로 받는다.
- 정렬 순서 query parameter 이름은 `sort`를 기본안으로 한다.
- `sort`를 보내지 않으면 `LATEST`를 기본값으로 사용한다.
- `다시듣기` 콘텐츠 추가 로딩을 위해 `page`, `size` query parameter를 받는다.
- `page`는 0부터 시작하는 page index로 처리한다.
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
- API는 인증 회원만 조회할 수 있어야 한다.
- 비회원이 조회하면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
- 조회 대상 회원이 존재하지 않으면 기존 정책과 동일하게 `member.validation.user_not_found` 계열 오류를 반환한다.
- 조회 대상 회원이 크리에이터가 아니면 기존 정책과 동일하게 `member.validation.creator_not_found` 계열 오류를 반환한다.
- 조회자와 크리에이터 사이에 차단 관계가 있으면 구버전 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
- 현재 진행 중인 라이브가 없거나 `다시듣기` 콘텐츠가 없어도 전체 API는 성공 처리한다.
#### Edge Cases
- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다.
- 알 수 없는 `sort` 값은 Spring enum binding 실패 또는 기존 validation 오류 흐름에 맞춰 400 계열 오류로 처리한다.
- `page`가 0보다 작거나 `size`가 20보다 작거나 50보다 크면 기존 validation 오류 흐름에 맞춰 400 계열 오류로 처리한다.
### Feature B. 응답 스키마
#### Requirements
- 응답 DTO는 구현 전에 명시하고 공개 API 계약으로 관리한다.
- 응답 최상위 DTO 이름은 `CreatorChannelLiveTabResponse`를 기본안으로 한다.
- 응답에는 다음 값을 포함한다.
- `liveReplayContentCount`: `다시듣기` 카테고리 콘텐츠 전체 개수
- `currentLive`: 현재 진행 중인 라이브, 없으면 `null`
- `liveReplayContents`: `다시듣기` 콘텐츠 목록
- `sort`: 콘텐츠 조회에 실제 적용한 정렬 순서
- `page`: 현재 응답의 page index
- `size`: 현재 응답의 page size
- `hasNext`: 다음 page 존재 여부
- `currentLive`는 기존 `CreatorChannelLiveResponse`와 동일한 필드/의미를 사용한다.
- `liveReplayContents`의 각 item은 기존 `CreatorChannelAudioContentResponse`를 사용한다.
- `CreatorChannelAudioContentResponse`에는 다음 범위의 오디오 콘텐츠 조회 API에서도 재사용할 수 있도록 `isOwned`, `isRented`를 추가한다.
- `sort`는 요청값이 없으면 기본값 `LATEST`를 내려준다.
- `page`, `size`는 실제 적용된 값을 내려준다.
- `hasNext`는 같은 필터/정렬 조건에서 다음 page에 노출할 `다시듣기` 콘텐츠가 있으면 `true`로 내려준다.
- 응답 스키마 예시는 다음과 같다.
```kotlin
data class CreatorChannelLiveTabResponse(
val liveReplayContentCount: Int,
val currentLive: CreatorChannelLiveResponse?,
val liveReplayContents: List<CreatorChannelAudioContentResponse>,
val sort: ContentSort,
val page: Int,
val size: Int,
val hasNext: Boolean
)
data class CreatorChannelAudioContentResponse(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
val isAdult: Boolean,
val isPointAvailable: Boolean,
val isFirstContent: Boolean,
val seriesName: String?,
val isOriginalSeries: Boolean?,
val isOwned: Boolean,
val isRented: Boolean
)
enum class ContentSort {
LATEST,
POPULAR,
OWNED,
PRICE_HIGH,
PRICE_LOW
}
```
#### Edge Cases
- 현재 진행 중인 라이브가 없으면 `currentLive``null`로 내려준다.
- `다시듣기` 콘텐츠가 없으면 `liveReplayContentCount``0`, `liveReplayContents`는 빈 배열, `hasNext``false`로 내려준다.
### Feature C. 현재 진행 중인 라이브
#### Requirements
- 크리에이터가 현재 진행 중인 라이브를 내려준다.
- 현재 진행 중인 라이브는 기존 라이브 도메인의 `LiveRoomStatus.NOW` 의미와 동일하게 판단한다.
- 응답 필드는 기존 `CreatorChannelLiveResponse`와 동일하게 다음 값을 포함한다.
- `liveId`
- `title`
- `coverImageUrl`
- `beginDateTimeUtc`
- `price`
- `isAdult`
- 조회자의 성인 콘텐츠 노출 정책과 차단 정책을 반영한다.
- 현재 라이브 노출은 기존 라이브 목록 정책과 동일하게 성별 제한(`LiveRoom.genderRestriction`)과 크리에이터 입장 제한(`LiveRoom.isAvailableJoinCreator`)을 반영한다.
- 성별 제한 판단에 사용하는 조회자 성별은 기존 라이브 목록과 동일하게 `Auth.gender`가 있으면 이를 우선하고, 없으면 `Member.gender`를 사용하는 effective gender다.
#### Edge Cases
- 현재 진행 중인 라이브 후보가 여러 개이면 기존 라이브 목록/홈 API의 현재 라이브 선택 정책을 따른다.
- 성인 콘텐츠 노출 정책상 볼 수 없는 라이브만 있으면 `currentLive``null`로 내려준다.
### Feature D. `다시듣기` 콘텐츠 목록과 개수
#### Requirements
- `다시듣기` 콘텐츠는 `AudioContentTheme.theme == "다시듣기"`인 오디오 콘텐츠를 의미한다.
- `AudioContentTheme.isActive == true`인 테마만 대상으로 한다.
- 조회 대상은 지정한 `creatorId`의 콘텐츠로 제한한다.
- 공개된 콘텐츠만 조회한다.
- 예약 공개 전 콘텐츠는 포함하지 않는다.
- `releaseDate == null`인 오디오 콘텐츠는 삭제/미공개 데이터로 보고 조회에서 제외한다.
- 응답 item 필드는 기존 `CreatorChannelAudioContentResponse`와 동일하게 다음 값을 포함한다.
- `audioContentId`
- `title`
- `duration`
- `imageUrl`
- `price`
- `isAdult`
- `isPointAvailable`
- `isFirstContent`
- `seriesName`
- `isOriginalSeries`
- `CreatorChannelAudioContentResponse`에는 유료 콘텐츠 상태 표시를 위해 다음 값을 추가한다.
- `isOwned`: 조회자가 해당 콘텐츠를 소장 중이면 `true`
- `isRented`: 조회자가 해당 콘텐츠를 대여 중이고 대여 기간이 유효하면 `true`
- 무료 콘텐츠 또는 조회자가 구매/대여하지 않은 콘텐츠는 `isOwned == false`, `isRented == false`로 내려준다.
- 일반적으로 `isOwned == true``isRented == true`가 동시에 발생하지 않지만, 데이터상 동시에 유효한 소장/대여 주문이 있으면 둘 다 `true`로 내려준다.
- 콘텐츠 개수는 같은 필터를 적용한 `다시듣기` 콘텐츠 전체 개수로 계산한다.
- 콘텐츠 목록은 `page`, `size` 기준으로 페이징 조회한다.
- 기본 page size는 20개다.
- 클라이언트는 `hasNext == true`이면 같은 `creatorId`, `sort`, `size`와 다음 `page` 값으로 추가 로딩할 수 있어야 한다.
- 다음 page 존재 여부는 `size + 1`개를 조회하거나 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
- 목록 조회와 개수 조회는 성인 콘텐츠 노출 정책, 차단 정책, 공개 여부 필터가 서로 어긋나지 않아야 한다.
- 조회자의 성인 콘텐츠 노출 정책이 false이면 성인 콘텐츠는 목록과 개수에서 제외한다.
- `isFirstContent`, `seriesName`, `isOriginalSeries`, 구매/대여/포인트 사용 가능 여부의 의미는 기존 오디오 콘텐츠/시리즈 콘텐츠 응답과 동일하게 유지한다.
- `isFirstContent``다시듣기` 테마 안에서 첫 콘텐츠인지가 아니라, 크리에이터의 전체 공개 오디오 콘텐츠 중 첫 콘텐츠인지로 판단한다.
#### Edge Cases
- 시리즈에 속하지 않은 콘텐츠는 `seriesName`, `isOriginalSeries``null`로 내려준다.
- 공개된 `다시듣기` 콘텐츠가 없으면 빈 배열을 내려준다.
- 요청한 page 범위에 콘텐츠가 없으면 `liveReplayContents`는 빈 배열, `hasNext``false`로 내려주되 `liveReplayContentCount`는 전체 개수를 유지한다.
### Feature E. 콘텐츠 정렬
#### Requirements
- 정렬 순서는 enum으로 처리한다.
- enum 이름은 `ContentSort`를 기본안으로 한다.
- `ContentSort`는 크리에이터 채널에 한정하지 않고, 서비스 전반에서 콘텐츠 목록 정렬이 필요할 때 재사용할 수 있는 공용 정렬 enum으로 둔다.
- `ContentSort` 파일 위치는 구현 시 `kr.co.vividnext.sodalive.v2.common.domain.ContentSort`를 기본 후보로 한다.
- `ContentSort`는 라이브 탭의 `다시듣기` 콘텐츠뿐 아니라 다음 범위의 오디오 콘텐츠, 시리즈, 화보 등 콘텐츠형 목록에서 같은 정렬 의미를 공유한다.
- 공개 요청/응답 값은 다음을 사용한다.
- `LATEST`: 최신순, 기본값
- `POPULAR`: 인기순
- `OWNED`: 소장순
- `PRICE_HIGH`: 높은 가격순
- `PRICE_LOW`: 낮은 가격순
- `LATEST`는 공개일 최신순을 1차 정렬로 사용한다.
- `LATEST`의 2차 정렬은 높은 가격순이다.
- `LATEST`의 3차 정렬은 `audioContent.id desc`다.
- `POPULAR`은 매출이 많은 콘텐츠를 먼저 노출한다.
- `OWNED`는 조회자가 소장한 콘텐츠를 먼저 노출한다.
- `PRICE_HIGH`는 가격이 높은 콘텐츠를 먼저 노출한다.
- `PRICE_LOW`는 가격이 낮은 콘텐츠를 먼저 노출한다.
- `POPULAR`, `OWNED`, `PRICE_HIGH`, `PRICE_LOW`의 2차 정렬은 최신순이다.
- `POPULAR`, `OWNED`, `PRICE_HIGH`, `PRICE_LOW`의 3차 정렬은 `audioContent.id desc`다.
- 최신순 기준에 사용하는 날짜는 기존 `CreatorChannelAudioContentResponse` 목록 정책과 동일하게 공개 시각(`releaseDate`)을 기준으로 한다.
- 인기순의 매출은 대여/소장 여부와 관계없이 해당 콘텐츠에 순수하게 결제된 캔 매출 합계(`orders.can`)를 기준으로 하며, 포인트 사용액은 매출 기준에 포함하지 않는다.
- 환불되었거나 비활성 처리된 구매 내역은 기존 콘텐츠 구매/매출 정책과 동일하게 제외한다.
- 소장순은 조회자가 해당 콘텐츠를 유효하게 소장 또는 구매한 상태를 기준으로 한다.
- 같은 1차/2차 정렬 값을 가진 항목은 `audioContent.id desc`로 안정적으로 정렬한다.
#### Edge Cases
- 매출이 없는 콘텐츠의 인기순 매출값은 0으로 처리한다.
- 조회자가 소장한 콘텐츠가 없으면 `OWNED` 정렬은 최신순 + `audioContent.id desc` 보조 정렬과 같은 결과가 될 수 있다.
- 가격이 같은 콘텐츠는 각 정렬의 2차/3차 기준을 따른다.
---
## 8. Technical Constraints
- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다.
- Kotlin + Spring Boot 2.7.14 기존 스타일을 따른다.
- 신규 공개 API 스키마는 구현 전에 PRD와 구현 계획/TASK 문서에 명시한다.
- 기존 `CreatorChannelLiveResponse`, `CreatorChannelAudioContentResponse`와 필드/의미가 어긋나지 않도록 라이브 탭 API 응답 DTO를 작성한다.
- 라이브 탭 API의 `CreatorChannelAudioContentResponse`에는 `isOwned`, `isRented`를 포함하고, 다음 범위의 오디오 콘텐츠 조회 API에서도 같은 의미를 재사용할 수 있게 한다.
- 기존 크리에이터 채널 홈 API의 인증, 예외, 성인 콘텐츠 노출, 차단 관계 정책은 재사용하되, 신규 공개 API 파일 위치는 `v2.api.*` 조립 계층을 따른다.
- 기존 크리에이터 채널 홈 API가 `v2.creator.channel.adapter.in.web`에 위치한 것은 현재 구조의 예외로 보고, 이번 라이브 탭 구현에서는 같은 예외를 확장하지 않는다.
- 페이징 응답은 기존 v2 홈 추천 페이지 응답과 같은 `page`, `size`, `hasNext` 패턴을 따른다.
- `다시듣기` 테마명은 기존 코드의 문자열 상수와 중복되지 않도록 구현 시 공용 상수 또는 정책 객체로 관리하는 방안을 검토한다.
- `ContentSort`는 API binding, service 정책, 테스트에서 같은 타입을 사용한다.
---
## 9. Metrics
- 라이브 탭 API 성공/실패 건수
- 라이브 탭 API 응답 시간
- 정렬 순서별 요청 건수
- `currentLive`가 있는 응답 비율
- `다시듣기` 콘텐츠 개수와 실제 목록 노출 개수
---
## 10. Resolved Decisions
- 인기순 매출 기준은 대여/소장 여부와 관계없이 순수하게 결제된 캔 매출만 사용한다.
- 라이브 탭 신규 API는 기존 크리에이터 채널 홈 API 위치를 따라가지 않고, `v2.api.creator.channel.live` 공개 API 조립 계층으로 작성한다.
- 기존 크리에이터 채널 홈 API의 패키지 구조 정렬은 이번 라이브 탭 구현과 분리해 다음 범위에서 별도 리팩토링한다.
- `isOwned == true``isRented == true`가 동시에 발생할 가능성은 없지만, 만약 그런 상황이 발생하면 둘 다 `true`로 내려준다.

View File

@@ -0,0 +1,299 @@
# 크리에이터 채널 홈 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}/home`의 공개 계약을 보존하면서 홈 API 공개 조립 계층을 `v2.api.creator.channel.home`으로 옮기고 도메인 조회 계층을 API 패키지 밖으로 정렬한다.
**Architecture:** Controller, facade, response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.home` 하위에 두고, HTTP 계약과 공개 응답 변환만 담당한다. 조회 service, 순수 정책, domain model, port, repository는 `kr.co.vividnext.sodalive.v2.creator.channel.home` 하위에 두며 `v2.api.*`를 import하지 않는다. 기존 endpoint와 DTO 필드명은 그대로 유지하고, 기존 `v2.creator.channel.adapter.in.web.CreatorChannelHomeController`는 이동 후 남기지 않아 Spring mapping 충돌을 방지한다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper, ktlint
---
## 0. 구현 전 확정 사항
- 작업 성격: 동작 보존 리팩토링
- 기존 공개 endpoint: `GET /api/v2/creator-channels/{creatorId}/home`
- 기존 인증 정책: 인증 회원만 조회 가능, 비회원은 `common.error.bad_credentials` 계열 오류
- 공개 API 조립 패키지:
- `kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web`
- `kr.co.vividnext.sodalive.v2.api.creator.channel.home.application`
- `kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto`
- 도메인 조회 패키지:
- `kr.co.vividnext.sodalive.v2.creator.channel.home.application`
- `kr.co.vividnext.sodalive.v2.creator.channel.home.domain`
- `kr.co.vividnext.sodalive.v2.creator.channel.home.port.out`
- `kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence`
- 의존 방향: `v2.api.creator.channel.home -> v2.creator.channel.home`
- 금지 사항:
- endpoint 변경 금지
- 응답 필드명/의미 변경 금지
- 기능 추가 금지
- 라이브 탭 API 동작 변경 금지
- 불필요한 공용화 금지
## 1. 현재 공개 계약
현재 `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt` 기준 최상위 응답 필드는 아래와 같다. 구조 정렬 후에도 필드명과 의미를 유지한다.
```kotlin
data class CreatorChannelHomeResponse(
val creator: CreatorChannelCreatorResponse,
val currentLive: CreatorChannelLiveResponse?,
val latestAudioContent: CreatorChannelAudioContentResponse?,
val channelDonations: List<CreatorChannelDonationResponse>,
val notices: List<CreatorChannelCommunityPostResponse>,
val schedules: List<CreatorChannelScheduleResponse>,
val audioContents: List<CreatorChannelAudioContentResponse>,
val series: List<CreatorChannelSeriesResponse>,
val communities: List<CreatorChannelCommunityPostResponse>,
val fanTalk: CreatorChannelFanTalkSummaryResponse,
val introduce: String,
val activity: CreatorChannelActivityResponse,
val sns: CreatorChannelSnsResponse
)
```
아래 `@JsonProperty` 기반 boolean 필드명은 이동 후에도 유지한다.
- `creator.isAiChatAvailable`
- `creator.isDmAvailable`
- `creator.isFollow`
- `creator.isNotify`
- `currentLive.isAdult`
- `latestAudioContent.isAdult`
- `latestAudioContent.isPointAvailable`
- `latestAudioContent.isFirstContent`
- `latestAudioContent.isOriginalSeries`
- `latestAudioContent.isOwned`
- `latestAudioContent.isRented`
- `audioContents[*].isAdult`
- `audioContents[*].isPointAvailable`
- `audioContents[*].isFirstContent`
- `audioContents[*].isOriginalSeries`
- `audioContents[*].isOwned`
- `audioContents[*].isRented`
- `series[*].isNew`
- `series[*].isOriginal`
## 2. 파일 구조 계획
### 공개 API 조립 계층
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeController.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt`
### 도메인 조회 계층
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHome.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicy.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHomeQueryPolicy.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/CreatorChannelHomeQueryRepository.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/CreatorChannelHomeQueryRepository.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
### 테스트
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt`
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicyTest.kt` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHomeQueryPolicyTest.kt`
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
### 문서 산출물
- Modify: `docs/20260617_크리에이터_채널_홈_API_구조정렬/plan-task.md`
---
### Phase 1: 현재 계약 고정과 이동 전 실패 확인
- [x] **Task 1.1: controller 테스트를 새 API 패키지 기준으로 이동해 실패 확인**
- Files:
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeController.kt`
- RED: 테스트 package와 import를 새 controller 위치인 `kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeController` 기준으로 변경한다. 기존 endpoint `/api/v2/creator-channels/1/home`, 비회원 거부, 대표 JSON field path 검증은 유지한다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest`
- Expected: 새 controller 패키지가 아직 없어 컴파일 실패한다.
- GREEN: 아직 구현하지 않는다. 이 task는 이동 대상 controller 부재로 RED를 확인하는 단계다.
- REFACTOR: 없음.
- 기대 결과: 공개 API 조립 계층 이동 필요성이 테스트 실패로 고정된다.
- 검증 기록(2026-06-17): `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest` 실행 결과 `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt: (45, 13): Unresolved reference: CreatorChannelHomeController`로 실패했다. 새 API 패키지 controller가 아직 없어 실패한다는 RED 기대와 일치한다.
- [x] **Task 1.2: facade 테스트를 추가해 공개 응답 변환 책임을 고정**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt`
- RED: `CreatorChannelHomeFacade``CreatorChannelHomeQueryService.getHome(...)` 결과를 `CreatorChannelHomeResponse`로 변환하고 기존 필드명 의미를 유지하는지 검증하는 테스트를 작성한다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest`
- Expected: `CreatorChannelHomeFacade` 미존재로 컴파일 실패한다.
- GREEN: 아직 구현하지 않는다. 이 task는 facade 책임 부재로 RED를 확인하는 단계다.
- REFACTOR: 없음.
- 기대 결과: API 조립 계층이 service 대신 response DTO 변환 책임을 갖는다는 기준이 고정된다.
- 검증 기록(2026-06-17): `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest` 실행 결과 `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt: (32, 22): Unresolved reference: CreatorChannelHomeFacade`로 실패했다. facade가 아직 없어 실패한다는 RED 기대와 일치한다.
---
### Phase 2: 공개 API 조립 계층 이동
- [x] **Task 2.1: response DTO를 `v2.api.creator.channel.home.dto`로 이동**
- Files:
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
- RED: Task 1.1, Task 1.2에서 response DTO 새 package import 기준 컴파일 실패를 확인한 상태를 유지한다.
- GREEN: DTO 파일 package를 `kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto`로 변경하고, domain model import는 새 도메인 패키지 이동 전까지 기존 경로를 임시로 사용한다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest`
- Expected: facade가 아직 없으면 실패가 유지된다. DTO 자체 import 오류는 해결되어야 한다.
- REFACTOR: `@JsonProperty`가 이동 중 누락되지 않았는지 파일 diff로 확인한다.
- 기대 결과: 공개 응답 DTO가 API 조립 계층에 위치한다.
- 검증 기록(2026-06-17): `CreatorChannelHomeResponse.kt``kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto`로 이동하고 `CreatorChannelHomeQueryServiceTest`의 DTO import를 새 패키지로 갱신했다. 이후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest` 실행 결과 `BUILD SUCCESSFUL`로 기존 DTO 변환 회귀 테스트 통과를 확인했다.
- [x] **Task 2.2: `CreatorChannelHomeFacade`를 추가**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt`
- RED: Task 1.2의 facade 미존재 실패를 사용한다.
- GREEN: `CreatorChannelHomeFacade`를 추가하고 `CreatorChannelHomeQueryService`를 호출한 뒤 `CreatorChannelHomeResponse.from(...)`으로 변환한다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest`
- Expected: PASS
- REFACTOR: facade에 조회 정책이나 repository 접근이 들어가지 않았는지 확인한다.
- 기대 결과: 공개 API 조립 계층의 응답 변환 책임이 controller에서 facade로 이동한다.
- 검증 기록(2026-06-17): `CreatorChannelHomeFacade`를 추가해 기존 `CreatorChannelHomeQueryService.getHome(...)` 결과를 `CreatorChannelHomeResponse.from(...)`으로 변환하도록 구현했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest` 실행 결과 `BUILD SUCCESSFUL`로 facade 단위 테스트 통과를 확인했다.
- [x] **Task 2.3: controller를 `v2.api.creator.channel.home.adapter.in.web`으로 이동**
- Files:
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeController.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeController.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`
- RED: Task 1.1의 새 controller package 미존재 실패를 사용한다.
- GREEN: controller package를 변경하고 직접 `CreatorChannelHomeQueryService` 대신 `CreatorChannelHomeFacade`를 주입한다. `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/home")`, `requireMember` 동작은 유지한다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest`
- Expected: PASS
- REFACTOR: 기존 경로에 `CreatorChannelHomeController.kt`가 남아 있지 않은지 확인한다.
- Run: `rg -n "class CreatorChannelHomeController|/\\{creatorId\\}/home" src/main/kotlin/kr/co/vividnext/sodalive/v2`
- Expected: home controller mapping은 새 API 패키지 controller 1건만 확인된다.
- 기대 결과: Spring mapping 충돌 없이 홈 API controller가 API 조립 계층에 위치한다.
- 검증 기록(2026-06-17): `CreatorChannelHomeController``kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web`으로 이동하고 직접 query service 주입 대신 `CreatorChannelHomeFacade` 주입으로 변경했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest` 실행 결과 `BUILD SUCCESSFUL`로 controller 테스트 통과를 확인했다. `rg -n "class CreatorChannelHomeController|@GetMapping\(\"/\{creatorId\}/home\"\)|package kr\.co\.vividnext\.sodalive\.v2\.creator\.channel\.adapter\." src/main/kotlin/kr/co/vividnext/sodalive/v2` 실행 결과 home controller class와 `@GetMapping("/{creatorId}/home")`은 새 API 패키지 controller 1건만 확인했다.
---
### Phase 3: 도메인 조회 계층 패키지 정렬
- [x] **Task 3.1: domain model과 query policy를 `v2.creator.channel.home.domain`으로 이동**
- Files:
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHome.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicy.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHomeQueryPolicy.kt`
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicyTest.kt` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHomeQueryPolicyTest.kt`
- Modify: imports in moved API DTO, service, tests
- RED: 이동한 테스트 package를 새 domain package 기준으로 바꾼 뒤 기존 main class 미이동 상태의 컴파일 실패를 확인한다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHomeQueryPolicyTest`
- Expected: 새 domain package class 미존재로 컴파일 실패한다.
- GREEN: domain model과 policy package를 변경하고 import를 갱신한다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHomeQueryPolicyTest`
- Expected: PASS
- REFACTOR: domain model이 `kr.co.vividnext.sodalive.v2.api`를 import하지 않는지 확인한다.
- 기대 결과: 순수 domain 책임이 API 패키지 밖의 home 도메인 패키지에 위치한다.
- 검증 기록(2026-06-17): `CreatorChannelHomeQueryPolicyTest`를 새 `kr.co.vividnext.sodalive.v2.creator.channel.home.domain` package로 먼저 이동한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHomeQueryPolicyTest`를 실행해 `Unresolved reference: CreatorChannelHomeQueryPolicy` 컴파일 실패를 확인했다. 이후 `CreatorChannelHome.kt`, `CreatorChannelHomeQueryPolicy.kt`를 새 domain package로 이동하고 API DTO, service, 관련 테스트 import를 갱신했다. 같은 테스트 재실행 결과 `BUILD SUCCESSFUL`을 확인했고, domain package의 API import 및 기존 domain package import 검색 결과 0건을 확인했다.
- [x] **Task 3.2: port와 query service를 `v2.creator.channel.home` 하위로 이동**
- Files:
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt`
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt`
- RED: service 테스트 package와 imports를 새 경로로 바꾼 뒤 기존 main class 미이동 상태의 컴파일 실패를 확인한다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest`
- Expected: 새 service/port package class 미존재로 컴파일 실패한다.
- GREEN: service와 port package를 변경하고 API facade가 새 service package를 import하도록 갱신한다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest`
- Expected: PASS
- REFACTOR: service가 API DTO를 import하지 않는지 확인한다.
- 기대 결과: 도메인 application service가 API 조립 계층에 의존하지 않는다.
- 검증 기록(2026-06-17): `CreatorChannelHomeQueryServiceTest`를 새 `kr.co.vividnext.sodalive.v2.creator.channel.home.application` package로 먼저 이동한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest`를 실행해 `Unresolved reference: CreatorChannelHomeQueryService` 컴파일 실패를 확인했다. 이후 `CreatorChannelHomeQueryService.kt``CreatorChannelHomeQueryPort.kt`를 각각 새 application/port package로 이동하고 facade, repository adapter, 관련 테스트 import를 갱신했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했고, 기존 service/port package 참조 검색 결과 0건을 확인했다.
- [x] **Task 3.3: repository adapter를 `v2.creator.channel.home.adapter.out.persistence`로 이동**
- Files:
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/CreatorChannelHomeQueryRepository.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/CreatorChannelHomeQueryRepository.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- Modify: imports in service and tests
- RED: repository 테스트 package와 imports를 새 경로로 바꾼 뒤 기존 main class 미이동 상태의 컴파일 실패를 확인한다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- Expected: 새 repository package class 미존재로 컴파일 실패한다.
- GREEN: repository interface와 기본 구현체 package를 변경하고 port import를 새 경로로 갱신한다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- Expected: PASS
- REFACTOR: repository 조회 조건과 정렬 조건의 동작 변경이 diff에 포함되지 않았는지 확인한다.
- 기대 결과: persistence adapter가 home 도메인 패키지 하위에 위치하고 기존 조회 정책을 유지한다.
- 검증 기록(2026-06-17): `DefaultCreatorChannelHomeQueryRepositoryTest`를 새 `kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence` package로 먼저 이동한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`를 실행해 `Unresolved reference: DefaultCreatorChannelHomeQueryRepository` 컴파일 실패를 확인했다. 이후 repository interface/default 구현체를 새 persistence package로 이동하고 테스트의 hard-coded source path를 새 경로로 갱신했다. 같은 테스트 재실행 결과 Kotlin daemon fallback 경고 후 `BUILD SUCCESSFUL`을 확인했다. Phase 3 관련 5개 테스트 묶음 실행도 `BUILD SUCCESSFUL`로 통과했고, 기존 Phase 3 package 참조 검색 결과 0건을 확인했다.
---
### Phase 4: 의존 방향과 회귀 검증
- [x] **Task 4.1: 도메인 패키지의 API 패키지 의존 여부 확인**
- Files:
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/live`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/series`
- RED: 해당 없음. 검색 기반 검증 task다.
- TDD 예외 사유: package import 방향 검증은 실패 테스트보다 정적 검색이 더 직접적인 검증이다.
- 대체 검증 방법:
- Run: `rg -n "kr\\.co\\.vividnext\\.sodalive\\.v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator src/main/kotlin/kr/co/vividnext/sodalive/v2/live src/main/kotlin/kr/co/vividnext/sodalive/v2/content src/main/kotlin/kr/co/vividnext/sodalive/v2/series`
- Expected: 도메인 패키지에서 API 패키지 import 결과 0건
- GREEN: 검색 결과가 있으면 API DTO 의존을 제거하고 domain model 또는 port record 의존으로 되돌린다.
- REFACTOR: 라이브 탭 API 패키지는 이번 범위에서 동작 변경하지 않았는지 diff로 확인한다.
- 기대 결과: 의존 방향이 `v2.api.creator.channel.home -> 도메인 패키지`로 유지된다.
- 검증 기록(2026-06-17): 계획서의 전체 검색 명령은 `src/main/kotlin/kr/co/vividnext/sodalive/v2/live`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/content`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/series` 경로가 현재 작업트리에 없어 경로 오류로 중단됨을 확인했다. 실제 `src/main/kotlin/kr/co/vividnext/sodalive/v2` 하위 경로는 `admin`, `api`, `can`, `chat`, `common`, `creator`, `ranking`, `recommendation`, `usercreatorchat`이며, `live`, `content`, `series`는 현재 작업트리에 없다. 실제 존재하는 `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator` 경로 대상으로 `rg -n "kr\\.co\\.vividnext\\.sodalive\\.v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator`를 재실행했고 결과 0건으로 도메인 패키지의 API 패키지 의존이 없음을 확인했다.
- [x] **Task 4.2: 홈 API 관련 단위/통합 회귀 테스트 실행**
- Files:
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHomeQueryPolicyTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- RED: Phase 1부터 Phase 3의 실패 확인 기록을 유지한다.
- GREEN: 아래 테스트를 모두 통과시킨다.
- 통과 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- Expected: PASS
- REFACTOR: 실패가 있으면 동작 변경 없이 package/import/bean wiring 문제만 수정한다.
- 기대 결과: controller, facade, service, policy, repository 회귀 테스트가 모두 통과한다.
- 검증 기록(2026-06-17): `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- [x] **Task 4.3: ktlint와 문서 검증 기록 갱신**
- Files:
- Modify: `docs/20260617_크리에이터_채널_홈_API_구조정렬/plan-task.md`
- RED: 해당 없음. 포맷과 문서 기록 검증 task다.
- TDD 예외 사유: ktlint와 문서 기록은 구현 동작 테스트가 아니라 최종 품질 게이트다.
- 대체 검증 방법:
- Run: `./gradlew ktlintCheck`
- Expected: PASS
- Run: `./gradlew tasks --all`
- Expected: Gradle task 목록 출력 성공
- GREEN: 검증 결과를 각 task 아래와 하단 검증 기록에 한국어로 누적 기록한다.
- REFACTOR: `git diff --name-only`로 이번 범위 밖 파일 변경이 없는지 확인한다.
- 기대 결과: 포맷 검증과 문서 유지보수 검증 결과가 기록된다.
- 검증 기록(2026-06-17): `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. `./gradlew tasks --all` 실행 결과 Gradle task 목록 출력 후 `BUILD SUCCESSFUL`을 확인했다. `git diff --name-only` 실행 결과 `docs/20260617_크리에이터_채널_홈_API_구조정렬/plan-task.md` 1건만 출력되어 Phase 4 문서 범위 밖 변경이 없음을 확인했다.
---
## 3. 전체 검증 기록
- 문서 생성 검증(2026-06-17): `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md` 규칙에 따라 `docs/20260617_크리에이터_채널_홈_API_구조정렬/prd.md``docs/20260617_크리에이터_채널_홈_API_구조정렬/plan-task.md`를 생성했다.
- Gradle 명령 유효성 검증(2026-06-17): sandbox 내 `./gradlew tasks --all``~/.gradle` wrapper lock 파일 접근 권한 문제로 실패했다. 승인 후 동일 명령을 재실행해 `BUILD SUCCESSFUL`을 확인했다.
- Phase 1 RED 검증(2026-06-17): controller 테스트를 새 API 패키지로 이동하고 facade 테스트를 추가한 뒤 각 Gradle test filter를 실행했다. `CreatorChannelHomeController``CreatorChannelHomeFacade`의 새 API 패키지 production class 미존재로 `compileTestKotlin`이 실패해 Phase 1의 실패 확인 목표를 충족했다.
- Phase 3 패키지 정렬 검증(2026-06-17): domain, service/port, repository adapter를 `kr.co.vividnext.sodalive.v2.creator.channel.home` 하위로 순차 이동했다. 각 task에서 테스트 파일 선이동 RED를 확인한 뒤 production package/import를 갱신했고, `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- Phase 4 의존 방향 및 회귀 검증(2026-06-17): 실제 `src/main/kotlin/kr/co/vividnext/sodalive/v2` 하위 경로는 `admin`, `api`, `can`, `chat`, `common`, `creator`, `ranking`, `recommendation`, `usercreatorchat`이며, 계획서에 포함된 `live`, `content`, `series` 경로는 현재 작업트리에 없어 존재 경로 기준으로 검증 기록을 남겼다. `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator` 대상 API 패키지 의존 검색 결과 0건을 확인했다. 홈 API 관련 5개 테스트 묶음, `./gradlew ktlintCheck`, `./gradlew tasks --all`은 모두 `BUILD SUCCESSFUL`로 통과했다. `git diff --name-only` 결과 Phase 4 문서 범위인 `docs/20260617_크리에이터_채널_홈_API_구조정렬/plan-task.md` 1건만 변경됐음을 확인했다.

View File

@@ -0,0 +1,130 @@
# PRD: 크리에이터 채널 홈 API 구조 정렬
## 1. Overview
기존 `GET /api/v2/creator-channels/{creatorId}/home` API의 endpoint와 응답 계약을 유지하면서, 공개 API 조립 계층을 `kr.co.vividnext.sodalive.v2.api.creator.channel.home`으로 옮기고 재사용 가능한 조회/정책/port/repository 책임을 API 패키지 밖 도메인 패키지로 정렬한다.
---
## 2. Problem
- 기존 크리에이터 채널 홈 API는 controller와 response DTO가 `kr.co.vividnext.sodalive.v2.creator.channel` 하위에 있어, 현재 라이브 탭 API가 따르는 `v2.api.*` 공개 조립 계층 구조와 맞지 않는다.
- 라이브 탭 API는 `kr.co.vividnext.sodalive.v2.api.creator.channel.live``kr.co.vividnext.sodalive.v2.creator.channel.live`로 공개 API와 도메인 조회 책임을 분리했지만, 홈 API는 같은 v2 공개 API 설계와 패키지 경계가 어긋나 있다.
- 공개 API DTO가 도메인 패키지 안에 남아 있으면 도메인 패키지가 API 응답 계약을 소유하는 형태가 되어 이후 탭별 API 확장 시 의존 방향이 혼동될 수 있다.
- 구조 정렬 과정에서 기존 controller를 제거하지 않고 새 controller를 추가하면 `GET /api/v2/creator-channels/{creatorId}/home` mapping 충돌이 발생할 수 있다.
---
## 3. Goals
- 기존 홈 API endpoint `GET /api/v2/creator-channels/{creatorId}/home`을 유지한다.
- 기존 홈 API 응답 필드명과 필드 의미를 변경하지 않는다.
- 홈 API의 controller, facade, response DTO를 `kr.co.vividnext.sodalive.v2.api.creator.channel.home` 하위 공개 API 조립 계층으로 이동한다.
- 홈 API의 조회 service, 순수 정책, port, repository는 API 패키지 밖 도메인 패키지에 둔다.
- 도메인 패키지가 `kr.co.vividnext.sodalive.v2.api.*`에 의존하지 않도록 보장한다.
- 새 API controller 이동 시 기존 `v2.creator.channel.adapter.in.web.CreatorChannelHomeController`로 인한 Spring mapping 충돌이 없도록 기존 controller 제거 또는 이동 범위를 명확히 한다.
- 기존 홈 API controller, facade 또는 service, repository 회귀 테스트를 유지하고 새 패키지 구조에 맞게 이동한다.
- 검증 결과와 의존성 확인 결과를 `plan-task.md`에 누적 기록할 수 있게 한다.
---
## 4. Non-Goals
- 홈 API 기능 추가는 하지 않는다.
- 홈 API 응답 스키마 확장, 필드명 변경, 필드 의미 변경은 하지 않는다.
- 기존 공개 endpoint path, HTTP method, 인증 정책은 변경하지 않는다.
- 라이브 탭 API(`v2.api.creator.channel.live`, `v2.creator.channel.live`) 구현은 리팩토링 대상이 아니다.
- 오디오, 시리즈, 커뮤니티, 팬 Talk, 후원 탭별 전체보기 API는 이번 범위에 포함하지 않는다.
- 불필요한 공용화, 신규 추상화, 도메인 정책 재설계는 하지 않는다.
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
---
## 5. Target Users
- 앱 클라이언트: 기존 홈 API 계약을 그대로 호출하는 클라이언트
- 서버 개발자: v2 공개 API 조립 계층과 도메인 조회 계층의 의존 방향을 일관되게 유지해야 하는 개발자
- QA/릴리즈 담당자: 리팩토링 후 기존 홈 API 동작 회귀 여부를 확인해야 하는 담당자
---
## 6. User Stories
- 앱 클라이언트는 기존과 동일하게 `GET /api/v2/creator-channels/{creatorId}/home`을 호출하고 동일한 응답 필드와 의미를 받고 싶다.
- 서버 개발자는 홈 API controller와 response DTO가 `v2.api.creator.channel.home`에 있어 공개 API 조립 계층을 쉽게 찾고 싶다.
- 서버 개발자는 도메인 조회 service와 repository가 `v2.api.*`에 의존하지 않는다는 것을 검색 명령으로 확인하고 싶다.
- 서버 개발자는 기존 홈 API controller가 남아 새 controller와 mapping 충돌을 일으키지 않는지 테스트와 검색으로 확인하고 싶다.
---
## 7. Core Features
### Feature A. 공개 API 조립 계층 이동
#### Requirements
- `CreatorChannelHomeController``kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web` 하위로 이동한다.
- 홈 API facade는 `kr.co.vividnext.sodalive.v2.api.creator.channel.home.application` 하위에 둔다.
- `CreatorChannelHomeResponse`와 하위 response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto` 하위로 이동한다.
- controller는 기존 endpoint `GET /api/v2/creator-channels/{creatorId}/home`을 그대로 제공한다.
- controller는 기존과 동일하게 인증 회원을 요구하고, 비회원은 `common.error.bad_credentials` 계열 오류를 반환한다.
- facade는 공개 API 응답 DTO 조립 책임만 갖고 도메인 조회 service를 호출한다.
- 기존 `kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeController`는 남기지 않는다.
#### Edge Cases
- 새 controller와 기존 controller가 동시에 bean으로 등록되어 같은 path mapping을 제공하면 안 된다.
### Feature B. 도메인 조회 계층 정렬
#### Requirements
- 홈 API 조회 service, 순수 정책, domain model, port, repository는 `kr.co.vividnext.sodalive.v2.creator.channel.home` 하위로 정렬한다.
- 도메인 조회 계층은 API response DTO를 import하지 않는다.
- 도메인 조회 계층은 API facade나 controller를 import하지 않는다.
- 의존 방향은 항상 `v2.api.creator.channel.home -> v2.creator.channel.home`이다.
- repository는 기존 QueryDSL 조회 의미와 정책을 변경하지 않는다.
#### Edge Cases
- 라이브 탭 API의 `v2.api.creator.channel.live`, `v2.creator.channel.live` 패키지는 이번 구조 정렬 대상이 아니므로 동작 변경 없이 import 영향만 확인한다.
### Feature C. 공개 계약 보존 회귀 검증
#### Requirements
- 홈 API 최상위 응답 필드는 기존과 동일하게 유지한다.
- `creator`
- `currentLive`
- `latestAudioContent`
- `channelDonations`
- `notices`
- `schedules`
- `audioContents`
- `series`
- `communities`
- `fanTalk`
- `introduce`
- `activity`
- `sns`
- 기존 하위 DTO 필드명과 의미를 변경하지 않는다.
- controller 테스트는 기존 endpoint와 대표 JSON field path를 검증한다.
- facade 또는 service 테스트는 도메인 조회 결과가 기존 응답 DTO로 변환되는 흐름을 검증한다.
- repository 테스트는 기존 조회 정책 회귀를 유지한다.
- `./gradlew ktlintCheck`를 실행하고 결과를 계획 문서에 기록한다.
#### Edge Cases
- response DTO 패키지 이동으로 Jackson `@JsonProperty`가 누락되어 `is*` 필드명이 바뀌면 안 된다.
---
## 8. Technical Constraints
- 언어/런타임은 Kotlin + Java 17을 유지한다.
- 빌드와 검증은 Gradle Wrapper(`./gradlew`)를 사용한다.
- Spring Boot 2.7.14, JUnit 5, MockMvc, QueryDSL 기존 관례를 따른다.
- 패키지 구조는 `docs/agent-guides/코드스타일.md`의 공개 API 조립 계층과 도메인 패키지 의존 방향 규칙을 따른다.
- 테스트는 `docs/agent-guides/테스트스타일.md`의 RED, GREEN, REFACTOR 절차를 따른다.
- 문서와 검증 기록은 `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`를 따른다.
---
## 9. Metrics
- 홈 API endpoint와 응답 계약 회귀 테스트 통과 여부
- facade 또는 service 단위 테스트 통과 여부
- repository 단위 테스트 통과 여부
- `./gradlew ktlintCheck` 통과 여부
- 도메인 패키지의 `v2.api.*` import 검색 결과 0건 여부
---
## 10. Open Questions
- 없음. 이번 범위는 동작 보존 리팩토링이며, 응답 계약이나 기능 정책 변경은 포함하지 않는다.

View File

@@ -15,7 +15,8 @@
- 각 task의 검증 기준에는 단일 테스트 실행 명령과 필요한 경우 전체 회귀 명령을 포함한다.
- 구현 완료 즉시 해당 task 체크박스를 `- [x]`로 갱신한다.
- 작업 도중 범위가 변경되면 계획 문서 체크리스트를 먼저 업데이트한 뒤 구현한다.
- 결과 보고 시 문서 하단에 검증 기록(무엇/왜/어떻게, 실행 명령, 결과) 한국어로 남긴다.
- 결과 보고 시 개별 task 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)은 해당 task 아래에 한국어로 남긴다.
- 여러 task/phase에 걸친 회귀 검증, 전체 빌드/포맷 검증, 문서 변경 범위 확인처럼 전체에 해당하는 검증 기록은 문서 하단에 한국어로 남긴다.
- 후속 수정이 발생해도 기존 검증 기록은 삭제하거나 덮어쓰지 않고 누적한다.
- `build.gradle.kts` 변경 시 실행 명령 섹션을 함께 갱신한다.
- 테스트 클래스 추가/이동 시 단일 테스트 실행 예시를 최신 상태로 유지한다.

View File

@@ -17,4 +17,5 @@
- 변경 중: 공개 API 스키마를 임의 변경하지 말고, 작은 단위로 안전하게 수정한다.
- 변경 중: 구현 완료 즉시 해당 task 체크박스를 `- [x]`로 갱신한다.
- 변경 후: 최소 단일 테스트 또는 `./gradlew test`를 실행하고, 필요 시 `./gradlew ktlintCheck`를 수행한다.
- 변경 후: 계획 문서 하단에 무엇을, 왜, 어떻게 검증했는지, 실행 명령과 결과를 한국어로 누적 기록한다.
- 변경 후: 각 task의 검증 결과는 해당 task 아래에 무엇을, 왜, 어떻게 검증했는지, 실행 명령과 결과를 한국어로 누적 기록한다.
- 변경 후: 여러 task/phase에 걸친 회귀 검증, 전체 빌드/포맷 검증, 문서 변경 범위 확인처럼 전체에 해당하는 검증은 계획 문서 하단의 검증 기록에 누적한다.

View File

@@ -13,7 +13,7 @@
### Phase 2: 문서 규칙 갱신
- [x] **Task 2.1: PRD 문서에 후속 요구사항 누적**
- 파일 경로: `docs/prd/20260513_에이전트문서작업절차개선_prd.md`
- 검증 기준: 새 폴더 구조, phase/task 형식, 검증 기록 누적, 가이드 분리 요구사항이 포함된다.
- 검증 기준: 새 폴더 구조, phase/task 형식, task별 검증 기록과 전체 검증 기록 구분, 가이드 분리 요구사항이 포함된다.
- [x] **Task 2.2: AGENTS.md 핵심 링크 갱신**
- 파일 경로: `AGENTS.md`
- 검증 기준: 실행 명령어와 커밋 메시지 상세 규칙을 직접 중복하지 않고 별도 agent-guides 문서를 참조한다.
@@ -22,13 +22,20 @@
- 검증 기준: PRD 작성, 사용자 인터뷰, 계획/TASK 작성 후 구현, 범위 변경 시 계획 선갱신 절차가 포함된다.
- [x] **Task 2.4: 문서 유지보수 가이드 갱신**
- 파일 경로: `docs/agent-guides/문서유지보수.md`
- 검증 기준: `docs/[날짜]_구현할내용한글/prd.md`, `docs/[날짜]_구현할내용한글/plan-task.md`, phase/task 형식, 검증 기록 누적 규칙이 포함된다.
- 검증 기준: `docs/[날짜]_구현할내용한글/prd.md`, `docs/[날짜]_구현할내용한글/plan-task.md`, phase/task 형식, task별 검증 기록과 전체 검증 기록 구분 규칙이 포함된다.
- [x] **Task 2.5: 실행 명령어 가이드 분리**
- 파일 경로: `docs/agent-guides/실행명령어.md`
- 검증 기준: Gradle 실행 명령어가 별도 문서에 정리된다.
- [x] **Task 2.6: 커밋 메시지 가이드 분리**
- 파일 경로: `docs/agent-guides/커밋메시지.md`
- 검증 기준: 커밋 형식과 검증 절차가 별도 문서에 정리된다.
- [x] **Task 2.7: 검증 기록 위치 규칙 보강**
- 파일 경로: `docs/prd/20260513_에이전트문서작업절차개선_prd.md`, `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`, `docs/plan-task/20260513_에이전트문서작업절차개선.md`
- 검증 기준: 개별 task 검증 기록은 해당 task 아래에 남기고, 여러 task/phase 또는 전체에 해당하는 검증 기록은 문서 하단에 남긴다는 규칙이 포함된다.
- 검증 기록:
- 무엇을: PRD와 agent guide의 검증 기록 위치 규칙을 task별 기록과 전체 기록으로 분리했다.
- 왜: 검증 결과를 구현 단위 가까이에 두고, 하단 검증 기록은 전체 회귀와 교차 phase 검증 용도로 유지하기 위해서다.
- 어떻게: `rg -n "문서 하단|해당 task 아래|전체에 해당|검증 기록|검증 결과" docs/agent-guides/작업절차.md docs/agent-guides/문서유지보수.md docs/prd/20260513_에이전트문서작업절차개선_prd.md docs/plan-task/20260513_에이전트문서작업절차개선.md`로 반영 문구를 확인했다.
### Phase 3: 검증
- [x] **Task 3.1: 문서 변경 내용 확인**
@@ -39,6 +46,14 @@
- 파일 경로: `build.gradle.kts`, `settings.gradle.kts`
- 실행 명령: `./gradlew tasks --all`
- 기대 결과: Gradle task 목록 조회가 성공한다.
- [x] **Task 3.3: 검증 기록 위치 규칙 문서 변경 범위 확인**
- 파일 경로: `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`, `docs/prd/20260513_에이전트문서작업절차개선_prd.md`, `docs/plan-task/20260513_에이전트문서작업절차개선.md`
- 실행 명령: `git diff -- docs/agent-guides/작업절차.md docs/agent-guides/문서유지보수.md docs/prd/20260513_에이전트문서작업절차개선_prd.md docs/plan-task/20260513_에이전트문서작업절차개선.md`
- 기대 결과: 검증 기록 위치 규칙 관련 문서 변경만 포함된다.
- 검증 기록:
- 무엇을: 이번 후속 문서 변경의 diff 범위를 확인했다.
- 왜: 요청한 검증 기록 위치 규칙 외의 문서나 코드가 함께 변경되지 않았는지 확인하기 위해서다.
- 어떻게: `git diff -- docs/agent-guides/작업절차.md docs/agent-guides/문서유지보수.md docs/prd/20260513_에이전트문서작업절차개선_prd.md docs/plan-task/20260513_에이전트문서작업절차개선.md`로 변경 범위를 확인했다.
## 검증 기록
- 1차 PRD/계획 작성
@@ -53,3 +68,7 @@
- 무엇을: 문서 저장 규칙을 `docs/[날짜]_구현할내용한글/prd.md`, `docs/[날짜]_구현할내용한글/plan-task.md` 형식으로 변경하고, 계획/TASK phase 형식과 검증 기록 누적 규칙을 보강했다. 실행 명령어와 커밋 메시지 규칙은 각각 `docs/agent-guides/실행명령어.md`, `docs/agent-guides/커밋메시지.md`로 분리했다.
- 왜: 사용자 요청에 따라 구현 전 PRD/계획 문서 준비 절차를 더 명확히 하고, `AGENTS.md`의 상세 규칙 중복을 줄이기 위해서다.
- 어떻게: `git diff -- AGENTS.md docs/agent-guides docs/prd/20260513_에이전트문서작업절차개선_prd.md docs/plan-task/20260513_에이전트문서작업절차개선.md`로 요청 범위의 문서 변경을 확인했다. `./gradlew tasks --all`은 샌드박스에서 `~/.gradle` lock 파일 접근 권한 문제로 1차 실패했고, 권한 승격 후 재실행해 `BUILD SUCCESSFUL in 20s`를 확인했다.
- 4차 검증 기록 위치 규칙 수정 및 검증
- 무엇을: 개별 task 검증 기록은 해당 task 아래에 남기고, 여러 task/phase 또는 전체에 해당하는 검증 기록은 하단 검증 기록에 누적하도록 PRD와 agent guide를 갱신했다.
- 왜: 검증 결과를 구현 단위 가까이에 두어 추적성을 높이고, 하단 검증 기록은 전체 회귀와 교차 phase 검증 용도로 유지하기 위해서다.
- 어떻게: `rg -n "문서 하단|해당 task 아래|전체에 해당|검증 기록|검증 결과" docs/agent-guides/작업절차.md docs/agent-guides/문서유지보수.md docs/prd/20260513_에이전트문서작업절차개선_prd.md docs/plan-task/20260513_에이전트문서작업절차개선.md``git diff -- docs/agent-guides/작업절차.md docs/agent-guides/문서유지보수.md docs/prd/20260513_에이전트문서작업절차개선_prd.md docs/plan-task/20260513_에이전트문서작업절차개선.md`로 반영 내용과 변경 범위를 확인했다.

View File

@@ -19,7 +19,7 @@
- PRD와 구현 계획/TASK 문서 저장 위치를 `docs/[날짜]_구현할내용한글/`로 명확히 한다.
- PRD 파일명은 `prd.md`, 구현 계획/TASK 파일명은 `plan-task.md`로 고정한다.
- 애매한 요구사항은 PRD 단계에서 사용자 인터뷰를 반복해 해소하도록 명시한다.
- 구현 계획/TASK 문서의 phase, task 체크박스, 파일 경로, 검증 기준, 검증 기록 누적 규칙을 명확히 한다.
- 구현 계획/TASK 문서의 phase, task 체크박스, 파일 경로, 검증 기준, task별 검증 기록과 전체 검증 기록 누적 규칙을 명확히 한다.
- 실행 명령어와 커밋 메시지 규칙을 별도 `docs/agent-guides/` 문서로 분리한다.
---
@@ -60,7 +60,8 @@
- 각 phase 또는 task에는 실행 명령, 기대 결과, 수동 확인 항목 등 검증 기준을 함께 작성한다.
- 작업 도중 범위가 변경되면 계획 문서 체크리스트를 먼저 업데이트한 뒤 구현한다.
- 구현 완료 즉시 체크박스를 `- [x]`로 갱신한다.
- 결과 보고 시 문서 하단에 검증 기록(무엇/왜/어떻게, 실행 명령, 결과) 한국어로 남긴다.
- 개별 task 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)은 해당 task 아래에 한국어로 남긴다.
- 여러 task/phase에 걸친 회귀 검증, 전체 빌드/포맷 검증, 문서 변경 범위 확인처럼 전체에 해당하는 검증 기록은 문서 하단에 한국어로 남긴다.
- 후속 수정이 발생해도 기존 검증 기록은 삭제하거나 덮어쓰지 않고 누적한다.
### 상세 가이드 분리

View File

@@ -1,10 +1,9 @@
package kr.co.vividnext.sodalive.v2.creator.channel.adapter.`in`.web
package kr.co.vividnext.sodalive.v2.api.creator.channel.home.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.creator.channel.application.CreatorChannelHomeQueryService
import kr.co.vividnext.sodalive.v2.creator.channel.dto.CreatorChannelHomeResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacade
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
@@ -14,7 +13,7 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/creator-channels")
class CreatorChannelHomeController(
private val creatorChannelHomeQueryService: CreatorChannelHomeQueryService
private val creatorChannelHomeFacade: CreatorChannelHomeFacade
) {
@GetMapping("/{creatorId}/home")
fun getHome(
@@ -22,11 +21,9 @@ class CreatorChannelHomeController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
CreatorChannelHomeResponse.from(
creatorChannelHomeQueryService.getHome(
creatorId = creatorId,
viewer = requireMember(member)
)
creatorChannelHomeFacade.getHome(
creatorId = creatorId,
viewer = requireMember(member)
)
)
}

View File

@@ -0,0 +1,28 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.home.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto.CreatorChannelHomeResponse
import kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
@Transactional(readOnly = true)
class CreatorChannelHomeFacade(
private val creatorChannelHomeQueryService: CreatorChannelHomeQueryService
) {
fun getHome(
creatorId: Long,
viewer: Member,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelHomeResponse {
return CreatorChannelHomeResponse.from(
creatorChannelHomeQueryService.getHome(
creatorId = creatorId,
viewer = viewer,
now = now
)
)
}
}

View File

@@ -1,19 +1,19 @@
package kr.co.vividnext.sodalive.v2.creator.channel.dto
package kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCreator
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalk
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalkSummary
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHome
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelLive
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSchedule
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSeries
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSns
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCreator
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalk
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalkSummary
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHome
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelLive
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSchedule
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSeries
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSns
import java.time.LocalDateTime
import java.time.ZoneOffset
@@ -122,7 +122,11 @@ data class CreatorChannelAudioContentResponse(
val isFirstContent: Boolean,
val seriesName: String?,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean?
val isOriginalSeries: Boolean?,
@JsonProperty("isOwned")
val isOwned: Boolean,
@JsonProperty("isRented")
val isRented: Boolean
) {
companion object {
fun from(audioContent: CreatorChannelAudioContent): CreatorChannelAudioContentResponse {
@@ -136,7 +140,9 @@ data class CreatorChannelAudioContentResponse(
isPointAvailable = audioContent.isPointAvailable,
isFirstContent = audioContent.isFirstContent,
seriesName = audioContent.seriesName,
isOriginalSeries = audioContent.isOriginalSeries
isOriginalSeries = audioContent.isOriginalSeries,
isOwned = audioContent.isOwned,
isRented = audioContent.isRented
)
}
}

View File

@@ -0,0 +1,42 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.live.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.live.application.CreatorChannelLiveFacade
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
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 CreatorChannelLiveController(
private val creatorChannelLiveFacade: CreatorChannelLiveFacade
) {
@GetMapping("/{creatorId}/live")
fun getLiveTab(
@PathVariable creatorId: Long,
@RequestParam(defaultValue = "LATEST") sort: ContentSort,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
creatorChannelLiveFacade.getLiveTab(
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,35 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.live.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.creator.channel.live.dto.CreatorChannelLiveTabResponse
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
@Transactional(readOnly = true)
class CreatorChannelLiveFacade(
private val creatorChannelLiveQueryService: CreatorChannelLiveQueryService
) {
fun getLiveTab(
creatorId: Long,
viewer: Member,
sort: ContentSort,
page: Int,
size: Int,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelLiveTabResponse {
return CreatorChannelLiveTabResponse.from(
creatorChannelLiveQueryService.getLiveTab(
creatorId = creatorId,
viewer = viewer,
sort = sort,
page = page,
size = size,
now = now
)
)
}
}

View File

@@ -0,0 +1,101 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.live.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLive
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveTab
import java.time.LocalDateTime
import java.time.ZoneOffset
data class CreatorChannelLiveTabResponse(
val liveReplayContentCount: Int,
val currentLive: CreatorChannelLiveResponse?,
val liveReplayContents: List<CreatorChannelAudioContentResponse>,
val sort: ContentSort,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(tab: CreatorChannelLiveTab): CreatorChannelLiveTabResponse {
return CreatorChannelLiveTabResponse(
liveReplayContentCount = tab.liveReplayContentCount,
currentLive = tab.currentLive?.let(CreatorChannelLiveResponse::from),
liveReplayContents = tab.liveReplayContents.map(CreatorChannelAudioContentResponse::from),
sort = tab.sort,
page = tab.page.page,
size = tab.page.size,
hasNext = tab.hasNext
)
}
}
}
data class CreatorChannelAudioContentResponse(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
val seriesName: String?,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean?,
@JsonProperty("isOwned")
val isOwned: Boolean,
@JsonProperty("isRented")
val isRented: Boolean
) {
companion object {
fun from(content: CreatorChannelAudioContent): CreatorChannelAudioContentResponse {
return CreatorChannelAudioContentResponse(
audioContentId = content.audioContentId,
title = content.title,
duration = content.duration,
imageUrl = content.imageUrl,
price = content.price,
isAdult = content.isAdult,
isPointAvailable = content.isPointAvailable,
isFirstContent = content.isFirstContent,
seriesName = content.seriesName,
isOriginalSeries = content.isOriginalSeries,
isOwned = content.isOwned,
isRented = content.isRented
)
}
}
}
data class CreatorChannelLiveResponse(
val liveId: Long,
val title: String,
val coverImageUrl: String?,
val beginDateTimeUtc: String,
val price: Int,
@JsonProperty("isAdult")
val isAdult: Boolean
) {
companion object {
fun from(live: CreatorChannelLive): CreatorChannelLiveResponse {
return CreatorChannelLiveResponse(
liveId = live.liveId,
title = live.title,
coverImageUrl = live.coverImageUrl,
beginDateTimeUtc = live.beginDateTime.toUtcIso(),
price = live.price,
isAdult = live.isAdult
)
}
}
}
private fun LocalDateTime.toUtcIso(): String {
return atOffset(ZoneOffset.UTC).toInstant().toString()
}

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.v2.common.domain
enum class ContentSort {
LATEST,
POPULAR,
OWNED,
PRICE_HIGH,
PRICE_LOW
}

View File

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

View File

@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelHomeQueryPort
interface CreatorChannelHomeQueryRepository : CreatorChannelHomeQueryPort

View File

@@ -1,4 +1,4 @@
package kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence
package kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence
import com.querydsl.core.types.Projections
import com.querydsl.core.types.dsl.BooleanExpression
@@ -9,6 +9,8 @@ import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
import kr.co.vividnext.sodalive.chat.character.QChatCharacter.chatCharacter
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.order.QOrder.order
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.explorer.profile.QCreatorCheers.creatorCheers
@@ -26,17 +28,17 @@ import kr.co.vividnext.sodalive.member.QMember.member
import kr.co.vividnext.sodalive.member.block.QBlockMember
import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelActivityRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCommunityPostRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelDonationRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkSummaryRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelLiveRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelScheduleRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSeriesRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSnsRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelActivityRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelCommunityPostRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelDonationRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelFanTalkRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelFanTalkSummaryRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelLiveRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelScheduleRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelSeriesRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelSnsRecord
import org.springframework.stereotype.Repository
import java.time.Duration
import java.time.LocalDateTime
@@ -155,12 +157,15 @@ class DefaultCreatorChannelHomeQueryRepository(
override fun findLatestAudioContent(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean
canViewAdultContent: Boolean,
viewerId: Long?
): CreatorChannelAudioContentRecord? {
val row = findAudioContentRows(creatorId, now, null, canViewAdultContent, 1).firstOrNull() ?: return null
val contentId = itAudioId(row)
return row.toAudioRecord(
firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent),
seriesByContentId = audioSeriesByContentIds(listOf(itAudioId(row)))
seriesByContentId = audioSeriesByContentIds(listOf(contentId)),
orderStatesByContentId = orderStatesByContentIds(viewerId, listOf(contentId), now)
)
}
@@ -351,12 +356,15 @@ class DefaultCreatorChannelHomeQueryRepository(
now: LocalDateTime,
latestAudioContentId: Long?,
canViewAdultContent: Boolean,
viewerId: Long?,
limit: Int
): List<CreatorChannelAudioContentRecord> {
val rows = findAudioContentRows(creatorId, now, latestAudioContentId, canViewAdultContent, limit)
val contentIds = rows.map { itAudioId(it) }
val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent)
val seriesByContentId = audioSeriesByContentIds(rows.map { itAudioId(it) })
return rows.map { it.toAudioRecord(firstContentId, seriesByContentId) }
val seriesByContentId = audioSeriesByContentIds(contentIds)
val orderStatesByContentId = orderStatesByContentIds(viewerId, contentIds, now)
return rows.map { it.toAudioRecord(firstContentId, seriesByContentId, orderStatesByContentId) }
}
override fun findSeries(
@@ -549,10 +557,12 @@ class DefaultCreatorChannelHomeQueryRepository(
private fun com.querydsl.core.Tuple.toAudioRecord(
firstContentId: Long?,
seriesByContentId: Map<Long, AudioSeriesSummary>
seriesByContentId: Map<Long, AudioSeriesSummary>,
orderStatesByContentId: Map<Long, AudioOrderState>
): CreatorChannelAudioContentRecord {
val audioContentId = get(audioContent.id)!!
val seriesSummary = seriesByContentId[audioContentId]
val orderState = orderStatesByContentId[audioContentId]
return CreatorChannelAudioContentRecord(
audioContentId = audioContentId,
title = get(audioContent.title)!!,
@@ -564,10 +574,39 @@ class DefaultCreatorChannelHomeQueryRepository(
isFirstContent = firstContentId == audioContentId,
publishedAt = get(audioContent.releaseDate)!!,
seriesName = seriesSummary?.title,
isOriginalSeries = seriesSummary?.isOriginal
isOriginalSeries = seriesSummary?.isOriginal,
isOwned = orderState?.isOwned ?: false,
isRented = orderState?.isRented ?: false
)
}
private fun orderStatesByContentIds(
viewerId: Long?,
contentIds: List<Long>,
now: LocalDateTime
): Map<Long, AudioOrderState> {
if (viewerId == null || contentIds.isEmpty()) return emptyMap()
return queryFactory
.select(order.audioContent.id, order.type)
.from(order)
.where(
order.member.id.eq(viewerId),
order.audioContent.id.`in`(contentIds),
order.isActive.isTrue,
order.type.eq(OrderType.KEEP)
.or(order.type.eq(OrderType.RENTAL).and(order.endDate.after(now)))
)
.fetch()
.groupBy { it.get(order.audioContent.id)!! }
.mapValues { (_, rows) ->
val types = rows.map { it.get(order.type)!! }.toSet()
AudioOrderState(
isOwned = OrderType.KEEP in types,
isRented = OrderType.RENTAL in types
)
}
}
private fun audioSeriesByContentIds(contentIds: List<Long>): Map<Long, AudioSeriesSummary> {
if (contentIds.isEmpty()) return emptyMap()
return queryFactory
@@ -908,6 +947,11 @@ class DefaultCreatorChannelHomeQueryRepository(
val isOriginal: Boolean
)
private data class AudioOrderState(
val isOwned: Boolean,
val isRented: Boolean
)
private data class SeriesContentStats(
val contentCount: Int,
val latestPublishedAt: LocalDateTime

View File

@@ -1,4 +1,4 @@
package kr.co.vividnext.sodalive.v2.creator.channel.application
package kr.co.vividnext.sodalive.v2.creator.channel.home.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.LangContext
@@ -8,31 +8,31 @@ 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.creator.channel.domain.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCreator
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalk
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalkSummary
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHome
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelLive
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSchedule
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSeries
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSns
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelActivityRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCommunityPostRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelDonationRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkSummaryRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelHomeQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelLiveRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelScheduleRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSeriesRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSnsRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCreator
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalk
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalkSummary
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHome
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHomeQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelLive
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSchedule
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSeries
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSns
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelActivityRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelCommunityPostRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelDonationRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelFanTalkRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelFanTalkSummaryRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelHomeQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelLiveRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelScheduleRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelSeriesRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelSnsRecord
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -72,14 +72,15 @@ class CreatorChannelHomeQueryService(
val isViewerCreator = viewerId == creatorId
val effectiveViewerGender = viewer.effectiveGender()
val latestAudioContent = queryPort
.findLatestAudioContent(creatorId, now, canViewAdultContent)
.findLatestAudioContent(creatorId, now, canViewAdultContent, viewerId)
?.toDomain()
val audioContents = queryPolicy.excludeLatestAudioContent(
queryPort.findAudioContents(
creatorId = creatorId,
now = now,
latestAudioContentId = latestAudioContent?.audioContentId,
canViewAdultContent = canViewAdultContent
canViewAdultContent = canViewAdultContent,
viewerId = viewerId
).map { it.toDomain() },
latestAudioContent?.audioContentId
)
@@ -179,7 +180,9 @@ class CreatorChannelHomeQueryService(
isFirstContent = isFirstContent,
publishedAt = publishedAt,
seriesName = seriesName,
isOriginalSeries = isOriginalSeries
isOriginalSeries = isOriginalSeries,
isOwned = isOwned,
isRented = isRented
)
private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation(

View File

@@ -1,4 +1,4 @@
package kr.co.vividnext.sodalive.v2.creator.channel.domain
package kr.co.vividnext.sodalive.v2.creator.channel.home.domain
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import java.time.LocalDateTime
@@ -51,7 +51,9 @@ data class CreatorChannelAudioContent(
val isFirstContent: Boolean,
val publishedAt: LocalDateTime,
val seriesName: String?,
val isOriginalSeries: Boolean?
val isOriginalSeries: Boolean?,
val isOwned: Boolean,
val isRented: Boolean
)
data class CreatorChannelDonation(

View File

@@ -1,4 +1,4 @@
package kr.co.vividnext.sodalive.v2.creator.channel.domain
package kr.co.vividnext.sodalive.v2.creator.channel.home.domain
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import org.springframework.stereotype.Component

View File

@@ -1,4 +1,4 @@
package kr.co.vividnext.sodalive.v2.creator.channel.port.out
package kr.co.vividnext.sodalive.v2.creator.channel.home.port.out
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Gender
@@ -23,7 +23,8 @@ interface CreatorChannelHomeQueryPort {
fun findLatestAudioContent(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean
canViewAdultContent: Boolean,
viewerId: Long? = null
): CreatorChannelAudioContentRecord?
fun findChannelDonations(
@@ -56,6 +57,7 @@ interface CreatorChannelHomeQueryPort {
now: LocalDateTime,
latestAudioContentId: Long?,
canViewAdultContent: Boolean,
viewerId: Long? = null,
limit: Int = 9
): List<CreatorChannelAudioContentRecord>
@@ -109,7 +111,9 @@ data class CreatorChannelAudioContentRecord(
val isFirstContent: Boolean,
val publishedAt: LocalDateTime,
val seriesName: String?,
val isOriginalSeries: Boolean?
val isOriginalSeries: Boolean?,
val isOwned: Boolean,
val isRented: Boolean
)
data class CreatorChannelDonationRecord(

View File

@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelLiveQueryPort
interface CreatorChannelLiveQueryRepository : CreatorChannelLiveQueryPort

View File

@@ -0,0 +1,372 @@
package kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence
import com.querydsl.core.Tuple
import com.querydsl.core.types.Projections
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.order.QOrder
import kr.co.vividnext.sodalive.content.order.QOrder.order
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
import kr.co.vividnext.sodalive.live.room.GenderRestriction
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
import kr.co.vividnext.sodalive.member.Gender
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.live.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelLiveRecord
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
@Repository
class DefaultCreatorChannelLiveQueryRepository(
private val queryFactory: JPAQueryFactory
) : CreatorChannelLiveQueryRepository {
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? {
return queryFactory
.select(
Projections.constructor(
CreatorChannelCreatorRecord::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("creatorChannelLiveBlockMember")
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 findCurrentLive(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean,
viewerId: Long?,
isViewerCreator: Boolean,
effectiveViewerGender: Gender?
): CreatorChannelLiveRecord? {
return queryFactory
.select(
Projections.constructor(
CreatorChannelLiveRecord::class.java,
liveRoom.id,
liveRoom.title,
liveRoom.coverImage,
liveRoom.beginDateTime,
liveRoom.price,
liveRoom.isAdult
)
)
.from(liveRoom)
.where(
liveRoom.member.id.eq(creatorId),
liveRoom.member.isActive.isTrue,
liveRoom.isActive.isTrue,
liveRoom.channelName.isNotNull,
liveRoom.channelName.isNotEmpty,
liveRoom.beginDateTime.loe(now),
adultLiveCondition(canViewAdultContent),
genderLiveCondition(viewerId, effectiveViewerGender),
creatorJoinLiveCondition(viewerId, isViewerCreator)
)
.orderBy(liveRoom.beginDateTime.desc(), liveRoom.id.desc())
.fetchFirst()
}
override fun countLiveReplayAudioContents(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int {
return queryFactory
.select(audioContent.id.count())
.from(audioContent)
.innerJoin(audioContent.theme, audioContentTheme)
.where(liveReplayAudioCondition(creatorId, now, canViewAdultContent))
.fetchOne()
?.toInt()
?: 0
}
override fun findLiveReplayAudioContents(
creatorId: Long,
viewerId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean,
sort: ContentSort,
offset: Long,
limit: Int
): List<CreatorChannelAudioContentRecord> {
val rows = findLiveReplayAudioRows(creatorId, viewerId, now, canViewAdultContent, sort, offset, limit)
val contentIds = rows.map { itAudioId(it) }
val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent)
val seriesByContentId = audioSeriesByContentIds(contentIds)
val orderStatesByContentId = orderStatesByContentIds(viewerId, contentIds, now)
return rows
.map { it.toAudioRecord(firstContentId, seriesByContentId, orderStatesByContentId) }
}
private fun findLiveReplayAudioRows(
creatorId: Long,
viewerId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean,
sort: ContentSort,
offset: Long,
limit: Int
): List<Tuple> {
val query = queryFactory
.select(
audioContent.id,
audioContent.title,
audioContent.duration,
audioContent.coverImage,
audioContent.price,
audioContent.isAdult,
audioContent.isPointAvailable,
audioContent.releaseDate
)
.from(audioContent)
.innerJoin(audioContent.theme, audioContentTheme)
.where(liveReplayAudioCondition(creatorId, now, canViewAdultContent))
when (sort) {
ContentSort.POPULAR -> {
val revenueOrder = QOrder("liveReplayRevenueOrder")
query
.leftJoin(revenueOrder)
.on(
revenueOrder.audioContent.id.eq(audioContent.id),
revenueOrder.isActive.isTrue
)
.groupBy(
audioContent.id,
audioContent.title,
audioContent.duration,
audioContent.coverImage,
audioContent.price,
audioContent.isAdult,
audioContent.isPointAvailable,
audioContent.releaseDate
)
.orderBy(
revenueOrder.can.sum().coalesce(0).desc(),
audioContent.releaseDate.desc(),
audioContent.id.desc()
)
}
ContentSort.OWNED -> {
val ownedOrder = QOrder("liveReplayOwnedOrder")
query
.leftJoin(ownedOrder)
.on(
ownedOrder.audioContent.id.eq(audioContent.id),
ownedOrder.member.id.eq(viewerId ?: -1L),
ownedOrder.isActive.isTrue,
ownedOrder.type.eq(OrderType.KEEP)
)
.groupBy(
audioContent.id,
audioContent.title,
audioContent.duration,
audioContent.coverImage,
audioContent.price,
audioContent.isAdult,
audioContent.isPointAvailable,
audioContent.releaseDate
)
.orderBy(
ownedOrder.id.count().desc(),
audioContent.releaseDate.desc(),
audioContent.id.desc()
)
}
ContentSort.LATEST -> query.orderBy(
audioContent.releaseDate.desc(),
audioContent.price.desc(),
audioContent.id.desc()
)
ContentSort.PRICE_HIGH -> query.orderBy(
audioContent.price.desc(),
audioContent.releaseDate.desc(),
audioContent.id.desc()
)
ContentSort.PRICE_LOW -> query.orderBy(
audioContent.price.asc(),
audioContent.releaseDate.desc(),
audioContent.id.desc()
)
}
return query
.offset(offset)
.limit(limit.toLong())
.fetch()
}
private fun liveReplayAudioCondition(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean
): BooleanExpression {
return audioContent.member.id.eq(creatorId)
.and(audioContent.member.isActive.isTrue)
.and(audioContent.isActive.isTrue)
.and(audioContentTheme.isActive.isTrue)
.and(audioContentTheme.theme.eq(LIVE_REPLAY_THEME))
.and(audioContent.duration.isNotNull)
.and(audioContent.releaseDate.isNotNull)
.and(audioContent.releaseDate.loe(now))
.and(adultAudioCondition(canViewAdultContent))
}
private fun itAudioId(row: Tuple): Long = row.get(audioContent.id)!!
private fun Tuple.toAudioRecord(
firstContentId: Long?,
seriesByContentId: Map<Long, AudioSeriesSummary>,
orderStatesByContentId: Map<Long, AudioOrderState>
): CreatorChannelAudioContentRecord {
val audioContentId = get(audioContent.id)!!
val seriesSummary = seriesByContentId[audioContentId]
val orderState = orderStatesByContentId[audioContentId]
return CreatorChannelAudioContentRecord(
audioContentId = audioContentId,
title = get(audioContent.title)!!,
duration = get(audioContent.duration),
imagePath = get(audioContent.coverImage),
price = get(audioContent.price)!!,
isAdult = get(audioContent.isAdult)!!,
isPointAvailable = get(audioContent.isPointAvailable)!!,
isFirstContent = firstContentId == audioContentId,
publishedAt = get(audioContent.releaseDate)!!,
seriesName = seriesSummary?.title,
isOriginalSeries = seriesSummary?.isOriginal,
isOwned = orderState?.isOwned ?: false,
isRented = orderState?.isRented ?: false
)
}
private fun firstAudioContentId(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean
): Long? {
return queryFactory
.select(audioContent.id)
.from(audioContent)
.where(
audioContent.member.id.eq(creatorId),
audioContent.member.isActive.isTrue,
audioContent.isActive.isTrue,
audioContent.duration.isNotNull,
audioContent.releaseDate.isNotNull,
audioContent.releaseDate.loe(now),
adultAudioCondition(canViewAdultContent)
)
.orderBy(audioContent.releaseDate.asc(), audioContent.id.asc())
.fetchFirst()
}
private fun audioSeriesByContentIds(contentIds: List<Long>): Map<Long, AudioSeriesSummary> {
if (contentIds.isEmpty()) return emptyMap()
return queryFactory
.select(seriesContent.content.id, series.title, series.isOriginal)
.from(seriesContent)
.innerJoin(seriesContent.series, series)
.where(seriesContent.content.id.`in`(contentIds))
.fetch()
.associate {
it.get(seriesContent.content.id)!! to AudioSeriesSummary(
title = it.get(series.title)!!,
isOriginal = it.get(series.isOriginal)!!
)
}
}
private fun orderStatesByContentIds(
viewerId: Long?,
contentIds: List<Long>,
now: LocalDateTime
): Map<Long, AudioOrderState> {
if (viewerId == null || contentIds.isEmpty()) return emptyMap()
return queryFactory
.select(order.audioContent.id, order.type)
.from(order)
.where(
order.member.id.eq(viewerId),
order.audioContent.id.`in`(contentIds),
order.isActive.isTrue,
order.type.eq(OrderType.KEEP)
.or(order.type.eq(OrderType.RENTAL).and(order.endDate.after(now)))
)
.fetch()
.groupBy { it.get(order.audioContent.id)!! }
.mapValues { (_, rows) ->
val types = rows.map { it.get(order.type)!! }.toSet()
AudioOrderState(
isOwned = OrderType.KEEP in types,
isRented = OrderType.RENTAL in types
)
}
}
private fun adultLiveCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else liveRoom.isAdult.isFalse
}
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else audioContent.isAdult.isFalse
}
private fun genderLiveCondition(viewerId: Long?, effectiveViewerGender: Gender?): BooleanExpression? {
if (effectiveViewerGender == null || effectiveViewerGender == Gender.NONE) return null
val genderCondition = when (effectiveViewerGender) {
Gender.MALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.MALE_ONLY)
Gender.FEMALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.FEMALE_ONLY)
Gender.NONE -> return null
}
return viewerId?.let { genderCondition.or(liveRoom.member.id.eq(it)) } ?: genderCondition
}
private fun creatorJoinLiveCondition(viewerId: Long?, isViewerCreator: Boolean): BooleanExpression? {
if (!isViewerCreator || viewerId == null) return null
return liveRoom.isAvailableJoinCreator.isTrue.or(liveRoom.member.id.eq(viewerId))
}
private data class AudioSeriesSummary(
val title: String,
val isOriginal: Boolean
)
private data class AudioOrderState(
val isOwned: Boolean,
val isRented: Boolean
)
private companion object {
const val LIVE_REPLAY_THEME = "다시듣기"
}
}

View File

@@ -0,0 +1,137 @@
package kr.co.vividnext.sodalive.v2.creator.channel.live.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Gender
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.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLive
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveTab
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelLiveQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelLiveRecord
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 CreatorChannelLiveQueryService(
private val queryPortProvider: ObjectProvider<CreatorChannelLiveQueryPort>,
private val queryPolicy: CreatorChannelLiveReplayQueryPolicy,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val messageSource: SodaMessageSource,
private val langContext: LangContext,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
fun getLiveTab(
creatorId: Long,
viewer: Member,
sort: ContentSort,
page: Int,
size: Int,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelLiveTab {
val livePage = 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 isViewerCreator = viewerId == creatorId
val effectiveViewerGender = viewer.effectiveGender()
val fetchedContents = queryPort.findLiveReplayAudioContents(
creatorId = creatorId,
viewerId = viewerId,
now = now,
canViewAdultContent = canViewAdultContent,
sort = sort,
offset = livePage.offset,
limit = livePage.fetchLimit
)
return CreatorChannelLiveTab(
liveReplayContentCount = queryPort.countLiveReplayAudioContents(
creatorId = creatorId,
now = now,
canViewAdultContent = canViewAdultContent
),
currentLive = queryPort.findCurrentLive(
creatorId = creatorId,
now = now,
canViewAdultContent = canViewAdultContent,
viewerId = viewerId,
isViewerCreator = isViewerCreator,
effectiveViewerGender = effectiveViewerGender
)?.toDomain(),
liveReplayContents = queryPolicy.limitItems(fetchedContents, livePage).map { it.toDomain() },
sort = sort,
page = livePage,
hasNext = queryPolicy.hasNext(fetchedContents, livePage)
)
}
private fun validateCreatorRole(creator: CreatorChannelCreatorRecord) {
when (creator.role) {
MemberRole.CREATOR -> return
else -> throw SodaException(messageKey = "member.validation.creator_not_found")
}
}
private fun Member.effectiveGender(): Gender {
auth?.let { return if (it.gender == 1) Gender.MALE else Gender.FEMALE }
return gender
}
private fun CreatorChannelLiveRecord.toDomain() = CreatorChannelLive(
liveId = liveId,
title = title,
coverImageUrl = coverImagePath.toCdnUrl(),
beginDateTime = beginDateTime,
price = price,
isAdult = isAdult
)
private fun CreatorChannelAudioContentRecord.toDomain() = CreatorChannelAudioContent(
audioContentId = audioContentId,
title = title,
duration = duration,
imageUrl = imagePath.toCdnUrl(),
price = price,
isAdult = isAdult,
isPointAvailable = isPointAvailable,
isFirstContent = isFirstContent,
publishedAt = publishedAt,
seriesName = seriesName,
isOriginalSeries = isOriginalSeries,
isOwned = isOwned,
isRented = isRented
)
private fun String?.toCdnUrl(): String? {
if (isNullOrBlank()) return null
if (startsWith("https://") || startsWith("http://")) return this
return "$cloudFrontHost/$this"
}
}

View File

@@ -0,0 +1,28 @@
package kr.co.vividnext.sodalive.v2.creator.channel.live.domain
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.stereotype.Component
@Component
class CreatorChannelLiveReplayQueryPolicy {
fun createPage(page: Int, size: Int): CreatorChannelPage {
if (page < MIN_PAGE || size < MIN_PAGE_SIZE || size > MAX_PAGE_SIZE) {
throw SodaException(messageKey = "common.error.invalid_request")
}
return CreatorChannelPage(page = page, size = 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
}
companion object {
private const val MIN_PAGE = 0
private const val MIN_PAGE_SIZE = 20
private const val MAX_PAGE_SIZE = 50
}
}

View File

@@ -0,0 +1,38 @@
package kr.co.vividnext.sodalive.v2.creator.channel.live.domain
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import java.time.LocalDateTime
data class CreatorChannelLiveTab(
val liveReplayContentCount: Int,
val currentLive: CreatorChannelLive?,
val liveReplayContents: List<CreatorChannelAudioContent>,
val sort: ContentSort,
val page: CreatorChannelPage,
val hasNext: Boolean
)
data class CreatorChannelLive(
val liveId: Long,
val title: String,
val coverImageUrl: String?,
val beginDateTime: LocalDateTime,
val price: Int,
val isAdult: Boolean
)
data class CreatorChannelAudioContent(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
val isAdult: Boolean,
val isPointAvailable: Boolean,
val isFirstContent: Boolean,
val publishedAt: LocalDateTime,
val seriesName: String?,
val isOriginalSeries: Boolean?,
val isOwned: Boolean,
val isRented: Boolean
)

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.v2.creator.channel.live.domain
data class CreatorChannelPage(
val page: Int,
val size: Int
) {
val offset: Long = page.toLong() * size
val fetchLimit: Int = size + 1
}

View File

@@ -0,0 +1,68 @@
package kr.co.vividnext.sodalive.v2.creator.channel.live.port.out
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import java.time.LocalDateTime
interface CreatorChannelLiveQueryPort {
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord?
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
fun findCurrentLive(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean,
viewerId: Long?,
isViewerCreator: Boolean,
effectiveViewerGender: Gender?
): CreatorChannelLiveRecord?
fun countLiveReplayAudioContents(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int
fun findLiveReplayAudioContents(
creatorId: Long,
viewerId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean,
sort: ContentSort,
offset: Long,
limit: Int
): List<CreatorChannelAudioContentRecord>
}
data class CreatorChannelCreatorRecord(
val creatorId: Long,
val role: MemberRole,
val nickname: String
)
data class CreatorChannelLiveRecord(
val liveId: Long,
val title: String,
val coverImagePath: String?,
val beginDateTime: LocalDateTime,
val price: Int,
val isAdult: Boolean
)
data class CreatorChannelAudioContentRecord(
val audioContentId: Long,
val title: String,
val duration: String?,
val imagePath: String?,
val price: Int,
val isAdult: Boolean,
val isPointAvailable: Boolean,
val isFirstContent: Boolean,
val publishedAt: LocalDateTime,
val seriesName: String?,
val isOriginalSeries: Boolean?,
val isOwned: Boolean,
val isRented: Boolean
)

View File

@@ -1,4 +1,4 @@
package kr.co.vividnext.sodalive.v2.creator.channel.adapter.`in`.web
package kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.`in`.web
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.i18n.LangContext
@@ -6,20 +6,21 @@ 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.home.application.CreatorChannelHomeFacade
import kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto.CreatorChannelHomeResponse
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryService
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCreator
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalk
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalkSummary
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHome
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelLive
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSchedule
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSeries
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSns
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCreator
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalk
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalkSummary
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHome
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelLive
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSchedule
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSeries
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSns
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
@@ -48,7 +49,7 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
private val mockMvc: MockMvc
) {
@MockBean
private lateinit var service: CreatorChannelHomeQueryService
private lateinit var facade: CreatorChannelHomeFacade
@MockBean
private lateinit var countryContext: CountryContext
@@ -87,10 +88,10 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
}
@Test
@DisplayName("크리에이터 채널 홈 조회는 인증 회원과 creatorId를 서비스에 전달하고 성공 응답을 반환한다")
@DisplayName("크리에이터 채널 홈 조회는 인증 회원과 creatorId를 facade에 전달하고 성공 응답을 반환한다")
fun shouldReturnCreatorChannelHomeForAuthenticatedMember() {
val viewer = createMember(id = 10L)
Mockito.doReturn(createHome()).`when`(service).getHome(
Mockito.doReturn(CreatorChannelHomeResponse.from(createHome())).`when`(facade).getHome(
Mockito.eq(1L),
Mockito.any(Member::class.java) ?: viewer,
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
@@ -122,6 +123,10 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.latestAudioContent.isPointAvailable").value(true))
.andExpect(jsonPath("$.data.latestAudioContent.isFirstContent").value(true))
.andExpect(jsonPath("$.data.latestAudioContent.isOriginalSeries").value(true))
.andExpect(jsonPath("$.data.latestAudioContent.isOwned").value(true))
.andExpect(jsonPath("$.data.latestAudioContent.isRented").value(false))
.andExpect(jsonPath("$.data.audioContents[0].isOwned").value(false))
.andExpect(jsonPath("$.data.audioContents[0].isRented").value(true))
.andExpect(jsonPath("$.data.currentLive.isAdult").value(true))
.andExpect(jsonPath("$.data.schedules[0].isAdult").doesNotExist())
.andExpect(jsonPath("$.data.channelDonations[0].donationId").doesNotExist())
@@ -130,7 +135,7 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.series[0].isNew").value(true))
.andExpect(jsonPath("$.data.series[0].isOriginal").value(true))
Mockito.verify(service).getHome(
Mockito.verify(facade).getHome(
Mockito.eq(1L),
Mockito.eq(viewer) ?: viewer,
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
@@ -195,7 +200,9 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
isFirstContent = true,
publishedAt = LocalDateTime.of(2026, 6, 11, 1, 0),
seriesName = "series",
isOriginalSeries = true
isOriginalSeries = true,
isOwned = true,
isRented = false
),
channelDonations = listOf(
CreatorChannelDonation(
@@ -228,7 +235,9 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
isFirstContent = false,
publishedAt = LocalDateTime.of(2026, 6, 10, 1, 0),
seriesName = null,
isOriginalSeries = null
isOriginalSeries = null,
isOwned = false,
isRented = true
)
),
series = listOf(

View File

@@ -0,0 +1,215 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.home.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryService
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCreator
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalk
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalkSummary
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHome
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelLive
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSchedule
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSeries
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSns
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import java.time.LocalDateTime
class CreatorChannelHomeFacadeTest {
@Test
@DisplayName("크리에이터 채널 홈 facade는 query service 결과를 공개 응답 DTO로 변환한다")
fun shouldMapHomeQueryResultToPublicResponse() {
val service = Mockito.mock(CreatorChannelHomeQueryService::class.java)
val facade = CreatorChannelHomeFacade(service)
val viewer = createMember(id = 10L)
val now = LocalDateTime.of(2026, 6, 17, 10, 0)
Mockito.doReturn(createHome()).`when`(service).getHome(
creatorId = 1L,
viewer = viewer,
now = now
)
val response = facade.getHome(
creatorId = 1L,
viewer = viewer,
now = now
)
assertEquals(1L, response.creator.creatorId)
assertEquals(11L, response.creator.characterId)
assertTrue(response.creator.isAiChatAvailable)
assertFalse(response.creator.isDmAvailable)
assertEquals(101L, response.currentLive?.liveId)
assertEquals("2026-06-12T01:00:00Z", response.currentLive?.beginDateTimeUtc)
assertEquals(201L, response.latestAudioContent?.audioContentId)
assertTrue(response.latestAudioContent?.isPointAvailable == true)
assertTrue(response.latestAudioContent?.isFirstContent == true)
assertTrue(response.latestAudioContent?.isOriginalSeries == true)
assertTrue(response.latestAudioContent?.isOwned == true)
assertFalse(response.latestAudioContent?.isRented == true)
assertEquals("thanks", response.channelDonations.first().message)
assertEquals(301L, response.notices.first().postId)
assertEquals(501L, response.schedules.first().targetId)
assertEquals(202L, response.audioContents.first().audioContentId)
assertFalse(response.audioContents.first().isOwned)
assertTrue(response.audioContents.first().isRented)
assertEquals(601L, response.series.first().seriesId)
assertTrue(response.series.first().isNew)
assertTrue(response.series.first().isOriginal)
assertEquals(302L, response.communities.first().postId)
assertEquals(701L, response.fanTalk.latestFanTalk?.fanTalkId)
assertEquals("introduce", response.introduce)
assertEquals(10, response.activity.liveCount)
assertEquals("instagram", response.sns.instagramUrl)
}
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 createHome(): CreatorChannelHome {
val post = CreatorChannelCommunityPost(
postId = 301L,
creatorId = 1L,
creatorNickname = "creator",
creatorProfileUrl = "profile.png",
imageUrl = "image.png",
audioUrl = "audio.mp3",
content = "notice",
price = 10,
date = LocalDateTime.of(2026, 6, 12, 4, 0),
existOrdered = true,
likeCount = 2,
commentCount = 3
)
return CreatorChannelHome(
creator = CreatorChannelCreator(
creatorId = 1L,
characterId = 11L,
nickname = "creator",
profileImageUrl = "profile.png",
followerCount = 100,
isAiChatAvailable = true,
isDmAvailable = false,
isFollow = true,
isNotify = false
),
currentLive = CreatorChannelLive(
liveId = 101L,
title = "live",
coverImageUrl = "live.png",
beginDateTime = LocalDateTime.of(2026, 6, 12, 1, 0),
price = 20,
isAdult = true
),
latestAudioContent = CreatorChannelAudioContent(
audioContentId = 201L,
title = "audio",
duration = "00:10:00",
imageUrl = "audio.png",
price = 30,
isAdult = true,
isPointAvailable = true,
isFirstContent = true,
publishedAt = LocalDateTime.of(2026, 6, 11, 1, 0),
seriesName = "series",
isOriginalSeries = true,
isOwned = true,
isRented = false
),
channelDonations = listOf(
CreatorChannelDonation(
nickname = "fan",
profileImageUrl = "fan.png",
can = 50,
message = "thanks",
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0)
)
),
notices = listOf(post),
schedules = listOf(
CreatorChannelSchedule(
scheduledAt = LocalDateTime.of(2026, 6, 12, 3, 0),
title = "schedule",
type = CreatorActivityType.LIVE,
targetId = 501L,
isAdult = false
)
),
audioContents = listOf(
CreatorChannelAudioContent(
audioContentId = 202L,
title = "audio2",
duration = null,
imageUrl = null,
price = 0,
isAdult = false,
isPointAvailable = false,
isFirstContent = false,
publishedAt = LocalDateTime.of(2026, 6, 10, 1, 0),
seriesName = null,
isOriginalSeries = null,
isOwned = false,
isRented = true
)
),
series = listOf(
CreatorChannelSeries(
seriesId = 601L,
title = "series",
coverImageUrl = "series.png",
numberOfContent = 3,
isNew = true,
isOriginal = true
)
),
communities = listOf(post.copy(postId = 302L, content = "community")),
fanTalk = CreatorChannelFanTalkSummary(
totalCount = 1,
latestFanTalk = CreatorChannelFanTalk(
fanTalkId = 701L,
memberId = 2L,
nickname = "fan",
profileImageUrl = "fan.png",
content = "hello",
languageCode = "ko",
createdAt = LocalDateTime.of(2026, 6, 12, 5, 0)
)
),
introduce = "introduce",
activity = CreatorChannelActivity(
debutDate = LocalDateTime.of(2026, 6, 12, 6, 0),
dDay = "D+1",
liveCount = 10,
liveDurationHours = 20,
liveContributorCount = 30,
audioContentCount = 40,
seriesCount = 50
),
sns = CreatorChannelSns(
instagramUrl = "instagram",
fancimmUrl = "fancimm",
xUrl = "x",
youtubeUrl = "youtube",
kakaoOpenChatUrl = "kakao"
)
)
}
}

View File

@@ -0,0 +1,260 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.live.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.live.application.CreatorChannelLiveFacade
import kr.co.vividnext.sodalive.v2.api.creator.channel.live.dto.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.live.dto.CreatorChannelLiveResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.live.dto.CreatorChannelLiveTabResponse
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
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(CreatorChannelLiveController::class)
@Import(CreatorChannelLiveControllerTest.TestSecurityConfig::class)
class CreatorChannelLiveControllerTest @Autowired constructor(
private val mockMvc: MockMvc
) {
@MockBean
private lateinit var facade: CreatorChannelLiveFacade
@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 shouldRejectAnonymousCreatorChannelLiveRequest() {
mockMvc.perform(
get("/api/v2/creator-channels/1/live")
.with(anonymous())
)
.andExpect(status().isUnauthorized)
}
@Test
@DisplayName("크리에이터 채널 라이브 탭 조회는 기본 정렬과 page 정보를 facade에 전달하고 성공 응답을 반환한다")
fun shouldReturnCreatorChannelLiveTabForAuthenticatedMember() {
val viewer = createMember(id = 10L)
Mockito.doReturn(createResponse()).`when`(facade).getLiveTab(
eqValue(1L),
eqValue(viewer),
eqValue(ContentSort.LATEST),
eqValue(0),
eqValue(20),
anyValue(LocalDateTime.now())
)
mockMvc.perform(
get("/api/v2/creator-channels/1/live")
.with(user(MemberAdapter(viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.liveReplayContentCount").value(1))
.andExpect(jsonPath("$.data.currentLive").exists())
.andExpect(jsonPath("$.data.liveReplayContents").isArray)
.andExpect(jsonPath("$.data.sort").value("LATEST"))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
.andExpect(jsonPath("$.data.liveReplayContents[0].isOwned").value(true))
.andExpect(jsonPath("$.data.liveReplayContents[0].isRented").value(false))
Mockito.verify(facade).getLiveTab(
eqValue(1L),
eqValue(viewer),
eqValue(ContentSort.LATEST),
eqValue(0),
eqValue(20),
anyValue(LocalDateTime.now())
)
}
@Test
@DisplayName("크리에이터 채널 라이브 탭 조회는 다음 페이지가 있는 대표 응답 표면을 반환한다")
fun shouldReturnLiveTabSurfaceWhenNextPageExists() {
val viewer = createMember(id = 10L)
Mockito.doReturn(createResponse(liveReplayContentCount = 21, contentCount = 20, hasNext = true))
.`when`(facade).getLiveTab(
eqValue(1L),
eqValue(viewer),
eqValue(ContentSort.LATEST),
eqValue(0),
eqValue(20),
anyValue(LocalDateTime.now())
)
mockMvc.perform(
get("/api/v2/creator-channels/1/live")
.with(user(MemberAdapter(viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.liveReplayContentCount").value(21))
.andExpect(jsonPath("$.data.currentLive.liveId").value(101L))
.andExpect(jsonPath("$.data.liveReplayContents.length()").value(20))
.andExpect(jsonPath("$.data.sort").value("LATEST"))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(true))
.andExpect(jsonPath("$.data.liveReplayContents[0].isOwned").value(true))
.andExpect(jsonPath("$.data.liveReplayContents[0].isRented").value(false))
.andExpect(jsonPath("$.data.liveReplayContents[1].isOwned").value(false))
.andExpect(jsonPath("$.data.liveReplayContents[1].isRented").value(true))
.andExpect(jsonPath("$.data.liveReplayContents[2].isOwned").value(false))
.andExpect(jsonPath("$.data.liveReplayContents[2].isRented").value(false))
}
@Test
@DisplayName("크리에이터 채널 라이브 탭 조회는 잘못된 page 요청을 기존 오류 응답으로 반환한다")
fun shouldReturnErrorResponseWhenPageIsInvalid() {
val viewer = createMember(id = 10L)
Mockito.doThrow(kr.co.vividnext.sodalive.common.SodaException(messageKey = "common.error.invalid_request"))
.`when`(facade).getLiveTab(
eqValue(1L),
eqValue(viewer),
eqValue(ContentSort.LATEST),
eqValue(-1),
eqValue(20),
anyValue(LocalDateTime.now())
)
mockMvc.perform(
get("/api/v2/creator-channels/1/live")
.param("page", "-1")
.with(user(MemberAdapter(viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
}
@Test
@DisplayName("크리에이터 채널 라이브 탭 조회는 잘못된 size 요청을 기존 오류 응답으로 반환한다")
fun shouldReturnErrorResponseWhenSizeIsInvalid() {
val viewer = createMember(id = 10L)
Mockito.doThrow(kr.co.vividnext.sodalive.common.SodaException(messageKey = "common.error.invalid_request"))
.`when`(facade).getLiveTab(
eqValue(1L),
eqValue(viewer),
eqValue(ContentSort.LATEST),
eqValue(0),
eqValue(0),
anyValue(LocalDateTime.now())
)
mockMvc.perform(
get("/api/v2/creator-channels/1/live")
.param("size", "0")
.with(user(MemberAdapter(viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
}
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(
liveReplayContentCount: Int = 1,
contentCount: Int = 1,
hasNext: Boolean = false
): CreatorChannelLiveTabResponse {
return CreatorChannelLiveTabResponse(
liveReplayContentCount = liveReplayContentCount,
currentLive = CreatorChannelLiveResponse(
liveId = 101L,
title = "live",
coverImageUrl = "live.png",
beginDateTimeUtc = "2026-06-17T01:00:00Z",
price = 20,
isAdult = true
),
liveReplayContents = createAudioContents(contentCount),
sort = ContentSort.LATEST,
page = 0,
size = 20,
hasNext = hasNext
)
}
private fun createAudioContents(count: Int): List<CreatorChannelAudioContentResponse> {
return (1..count).map { index ->
CreatorChannelAudioContentResponse(
audioContentId = 200L + index,
title = "audio-$index",
duration = "00:10:00",
imageUrl = "audio-$index.png",
price = 30,
isAdult = false,
isPointAvailable = true,
isFirstContent = index == 1,
seriesName = "series",
isOriginalSeries = true,
isOwned = index == 1,
isRented = index == 2
)
}
}
}

View File

@@ -0,0 +1,191 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.`in`.web
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.order.Order
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.live.room.GenderRestriction
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime
import javax.persistence.EntityManager
@SpringBootTest(
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:creator-channel-live-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
]
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class CreatorChannelLiveEndToEndTest @Autowired constructor(
private val mockMvc: MockMvc,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
) {
@Test
@DisplayName("라이브 탭 API는 controller-service-repository를 거쳐 대표 응답을 반환한다")
fun shouldReturnLiveTabThroughControllerServiceAndRepository() {
val fixture = createFixture()
mockMvc.perform(
get("/api/v2/creator-channels/${fixture.creatorId}/live")
.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.liveReplayContentCount").value(21))
.andExpect(jsonPath("$.data.currentLive.liveId").value(fixture.currentLiveId))
.andExpect(jsonPath("$.data.currentLive.title").value("e2e-live"))
.andExpect(jsonPath("$.data.currentLive.coverImageUrl").value("https://cdn.test/live-cover.png"))
.andExpect(jsonPath("$.data.liveReplayContents.length()").value(20))
.andExpect(jsonPath("$.data.liveReplayContents[0].audioContentId").value(fixture.keepContentId))
.andExpect(jsonPath("$.data.liveReplayContents[0].imageUrl").value("https://cdn.test/audio-1.png"))
.andExpect(jsonPath("$.data.liveReplayContents[0].isOwned").value(true))
.andExpect(jsonPath("$.data.liveReplayContents[0].isRented").value(false))
.andExpect(jsonPath("$.data.liveReplayContents[1].audioContentId").value(fixture.rentalContentId))
.andExpect(jsonPath("$.data.liveReplayContents[1].isOwned").value(false))
.andExpect(jsonPath("$.data.liveReplayContents[1].isRented").value(true))
.andExpect(jsonPath("$.data.liveReplayContents[2].audioContentId").value(fixture.unorderedContentId))
.andExpect(jsonPath("$.data.liveReplayContents[2].isOwned").value(false))
.andExpect(jsonPath("$.data.liveReplayContents[2].isRented").value(false))
.andExpect(jsonPath("$.data.sort").value("LATEST"))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(true))
}
private fun createFixture(): Fixture {
return transactionTemplate.execute {
val now = LocalDateTime.now()
val viewer = saveMember("live-e2e-viewer", MemberRole.USER)
val creator = saveMember("live-e2e-creator", MemberRole.CREATOR)
val currentLive = saveLiveRoom(creator, now.minusHours(1))
val liveReplayTheme = saveTheme("다시듣기")
val contents = (1..21).map { index ->
saveAudioContent(
creator = creator,
releaseDate = now.minusMinutes(index.toLong()),
theme = liveReplayTheme,
coverImage = "audio-$index.png"
)
}
saveOrder(viewer, creator, contents[0], OrderType.KEEP)
saveOrder(viewer, creator, contents[1], OrderType.RENTAL, endDate = now.plusDays(1))
entityManager.flush()
Fixture(
viewer = viewer,
creatorId = creator.id!!,
currentLiveId = currentLive.id!!,
keepContentId = contents[0].id!!,
rentalContentId = contents[1].id!!,
unorderedContentId = contents[2].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 saveLiveRoom(creator: Member, beginDateTime: LocalDateTime): LiveRoom {
val liveRoom = LiveRoom(
title = "e2e-live",
notice = "notice",
beginDateTime = beginDateTime,
numberOfPeople = 0,
coverImage = "live-cover.png",
isAdult = false,
price = 50,
isAvailableJoinCreator = true,
genderRestriction = GenderRestriction.ALL
)
liveRoom.member = creator
liveRoom.channelName = "e2e-live-channel"
liveRoom.isActive = true
entityManager.persist(liveRoom)
return liveRoom
}
private fun saveAudioContent(
creator: Member,
releaseDate: LocalDateTime,
theme: AudioContentTheme,
coverImage: String
): AudioContent {
val content = AudioContent(
title = "audio-$coverImage",
detail = "detail",
languageCode = "ko",
releaseDate = releaseDate,
isAdult = false,
price = 100,
isPointAvailable = true
)
content.member = creator
content.theme = theme
content.isActive = true
content.coverImage = coverImage
content.duration = "00:10:00"
entityManager.persist(content)
return content
}
private fun saveTheme(name: String): AudioContentTheme {
val theme = AudioContentTheme(theme = name, image = "$name.png", isActive = true)
entityManager.persist(theme)
return theme
}
private fun saveOrder(
member: Member,
creator: Member,
content: AudioContent,
type: OrderType,
endDate: LocalDateTime? = null
): Order {
val order = Order(type = type, isActive = true)
order.member = member
order.creator = creator
order.audioContent = content
entityManager.persist(order)
endDate?.let { order.endDate = it }
return order
}
private data class Fixture(
val viewer: Member,
val creatorId: Long,
val currentLiveId: Long,
val keepContentId: Long,
val rentalContentId: Long,
val unorderedContentId: Long
)
}

View File

@@ -0,0 +1,101 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.live.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryService
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLive
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveTab
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import java.time.LocalDateTime
class CreatorChannelLiveFacadeTest {
@Test
@DisplayName("라이브 탭 facade는 query service 결과를 공개 응답 DTO로 변환한다")
fun shouldMapLiveTabQueryResultToPublicResponse() {
val service = Mockito.mock(CreatorChannelLiveQueryService::class.java)
val facade = CreatorChannelLiveFacade(service)
val viewer = createMember(id = 10L)
val now = LocalDateTime.of(2026, 6, 17, 10, 0)
Mockito.doReturn(createTab()).`when`(service).getLiveTab(
creatorId = 1L,
viewer = viewer,
sort = ContentSort.LATEST,
page = 0,
size = 20,
now = now
)
val response = facade.getLiveTab(
creatorId = 1L,
viewer = viewer,
sort = ContentSort.LATEST,
page = 0,
size = 20,
now = now
)
assertEquals(1, response.liveReplayContentCount)
assertEquals(101L, response.currentLive?.liveId)
assertEquals("2026-06-17T01:00:00Z", response.currentLive?.beginDateTimeUtc)
assertEquals(201L, response.liveReplayContents.first().audioContentId)
assertTrue(response.liveReplayContents.first().isOwned)
assertFalse(response.liveReplayContents.first().isRented)
assertEquals(ContentSort.LATEST, response.sort)
assertEquals(0, response.page)
assertEquals(20, response.size)
assertFalse(response.hasNext)
}
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(): CreatorChannelLiveTab {
return CreatorChannelLiveTab(
liveReplayContentCount = 1,
currentLive = CreatorChannelLive(
liveId = 101L,
title = "live",
coverImageUrl = "live.png",
beginDateTime = LocalDateTime.of(2026, 6, 17, 1, 0),
price = 20,
isAdult = true
),
liveReplayContents = listOf(
CreatorChannelAudioContent(
audioContentId = 201L,
title = "audio",
duration = "00:10:00",
imageUrl = "audio.png",
price = 30,
isAdult = false,
isPointAvailable = true,
isFirstContent = true,
publishedAt = LocalDateTime.of(2026, 6, 16, 1, 0),
seriesName = "series",
isOriginalSeries = true,
isOwned = true,
isRented = false
)
),
sort = ContentSort.LATEST,
page = CreatorChannelPage(page = 0, size = 20),
hasNext = false
)
}
}

View File

@@ -0,0 +1,14 @@
package kr.co.vividnext.sodalive.v2.common.domain
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class ContentSortTest {
@Test
fun shouldDefineCommonContentSortValues() {
assertEquals(
listOf("LATEST", "POPULAR", "OWNED", "PRICE_HIGH", "PRICE_LOW"),
ContentSort.values().map { it.name }
)
}
}

View File

@@ -1,4 +1,4 @@
package kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence
package kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
@@ -8,6 +8,8 @@ import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.ContentType
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
@@ -128,7 +130,7 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
@DisplayName("홈 repository 조회는 Phase 3 projection/bulk 최적화 대상에서 entity 전체 fetch와 per-row helper를 사용하지 않는다")
fun shouldUseProjectionAndBulkQueriesForPhaseThreeOptimizedMethods() {
val source = Paths.get(
"src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/" +
"src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/" +
"DefaultCreatorChannelHomeQueryRepository.kt"
)
.toFile()
@@ -209,6 +211,8 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
val latestAudio = saveAudioContent(creator, now.minusDays(1), isAdult = false, price = 200)
val series = saveSeries("integrated-home-series", creator, isOriginal = true)
saveSeriesContent(series, listAudio)
saveOrder(viewer, creator, latestAudio, OrderType.KEEP)
saveOrder(viewer, creator, listAudio, OrderType.RENTAL, endDate = now.plusDays(1))
val donation = saveDonation(creator, donor, 500, now.minusHours(3), additionalMessage = "integrated thanks")
val notice = saveCommunity(creator, isFixed = true, fixedAt = now.minusHours(4), price = 0)
val community = saveCommunity(
@@ -236,7 +240,12 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
isViewerCreator = false,
effectiveViewerGender = null
)
val latestAudioRecord = repository.findLatestAudioContent(creator.id!!, now, canViewAdultContent = false)
val latestAudioRecord = repository.findLatestAudioContent(
creator.id!!,
now,
canViewAdultContent = false,
viewerId = viewer.id!!
)
val donations = repository.findChannelDonations(creator.id!!, viewer.id!!, now, limit = 8)
val notices = repository.findCommunityPosts(creator.id!!, viewer.id!!, isFixed = true, false, limit = 3)
val schedules = repository.findSchedules(
@@ -253,6 +262,7 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
now,
latestAudioContentId = latestAudioRecord!!.audioContentId,
canViewAdultContent = false,
viewerId = viewer.id!!,
limit = 9
)
val seriesRecords = repository.findSeries(creator.id!!, viewer.id!!, now, false, ContentType.ALL, limit = 8)
@@ -265,12 +275,16 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
assertEquals("integrated introduce", creatorRecord.introduce)
assertEquals(currentLive.id, currentLiveRecord!!.liveId)
assertEquals(latestAudio.id, latestAudioRecord.audioContentId)
assertTrue(latestAudioRecord.isOwned)
assertFalse(latestAudioRecord.isRented)
assertEquals(listOf(donation.can), donations.map { it.can })
assertEquals("integrated thanks", donations.single().message)
assertEquals(listOf(notice.id), notices.map { it.postId })
assertEquals(listOf(liveSchedule.id, audioSchedule.id), schedules.map { it.targetId })
assertEquals(listOf(CreatorActivityType.LIVE, CreatorActivityType.AUDIO), schedules.map { it.type })
assertEquals(listOf(listAudio.id, firstAudio.id), audioContents.map { it.audioContentId })
assertEquals(listOf(false, false), audioContents.map { it.isOwned })
assertEquals(listOf(true, false), audioContents.map { it.isRented })
assertEquals(listOf(series.id), seriesRecords.map { it.seriesId })
assertEquals(true, seriesRecords.single().isOriginal)
assertEquals(listOf(community.id), communities.map { it.postId })
@@ -526,6 +540,44 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
assertTrue(records.last().isPointAvailable)
}
@Test
@DisplayName("최신 오디오와 오디오 목록은 조회자의 유효한 소장/대여 주문 상태를 함께 반환한다")
fun shouldFindAudioContentOwnershipFlagsByViewerOrders() {
val now = LocalDateTime.of(2026, 6, 12, 12, 0)
val viewer = saveMember("audio-order-viewer", MemberRole.USER)
val creator = saveMember("audio-order-creator", MemberRole.CREATOR)
val keepAndRental = saveAudioContent(creator, now.minusDays(3), isAdult = false)
val rentalOnly = saveAudioContent(creator, now.minusDays(2), isAdult = false)
val keepOnly = saveAudioContent(creator, now.minusDays(1), isAdult = false)
saveOrder(viewer, creator, keepOnly, OrderType.KEEP)
saveOrder(viewer, creator, rentalOnly, OrderType.RENTAL, endDate = now.plusDays(1))
saveOrder(viewer, creator, keepAndRental, OrderType.KEEP)
saveOrder(viewer, creator, keepAndRental, OrderType.RENTAL, endDate = now.plusDays(1))
flushAndClear()
val latestRecord = repository.findLatestAudioContent(
creator.id!!,
now,
canViewAdultContent = false,
viewerId = viewer.id!!
)
val records = repository.findAudioContents(
creator.id!!,
now,
latestAudioContentId = latestRecord!!.audioContentId,
canViewAdultContent = false,
viewerId = viewer.id!!,
limit = 9
)
assertEquals(keepOnly.id, latestRecord.audioContentId)
assertTrue(latestRecord.isOwned)
assertFalse(latestRecord.isRented)
assertEquals(listOf(rentalOnly.id, keepAndRental.id), records.map { it.audioContentId })
assertEquals(listOf(false, true), records.map { it.isOwned })
assertEquals(listOf(true, true), records.map { it.isRented })
}
@Test
@DisplayName("오디오 목록은 releaseDate가 null인 콘텐츠를 제외한다")
fun shouldExcludeNullReleaseDateAudioContent() {
@@ -1441,6 +1493,23 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
return useCan
}
private fun saveOrder(
member: Member,
creator: Member,
content: AudioContent,
type: OrderType,
isActive: Boolean = true,
endDate: LocalDateTime? = null
): Order {
val order = Order(type = type, isActive = isActive)
order.member = member
order.creator = creator
order.audioContent = content
endDate?.let { order.endDate = it }
entityManager.persist(order)
return order
}
private fun saveCheers(
member: Member,
creator: Member,

View File

@@ -1,4 +1,4 @@
package kr.co.vividnext.sodalive.v2.creator.channel.application
package kr.co.vividnext.sodalive.v2.creator.channel.home.application
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kr.co.vividnext.sodalive.common.SodaException
@@ -13,33 +13,33 @@ import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto.CreatorChannelHomeResponse
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCreator
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalk
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalkSummary
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHome
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelLive
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSchedule
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSeries
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSns
import kr.co.vividnext.sodalive.v2.creator.channel.dto.CreatorChannelHomeResponse
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelActivityRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCommunityPostRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelDonationRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkSummaryRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelHomeQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelLiveRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelScheduleRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSeriesRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSnsRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCreator
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalk
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalkSummary
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHome
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHomeQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelLive
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSchedule
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSeries
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSns
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelActivityRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelCommunityPostRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelDonationRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelFanTalkRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelFanTalkSummaryRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelHomeQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelLiveRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelScheduleRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelSeriesRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelSnsRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
@@ -68,7 +68,11 @@ class CreatorChannelHomeQueryServiceTest {
assertEquals("https://cdn.test/profile/creator.png", home.creator.profileImageUrl)
assertEquals("https://cdn.test/live.png", home.currentLive?.coverImageUrl)
assertEquals("https://cdn.test/audio/latest.png", home.latestAudioContent?.imageUrl)
assertTrue(home.latestAudioContent?.isOwned == true)
assertFalse(home.latestAudioContent?.isRented == true)
assertEquals(listOf(203L, 202L), home.audioContents.map { it.audioContentId })
assertEquals(listOf(false, true), home.audioContents.map { it.isOwned })
assertEquals(listOf(true, false), home.audioContents.map { it.isRented })
assertEquals(listOf(402L, 401L, 404L), home.schedules.map { it.targetId })
assertFalse(home.schedules.any { it.isAdult })
assertEquals("https://cdn.test/profile/fan.png", home.channelDonations.first().profileImageUrl)
@@ -179,6 +183,8 @@ class CreatorChannelHomeQueryServiceTest {
assertEquals(home.creator.characterId, response.creator.characterId)
assertEquals(home.currentLive?.liveId, response.currentLive?.liveId)
assertEquals(home.latestAudioContent?.audioContentId, response.latestAudioContent?.audioContentId)
assertEquals(home.latestAudioContent?.isOwned, response.latestAudioContent?.isOwned)
assertEquals(home.latestAudioContent?.isRented, response.latestAudioContent?.isRented)
assertEquals(home.channelDonations.first().message, response.channelDonations.first().message)
assertEquals(home.notices.first().postId, response.notices.first().postId)
assertEquals(home.schedules.first().targetId, response.schedules.first().targetId)
@@ -217,6 +223,8 @@ class CreatorChannelHomeQueryServiceTest {
assertTrue(response.latestAudioContent?.isPointAvailable == true)
assertTrue(response.latestAudioContent?.isFirstContent == true)
assertTrue(response.latestAudioContent?.isAdult == true)
assertTrue(response.latestAudioContent?.isOwned == true)
assertFalse(response.latestAudioContent?.isRented == true)
assertTrue(response.series.first().isOriginal)
assertNotNull(response.latestAudioContent?.isOriginalSeries)
}
@@ -239,6 +247,10 @@ class CreatorChannelHomeQueryServiceTest {
assertFalse(json["latestAudioContent"].has("firstContent"))
assertTrue(json["latestAudioContent"]["isAdult"].asBoolean())
assertFalse(json["latestAudioContent"].has("adult"))
assertTrue(json["latestAudioContent"]["isOwned"].asBoolean())
assertFalse(json["latestAudioContent"].has("owned"))
assertFalse(json["latestAudioContent"]["isRented"].asBoolean())
assertFalse(json["latestAudioContent"].has("rented"))
assertTrue(json["series"][0]["isOriginal"].asBoolean())
assertFalse(json["series"][0].has("original"))
assertFalse(json["series"][0].has("published" + "DaysOfWeek"))
@@ -297,7 +309,9 @@ class CreatorChannelHomeQueryServiceTest {
isFirstContent = true,
publishedAt = LocalDateTime.of(2026, 6, 11, 1, 0),
seriesName = "series",
isOriginalSeries = true
isOriginalSeries = true,
isOwned = true,
isRented = false
),
channelDonations = listOf(
CreatorChannelDonation(
@@ -330,7 +344,9 @@ class CreatorChannelHomeQueryServiceTest {
isFirstContent = false,
publishedAt = LocalDateTime.of(2026, 6, 10, 1, 0),
seriesName = null,
isOriginalSeries = null
isOriginalSeries = null,
isOwned = false,
isRented = true
)
),
series = listOf(
@@ -484,7 +500,8 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
override fun findLatestAudioContent(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean
canViewAdultContent: Boolean,
viewerId: Long?
): CreatorChannelAudioContentRecord? = audioContentRecord(201L, "audio/latest.png")
override fun findChannelDonations(
@@ -553,6 +570,7 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
now: LocalDateTime,
latestAudioContentId: Long?,
canViewAdultContent: Boolean,
viewerId: Long?,
limit: Int
): List<CreatorChannelAudioContentRecord> {
audioContentsLatestAudioContentId = latestAudioContentId
@@ -630,7 +648,9 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
isFirstContent = false,
publishedAt = LocalDateTime.of(2026, 6, 10, 0, audioContentId.toInt() % 60),
seriesName = null,
isOriginalSeries = null
isOriginalSeries = null,
isOwned = audioContentId == 201L || audioContentId == 202L,
isRented = audioContentId == 203L
)
}

View File

@@ -1,4 +1,4 @@
package kr.co.vividnext.sodalive.v2.creator.channel.domain
package kr.co.vividnext.sodalive.v2.creator.channel.home.domain
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import org.junit.jupiter.api.Assertions.assertEquals
@@ -127,7 +127,9 @@ class CreatorChannelHomeQueryPolicyTest {
isFirstContent = false,
publishedAt = publishedAt,
seriesName = null,
isOriginalSeries = null
isOriginalSeries = null,
isOwned = false,
isRented = false
)
}
}

View File

@@ -0,0 +1,388 @@
package kr.co.vividnext.sodalive.v2.creator.channel.live.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.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.live.room.GenderRestriction
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.Gender
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.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 DefaultCreatorChannelLiveQueryRepositoryTest @Autowired constructor(
private val entityManager: EntityManager,
queryFactory: JPAQueryFactory
) {
private val repository = DefaultCreatorChannelLiveQueryRepository(queryFactory)
@Test
@DisplayName("라이브 다시듣기 count는 공개 다시듣기 콘텐츠와 성인 노출 정책만 반영한다")
fun shouldCountPublicLiveReplayAudioContentsOnly() {
val now = LocalDateTime.of(2026, 6, 17, 12, 0)
val creator = saveMember("count-creator", MemberRole.CREATOR)
val liveReplayTheme = saveTheme("다시듣기")
saveAudioContent(creator, now.minusDays(2), isAdult = false, theme = liveReplayTheme)
saveAudioContent(creator, now.minusDays(1), isAdult = false, theme = liveReplayTheme)
saveAudioContent(creator, now.minusHours(1), isAdult = true, theme = liveReplayTheme)
saveAudioContent(creator, now.minusHours(2), isAdult = false, theme = saveTheme("수면"))
saveAudioContent(creator, now.plusHours(1), isAdult = false, theme = liveReplayTheme)
saveAudioContent(creator, now.minusHours(3), isAdult = false, theme = liveReplayTheme).isActive = false
saveAudioContent(creator, now.minusHours(4), isAdult = false, theme = saveTheme("inactive", isActive = false))
saveAudioContent(creator, now.minusHours(5), isAdult = false, theme = liveReplayTheme).duration = null
saveAudioContent(creator, now.minusHours(6), isAdult = false, theme = liveReplayTheme).releaseDate = null
flushAndClear()
val hiddenAdultCount = repository.countLiveReplayAudioContents(creator.id!!, now, canViewAdultContent = false)
val visibleAdultCount = repository.countLiveReplayAudioContents(creator.id!!, now, canViewAdultContent = true)
assertEquals(2, hiddenAdultCount)
assertEquals(3, visibleAdultCount)
}
@Test
@DisplayName("라이브 다시듣기 목록은 page 인자와 기본 정렬을 DB에서 적용하고 series/firstContent를 채운다")
fun shouldFindLiveReplayAudioContentsWithPaginationAndLatestSort() {
val now = LocalDateTime.of(2026, 6, 17, 12, 0)
val creator = saveMember("list-creator", MemberRole.CREATOR)
val theme = saveTheme("다시듣기")
val oldFirst = saveAudioContent(creator, now.minusDays(30), isAdult = false, theme = theme, price = 100)
repeat(20) { index ->
saveAudioContent(creator, now.minusDays(29L - index), isAdult = false, theme = theme, price = 100 + index)
}
val sameDateLowPrice = saveAudioContent(creator, now.minusHours(1), isAdult = false, theme = theme, price = 100)
val sameDateHighPrice = saveAudioContent(creator, now.minusHours(1), isAdult = false, theme = theme, price = 300)
val series = saveSeries("live-replay-series", creator, isOriginal = true)
saveSeriesContent(series, sameDateHighPrice)
flushAndClear()
val firstPage = repository.findLiveReplayAudioContents(
creatorId = creator.id!!,
viewerId = null,
now = now,
canViewAdultContent = false,
sort = ContentSort.LATEST,
offset = 0,
limit = 21
)
val secondPage = repository.findLiveReplayAudioContents(
creatorId = creator.id!!,
viewerId = null,
now = now,
canViewAdultContent = false,
sort = ContentSort.LATEST,
offset = 20,
limit = 21
)
assertEquals(21, firstPage.size)
assertEquals(listOf(sameDateHighPrice.id, sameDateLowPrice.id), firstPage.take(2).map { it.audioContentId })
assertEquals(3, secondPage.size)
assertEquals(firstPage[20].audioContentId, secondPage.first().audioContentId)
assertEquals(oldFirst.id, secondPage.last().audioContentId)
assertEquals("live-replay-series", firstPage.first().seriesName)
assertEquals(true, firstPage.first().isOriginalSeries)
assertTrue(secondPage.last().isFirstContent)
}
@Test
@DisplayName("라이브 다시듣기의 isFirstContent는 테마가 아니라 전체 공개 오디오 콘텐츠 첫 항목을 기준으로 한다")
fun shouldMarkFirstContentByAllPublicAudioContentsNotLiveReplayTheme() {
val now = LocalDateTime.of(2026, 6, 17, 12, 0)
val creator = saveMember("first-content-creator", MemberRole.CREATOR)
saveAudioContent(
creator = creator,
releaseDate = now.minusDays(10),
isAdult = false,
theme = saveTheme("수면")
)
val liveReplay = saveAudioContent(
creator = creator,
releaseDate = now.minusDays(1),
isAdult = false,
theme = saveTheme("다시듣기")
)
flushAndClear()
val records = repository.findLiveReplayAudioContents(
creatorId = creator.id!!,
viewerId = null,
now = now,
canViewAdultContent = false,
sort = ContentSort.LATEST,
offset = 0,
limit = 20
)
assertEquals(listOf(liveReplay.id), records.map { it.audioContentId })
assertEquals(listOf(false), records.map { it.isFirstContent })
}
@Test
@DisplayName("라이브 다시듣기 목록은 가격 정렬을 적용한다")
fun shouldSortLiveReplayAudioContentsByPrice() {
val now = LocalDateTime.of(2026, 6, 17, 12, 0)
val creator = saveMember("price-creator", MemberRole.CREATOR)
val theme = saveTheme("다시듣기")
val low = saveAudioContent(creator, now.minusDays(1), isAdult = false, theme = theme, price = 100)
val high = saveAudioContent(creator, now.minusDays(2), isAdult = false, theme = theme, price = 300)
flushAndClear()
val highRecords = repository.findLiveReplayAudioContents(creator.id!!, null, now, false, ContentSort.PRICE_HIGH, 0, 20)
val lowRecords = repository.findLiveReplayAudioContents(creator.id!!, null, now, false, ContentSort.PRICE_LOW, 0, 20)
assertEquals(listOf(high.id, low.id), highRecords.map { it.audioContentId })
assertEquals(listOf(low.id, high.id), lowRecords.map { it.audioContentId })
}
@Test
@DisplayName("인기순은 활성 주문 can 합계를 기준으로 정렬하고 point와 비활성 주문을 제외한다")
fun shouldSortLiveReplayAudioContentsByPopularCanRevenue() {
val now = LocalDateTime.of(2026, 6, 17, 12, 0)
val viewer = saveMember("popular-viewer", MemberRole.USER)
val creator = saveMember("popular-creator", MemberRole.CREATOR)
val theme = saveTheme("다시듣기")
val olderHighRevenue = saveAudioContent(creator, now.minusDays(3), isAdult = false, theme = theme, price = 100)
val newerLowRevenue = saveAudioContent(creator, now.minusDays(1), isAdult = false, theme = theme, price = 100)
val inactiveRevenue = saveAudioContent(creator, now.minusDays(2), isAdult = false, theme = theme, price = 100)
saveOrder(viewer, creator, olderHighRevenue, OrderType.KEEP, can = 500, point = 900)
saveOrder(viewer, creator, newerLowRevenue, OrderType.KEEP, can = 100, point = 9000)
saveOrder(viewer, creator, inactiveRevenue, OrderType.KEEP, isActive = false, can = 1000)
flushAndClear()
val records = repository.findLiveReplayAudioContents(creator.id!!, viewer.id!!, now, false, ContentSort.POPULAR, 0, 20)
assertEquals(listOf(olderHighRevenue.id, newerLowRevenue.id, inactiveRevenue.id), records.map { it.audioContentId })
}
@Test
@DisplayName("소장순은 조회자 KEEP 콘텐츠를 먼저 정렬하고 소장/대여 상태를 함께 반환한다")
fun shouldSortLiveReplayAudioContentsByOwnedAndReturnOrderStates() {
val now = LocalDateTime.of(2026, 6, 17, 12, 0)
val viewer = saveMember("owned-viewer", MemberRole.USER)
val creator = saveMember("owned-creator", MemberRole.CREATOR)
val theme = saveTheme("다시듣기")
val keepAndRental = saveAudioContent(creator, now.minusDays(4), isAdult = false, theme = theme)
val expiredRental = saveAudioContent(creator, now.minusDays(3), isAdult = false, theme = theme)
val rentalOnly = saveAudioContent(creator, now.minusDays(2), isAdult = false, theme = theme)
val keepOnly = saveAudioContent(creator, now.minusDays(1), isAdult = false, theme = theme)
saveOrder(viewer, creator, keepOnly, OrderType.KEEP)
saveOrder(viewer, creator, rentalOnly, OrderType.RENTAL, endDate = now.plusDays(1))
saveOrder(viewer, creator, expiredRental, OrderType.RENTAL, endDate = now.minusDays(1))
saveOrder(viewer, creator, keepAndRental, OrderType.KEEP)
saveOrder(viewer, creator, keepAndRental, OrderType.RENTAL, endDate = now.plusDays(1))
flushAndClear()
val records = repository.findLiveReplayAudioContents(creator.id!!, viewer.id!!, now, false, ContentSort.OWNED, 0, 20)
assertEquals(listOf(keepOnly.id, keepAndRental.id, rentalOnly.id, expiredRental.id), records.map { it.audioContentId })
assertEquals(listOf(true, true, false, false), records.map { it.isOwned })
assertEquals(listOf(false, true, true, false), records.map { it.isRented })
}
@Test
@DisplayName("현재 라이브 조회는 홈 API와 같은 성인/성별/크리에이터 입장 정책을 적용한다")
fun shouldFindCurrentLiveWithHomePolicy() {
val now = LocalDateTime.of(2026, 6, 17, 12, 0)
val creator = saveMember("current-live-creator", MemberRole.CREATOR)
val viewerCreator = saveMember("current-live-viewer", MemberRole.CREATOR)
saveLiveRoom(creator, now.minusMinutes(3), channelName = "adult", isAdult = true)
saveLiveRoom(
creator,
now.minusMinutes(4),
channelName = "male-only",
isAdult = false,
genderRestriction = GenderRestriction.MALE_ONLY
)
saveLiveRoom(
creator,
now.minusMinutes(5),
channelName = "creator-hidden",
isAdult = false,
isAvailableJoinCreator = false
)
val visible = saveLiveRoom(creator, now.minusMinutes(6), channelName = "visible", isAdult = false)
flushAndClear()
val live = repository.findCurrentLive(
creatorId = creator.id!!,
now = now,
canViewAdultContent = false,
viewerId = viewerCreator.id!!,
isViewerCreator = true,
effectiveViewerGender = Gender.FEMALE
)
assertEquals(visible.id, live!!.liveId)
}
@Test
@DisplayName("크리에이터 조회와 차단 관계 조회는 live service port 계약을 만족한다")
fun shouldFindCreatorAndBlockedRelationship() {
val viewer = saveMember("blocked-viewer", MemberRole.USER)
val creator = saveMember("blocked-creator", MemberRole.CREATOR)
saveBlock(creator, viewer)
flushAndClear()
val record = repository.findCreator(creator.id!!, viewer.id!!)
assertEquals(creator.id, record!!.creatorId)
assertEquals(MemberRole.CREATOR, record.role)
assertTrue(repository.existsBlockedBetween(viewer.id!!, creator.id!!))
}
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 saveLiveRoom(
creator: Member,
beginDateTime: LocalDateTime,
channelName: String?,
isAdult: Boolean,
isActive: Boolean = true,
genderRestriction: GenderRestriction = GenderRestriction.ALL,
isAvailableJoinCreator: Boolean = true
): LiveRoom {
val liveRoom = LiveRoom(
title = "live-${creator.nickname}-$beginDateTime",
notice = "notice",
beginDateTime = beginDateTime,
numberOfPeople = 0,
coverImage = "live.png",
isAdult = isAdult,
price = 50,
isAvailableJoinCreator = isAvailableJoinCreator,
genderRestriction = genderRestriction
)
liveRoom.member = creator
liveRoom.channelName = channelName
liveRoom.isActive = isActive
entityManager.persist(liveRoom)
return liveRoom
}
private fun saveAudioContent(
creator: Member,
releaseDate: LocalDateTime,
isAdult: Boolean,
theme: AudioContentTheme,
price: Int = 0,
isPointAvailable: Boolean = false
): AudioContent {
val content = AudioContent(
title = "audio-${creator.nickname}-$releaseDate",
detail = "detail",
languageCode = "ko",
releaseDate = releaseDate,
isAdult = isAdult,
price = price,
isPointAvailable = isPointAvailable
)
content.member = creator
content.theme = theme
content.isActive = true
content.coverImage = "audio.png"
content.duration = "00:10:00"
entityManager.persist(content)
return content
}
private fun saveTheme(name: String, isActive: Boolean = true): AudioContentTheme {
val theme = AudioContentTheme(theme = name, image = "$name.png", isActive = isActive)
entityManager.persist(theme)
return theme
}
private fun saveSeries(title: String, creator: Member, isOriginal: Boolean = false): Series {
val series = Series(title = title, introduction = "introduction", languageCode = "ko", isOriginal = isOriginal)
series.member = creator
series.genre = saveSeriesGenre(title)
series.coverImage = "$title.png"
entityManager.persist(series)
return series
}
private fun saveSeriesGenre(name: String): SeriesGenre {
val genre = SeriesGenre(genre = "genre-$name", isAdult = false, isActive = true)
entityManager.persist(genre)
return genre
}
private fun saveSeriesContent(series: Series, content: AudioContent): SeriesContent {
val seriesContent = SeriesContent()
seriesContent.series = series
seriesContent.content = content
entityManager.persist(seriesContent)
return seriesContent
}
private fun saveOrder(
member: Member,
creator: Member,
content: AudioContent,
type: OrderType,
isActive: Boolean = true,
endDate: LocalDateTime? = null,
can: Int? = null,
point: Int = 0
): Order {
val order = Order(type = type, isActive = isActive)
order.member = member
order.creator = creator
order.audioContent = content
can?.let { order.can = it }
order.point = point
entityManager.persist(order)
if (endDate != null) {
entityManager.flush()
order.endDate = endDate
}
return order
}
private fun flushAndClear() {
entityManager.flush()
entityManager.clear()
}
}

View File

@@ -0,0 +1,397 @@
package kr.co.vividnext.sodalive.v2.creator.channel.live.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Gender
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.auth.Auth
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.live.domain.CreatorChannelLiveReplayQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelLiveQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelLiveRecord
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.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 CreatorChannelLiveQueryServiceTest {
@Test
@DisplayName("라이브 탭 서비스는 검증 후 현재 라이브와 다시듣기 조회에 필요한 정책 컨텍스트를 전달한다")
fun shouldPassLiveTabQueryContextToPort() {
val port = FakeCreatorChannelLiveQueryPort()
val service = createService(port, canViewAdultContent = false)
val viewer = createMember(id = 10L, gender = Gender.FEMALE, authGender = 1)
val now = LocalDateTime.of(2026, 6, 17, 10, 0)
val tab = service.getLiveTab(
creatorId = 1L,
viewer = viewer,
sort = ContentSort.LATEST,
page = 0,
size = 20,
now = now
)
assertEquals(1L, port.findCreatorCreatorId)
assertEquals(10L, port.findCreatorViewerId)
assertEquals(10L, port.existsBlockedViewerId)
assertEquals(1L, port.existsBlockedCreatorId)
assertEquals(10L, port.currentLiveViewerId)
assertFalse(port.currentLiveIsViewerCreator == true)
assertEquals(Gender.MALE, port.currentLiveEffectiveViewerGender)
assertEquals(false, port.currentLiveCanViewAdultContent)
assertEquals(false, port.countCanViewAdultContent)
assertEquals(false, port.listCanViewAdultContent)
assertEquals(ContentSort.LATEST, port.listSort)
assertEquals(0L, port.listOffset)
assertEquals(21, port.listLimit)
assertEquals("https://cdn.test/live.png", tab.currentLive?.coverImageUrl)
assertEquals("https://cdn.test/audio/1.png", tab.liveReplayContents.first().imageUrl)
}
@Test
@DisplayName("라이브 탭 서비스는 size + 1개 조회 결과를 응답 size로 제한하고 hasNext를 true로 반환한다")
fun shouldAssembleLiveTabWithHasNextWhenFetchedMoreThanRequestedSize() {
val port = FakeCreatorChannelLiveQueryPort().apply {
liveReplayContents = (1L..21L).map { audioContentRecord(it) }
}
val service = createService(port)
val viewer = createMember(id = 10L)
val tab = service.getLiveTab(
creatorId = 1L,
viewer = viewer,
sort = ContentSort.LATEST,
page = 0,
size = 20,
now = LocalDateTime.of(2026, 6, 17, 10, 0)
)
assertEquals(30, tab.liveReplayContentCount)
assertEquals(20, tab.liveReplayContents.size)
assertEquals((1L..20L).toList(), tab.liveReplayContents.map { it.audioContentId })
assertTrue(tab.hasNext)
assertEquals(ContentSort.LATEST, tab.sort)
assertEquals(0, tab.page.page)
assertEquals(20, tab.page.size)
assertTrue(tab.liveReplayContents.first().isOwned)
assertFalse(tab.liveReplayContents.first().isRented)
assertTrue(tab.liveReplayContents[1].isRented)
}
@Test
@DisplayName("라이브 탭 서비스는 빈 page에서도 count와 요청 page 정보를 유지한다")
fun shouldKeepCountAndPageWhenReplayContentsAreEmpty() {
val port = FakeCreatorChannelLiveQueryPort().apply {
liveReplayContents = emptyList()
currentLive = null
}
val service = createService(port)
val viewer = createMember(id = 10L)
val tab = service.getLiveTab(
creatorId = 1L,
viewer = viewer,
sort = ContentSort.PRICE_LOW,
page = 2,
size = 20,
now = LocalDateTime.of(2026, 6, 17, 10, 0)
)
assertEquals(30, tab.liveReplayContentCount)
assertNull(tab.currentLive)
assertEquals(emptyList<Any>(), tab.liveReplayContents)
assertFalse(tab.hasNext)
assertEquals(ContentSort.PRICE_LOW, tab.sort)
assertEquals(2, tab.page.page)
assertEquals(20, tab.page.size)
assertEquals(40L, port.listOffset)
assertEquals(21, port.listLimit)
}
@Test
@DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다")
fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() {
val port = FakeCreatorChannelLiveQueryPort().apply {
creator = null
}
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getLiveTab(1L, viewer, ContentSort.LATEST, 0, 20, LocalDateTime.of(2026, 6, 17, 10, 0))
}
assertEquals("member.validation.user_not_found", exception.messageKey)
}
@Test
@DisplayName("대상 회원 role이 CREATOR가 아니면 creator_not_found 예외를 던진다")
fun shouldThrowCreatorNotFoundWhenMemberIsNotCreator() {
val port = FakeCreatorChannelLiveQueryPort().apply {
creator = creator?.copy(role = MemberRole.USER)
}
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getLiveTab(1L, viewer, ContentSort.LATEST, 0, 20, LocalDateTime.of(2026, 6, 17, 10, 0))
}
assertEquals("member.validation.creator_not_found", exception.messageKey)
}
@Test
@DisplayName("차단 관계가 있으면 대상 회원이 크리에이터가 아니어도 접근 차단 예외를 먼저 던진다")
fun shouldThrowBlockedAccessBeforeCreatorNotFoundWhenViewerAndTargetAreBlocked() {
val port = FakeCreatorChannelLiveQueryPort().apply {
creator = creator?.copy(role = MemberRole.USER)
blocked = true
}
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getLiveTab(1L, viewer, ContentSort.LATEST, 0, 20, LocalDateTime.of(2026, 6, 17, 10, 0))
}
assertNull(exception.messageKey)
assertEquals("creator님의 요청으로 채널 접근이 제한됩니다.", exception.message)
}
@Test
@DisplayName("잘못된 page 요청이면 invalid request 예외를 던진다")
fun shouldThrowInvalidRequestWhenPageIsNegative() {
val service = createServiceWithMissingPort()
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getLiveTab(1L, viewer, ContentSort.LATEST, -1, 20, LocalDateTime.of(2026, 6, 17, 10, 0))
}
assertEquals("common.error.invalid_request", exception.messageKey)
}
@Test
@DisplayName("잘못된 size 요청이면 port 조회 전에 invalid request 예외를 던진다")
fun shouldThrowInvalidRequestBeforePortLookupWhenSizeIsOutOfRange() {
val service = createServiceWithMissingPort()
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getLiveTab(1L, viewer, ContentSort.LATEST, 0, 51, LocalDateTime.of(2026, 6, 17, 10, 0))
}
assertEquals("common.error.invalid_request", exception.messageKey)
}
private fun createService(
port: FakeCreatorChannelLiveQueryPort,
canViewAdultContent: Boolean = true
): CreatorChannelLiveQueryService {
return createService(
portProvider = FixedCreatorChannelLiveQueryPortProvider(port),
canViewAdultContent = canViewAdultContent
)
}
private fun createServiceWithMissingPort(): CreatorChannelLiveQueryService {
return createService(portProvider = MissingCreatorChannelLiveQueryPortProvider())
}
private fun createService(
portProvider: ObjectProvider<CreatorChannelLiveQueryPort>,
canViewAdultContent: Boolean = true
): CreatorChannelLiveQueryService {
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 messageSource = SodaMessageSource()
val langContext = LangContext()
langContext.setLang(Lang.KO)
return CreatorChannelLiveQueryService(
queryPortProvider = portProvider,
queryPolicy = CreatorChannelLiveReplayQueryPolicy(),
memberContentPreferenceService = preferenceService,
messageSource = messageSource,
langContext = langContext,
cloudFrontHost = "https://cdn.test"
)
}
private fun createMember(
id: Long,
gender: Gender = Gender.NONE,
authGender: Int? = null
): Member {
val member = Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id",
provider = MemberProvider.EMAIL,
gender = gender
)
member.id = id
authGender?.let {
Auth(
name = "name",
birth = "19900101",
uniqueCi = "ci$id",
di = "di$id",
gender = it
).member = member
}
return member
}
}
private class FixedCreatorChannelLiveQueryPortProvider(
private val port: CreatorChannelLiveQueryPort
) : ObjectProvider<CreatorChannelLiveQueryPort> {
override fun getObject(vararg args: Any?): CreatorChannelLiveQueryPort = port
override fun getIfAvailable(): CreatorChannelLiveQueryPort = port
override fun getIfUnique(): CreatorChannelLiveQueryPort = port
override fun getObject(): CreatorChannelLiveQueryPort = port
}
private class MissingCreatorChannelLiveQueryPortProvider : ObjectProvider<CreatorChannelLiveQueryPort> {
override fun getObject(vararg args: Any?): CreatorChannelLiveQueryPort {
throw IllegalStateException("port should not be resolved before page validation")
}
override fun getIfAvailable(): CreatorChannelLiveQueryPort? = null
override fun getIfUnique(): CreatorChannelLiveQueryPort? = null
override fun getObject(): CreatorChannelLiveQueryPort {
throw IllegalStateException("port should not be resolved before page validation")
}
}
private class FakeCreatorChannelLiveQueryPort : CreatorChannelLiveQueryPort {
var creator: CreatorChannelCreatorRecord? = CreatorChannelCreatorRecord(
creatorId = 1L,
role = MemberRole.CREATOR,
nickname = "creator"
)
var blocked = false
var currentLive: CreatorChannelLiveRecord? = CreatorChannelLiveRecord(
liveId = 101L,
title = "live",
coverImagePath = "live.png",
beginDateTime = LocalDateTime.of(2026, 6, 17, 9, 0),
price = 10,
isAdult = false
)
var liveReplayContentCount = 30
var liveReplayContents = (1L..21L).map { audioContentRecord(it) }
var findCreatorCreatorId: Long? = null
var findCreatorViewerId: Long? = null
var existsBlockedViewerId: Long? = null
var existsBlockedCreatorId: Long? = null
var currentLiveViewerId: Long? = null
var currentLiveIsViewerCreator: Boolean? = null
var currentLiveEffectiveViewerGender: Gender? = null
var currentLiveCanViewAdultContent: Boolean? = null
var countCanViewAdultContent: Boolean? = null
var listCanViewAdultContent: Boolean? = null
var listSort: ContentSort? = null
var listOffset: Long? = null
var listLimit: Int? = null
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? {
findCreatorCreatorId = creatorId
findCreatorViewerId = viewerId
return creator
}
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean {
existsBlockedViewerId = viewerId
existsBlockedCreatorId = creatorId
return blocked
}
override fun findCurrentLive(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean,
viewerId: Long?,
isViewerCreator: Boolean,
effectiveViewerGender: Gender?
): CreatorChannelLiveRecord? {
currentLiveViewerId = viewerId
currentLiveIsViewerCreator = isViewerCreator
currentLiveEffectiveViewerGender = effectiveViewerGender
currentLiveCanViewAdultContent = canViewAdultContent
return currentLive
}
override fun countLiveReplayAudioContents(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int {
countCanViewAdultContent = canViewAdultContent
return liveReplayContentCount
}
override fun findLiveReplayAudioContents(
creatorId: Long,
viewerId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean,
sort: ContentSort,
offset: Long,
limit: Int
): List<CreatorChannelAudioContentRecord> {
listCanViewAdultContent = canViewAdultContent
listSort = sort
listOffset = offset
listLimit = limit
return liveReplayContents
}
}
private fun audioContentRecord(audioContentId: Long): CreatorChannelAudioContentRecord {
return CreatorChannelAudioContentRecord(
audioContentId = audioContentId,
title = "audio-$audioContentId",
duration = "00:10:00",
imagePath = "audio/$audioContentId.png",
price = 10,
isAdult = false,
isPointAvailable = true,
isFirstContent = audioContentId == 1L,
publishedAt = LocalDateTime.of(2026, 6, 16, 10, 0),
seriesName = "series",
isOriginalSeries = true,
isOwned = audioContentId == 1L,
isRented = audioContentId == 2L
)
}

View File

@@ -0,0 +1,76 @@
package kr.co.vividnext.sodalive.v2.creator.channel.live.domain
import kr.co.vividnext.sodalive.common.SodaException
import org.junit.jupiter.api.Assertions.assertEquals
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
class CreatorChannelLiveReplayQueryPolicyTest {
private val policy = CreatorChannelLiveReplayQueryPolicy()
@Test
@DisplayName("라이브 다시듣기 page 정책은 offset, fetch limit, items limit, hasNext를 계산한다")
fun shouldCalculatePagePolicyForLiveReplayContents() {
val page = policy.createPage(page = 0, size = 20)
val fetched = (1..21).toList()
val items = policy.limitItems(fetched, page)
assertEquals(0L, page.offset)
assertEquals(21, page.fetchLimit)
assertEquals(20, items.size)
assertEquals((1..20).toList(), items)
assertTrue(policy.hasNext(fetched, page))
}
@Test
@DisplayName("size가 50이면 fetch limit을 51로 계산한다")
fun shouldCalculateFetchLimitWhenSizeIsMaximum() {
val page = policy.createPage(page = 1, size = 50)
assertEquals(50L, page.offset)
assertEquals(51, page.fetchLimit)
}
@Test
@DisplayName("page가 0보다 작으면 invalid request 예외를 던진다")
fun shouldThrowInvalidRequestWhenPageIsNegative() {
val exception = assertThrows(SodaException::class.java) {
policy.createPage(page = -1, size = 20)
}
assertEquals("common.error.invalid_request", exception.messageKey)
}
@Test
@DisplayName("size가 20보다 작으면 invalid request 예외를 던진다")
fun shouldThrowInvalidRequestWhenSizeIsLessThanMinimum() {
val exception = assertThrows(SodaException::class.java) {
policy.createPage(page = 0, size = 19)
}
assertEquals("common.error.invalid_request", exception.messageKey)
}
@Test
@DisplayName("size가 50보다 크면 invalid request 예외를 던진다")
fun shouldThrowInvalidRequestWhenSizeIsGreaterThanMaximum() {
val exception = assertThrows(SodaException::class.java) {
policy.createPage(page = 0, size = 51)
}
assertEquals("common.error.invalid_request", exception.messageKey)
}
@Test
@DisplayName("size가 Int 최대값이면 fetch limit overflow 전에 invalid request 예외를 던진다")
fun shouldThrowInvalidRequestWhenSizeWouldOverflowFetchLimit() {
val exception = assertThrows(SodaException::class.java) {
policy.createPage(page = 0, size = Int.MAX_VALUE)
}
assertEquals("common.error.invalid_request", exception.messageKey)
}
}