Compare commits
28 Commits
a66f857373
...
36bd5365e0
| Author | SHA1 | Date | |
|---|---|---|---|
| 36bd5365e0 | |||
| d82c3561d5 | |||
| b3e43a79ef | |||
| 59c83138bb | |||
| b5809bbce6 | |||
| a1837e8933 | |||
| fa57bd211a | |||
| eded4ac39a | |||
| 06713cb460 | |||
| e525f9de64 | |||
| 08ba743066 | |||
| 9cdf51b17f | |||
| 85a331c28d | |||
| f78772b613 | |||
| 90c0af0c8b | |||
| 3d843ac5d6 | |||
| 108778d5d3 | |||
| 3e3642bb7f | |||
| 6a3ca5f44f | |||
| 2ea030e0d6 | |||
| 04cedac1fb | |||
| 81978442b2 | |||
| fe19be90f9 | |||
| 7e6ac283cb | |||
| 8f41198d91 | |||
| 013f012a4b | |||
| be28e9f6d0 | |||
| dbc48f2ec3 |
529
docs/20260617_크리에이터_채널_라이브_API/plan-task.md
Normal file
529
docs/20260617_크리에이터_채널_라이브_API/plan-task.md
Normal 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`가 모두 성공했다.
|
||||||
271
docs/20260617_크리에이터_채널_라이브_API/prd.md
Normal file
271
docs/20260617_크리에이터_채널_라이브_API/prd.md
Normal 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`로 내려준다.
|
||||||
299
docs/20260617_크리에이터_채널_홈_API_구조정렬/plan-task.md
Normal file
299
docs/20260617_크리에이터_채널_홈_API_구조정렬/plan-task.md
Normal 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건만 변경됐음을 확인했다.
|
||||||
130
docs/20260617_크리에이터_채널_홈_API_구조정렬/prd.md
Normal file
130
docs/20260617_크리에이터_채널_홈_API_구조정렬/prd.md
Normal 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
|
||||||
|
- 없음. 이번 범위는 동작 보존 리팩토링이며, 응답 계약이나 기능 정책 변경은 포함하지 않는다.
|
||||||
@@ -15,7 +15,8 @@
|
|||||||
- 각 task의 검증 기준에는 단일 테스트 실행 명령과 필요한 경우 전체 회귀 명령을 포함한다.
|
- 각 task의 검증 기준에는 단일 테스트 실행 명령과 필요한 경우 전체 회귀 명령을 포함한다.
|
||||||
- 구현 완료 즉시 해당 task 체크박스를 `- [x]`로 갱신한다.
|
- 구현 완료 즉시 해당 task 체크박스를 `- [x]`로 갱신한다.
|
||||||
- 작업 도중 범위가 변경되면 계획 문서 체크리스트를 먼저 업데이트한 뒤 구현한다.
|
- 작업 도중 범위가 변경되면 계획 문서 체크리스트를 먼저 업데이트한 뒤 구현한다.
|
||||||
- 결과 보고 시 문서 하단에 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)을 한국어로 남긴다.
|
- 결과 보고 시 개별 task 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)은 해당 task 아래에 한국어로 남긴다.
|
||||||
|
- 여러 task/phase에 걸친 회귀 검증, 전체 빌드/포맷 검증, 문서 변경 범위 확인처럼 전체에 해당하는 검증 기록은 문서 하단에 한국어로 남긴다.
|
||||||
- 후속 수정이 발생해도 기존 검증 기록은 삭제하거나 덮어쓰지 않고 누적한다.
|
- 후속 수정이 발생해도 기존 검증 기록은 삭제하거나 덮어쓰지 않고 누적한다.
|
||||||
- `build.gradle.kts` 변경 시 실행 명령 섹션을 함께 갱신한다.
|
- `build.gradle.kts` 변경 시 실행 명령 섹션을 함께 갱신한다.
|
||||||
- 테스트 클래스 추가/이동 시 단일 테스트 실행 예시를 최신 상태로 유지한다.
|
- 테스트 클래스 추가/이동 시 단일 테스트 실행 예시를 최신 상태로 유지한다.
|
||||||
|
|||||||
@@ -17,4 +17,5 @@
|
|||||||
- 변경 중: 공개 API 스키마를 임의 변경하지 말고, 작은 단위로 안전하게 수정한다.
|
- 변경 중: 공개 API 스키마를 임의 변경하지 말고, 작은 단위로 안전하게 수정한다.
|
||||||
- 변경 중: 구현 완료 즉시 해당 task 체크박스를 `- [x]`로 갱신한다.
|
- 변경 중: 구현 완료 즉시 해당 task 체크박스를 `- [x]`로 갱신한다.
|
||||||
- 변경 후: 최소 단일 테스트 또는 `./gradlew test`를 실행하고, 필요 시 `./gradlew ktlintCheck`를 수행한다.
|
- 변경 후: 최소 단일 테스트 또는 `./gradlew test`를 실행하고, 필요 시 `./gradlew ktlintCheck`를 수행한다.
|
||||||
- 변경 후: 계획 문서 하단에 무엇을, 왜, 어떻게 검증했는지, 실행 명령과 결과를 한국어로 누적 기록한다.
|
- 변경 후: 각 task의 검증 결과는 해당 task 아래에 무엇을, 왜, 어떻게 검증했는지, 실행 명령과 결과를 한국어로 누적 기록한다.
|
||||||
|
- 변경 후: 여러 task/phase에 걸친 회귀 검증, 전체 빌드/포맷 검증, 문서 변경 범위 확인처럼 전체에 해당하는 검증은 계획 문서 하단의 검증 기록에 누적한다.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
### Phase 2: 문서 규칙 갱신
|
### Phase 2: 문서 규칙 갱신
|
||||||
- [x] **Task 2.1: PRD 문서에 후속 요구사항 누적**
|
- [x] **Task 2.1: PRD 문서에 후속 요구사항 누적**
|
||||||
- 파일 경로: `docs/prd/20260513_에이전트문서작업절차개선_prd.md`
|
- 파일 경로: `docs/prd/20260513_에이전트문서작업절차개선_prd.md`
|
||||||
- 검증 기준: 새 폴더 구조, phase/task 형식, 검증 기록 누적, 가이드 분리 요구사항이 포함된다.
|
- 검증 기준: 새 폴더 구조, phase/task 형식, task별 검증 기록과 전체 검증 기록 구분, 가이드 분리 요구사항이 포함된다.
|
||||||
- [x] **Task 2.2: AGENTS.md 핵심 링크 갱신**
|
- [x] **Task 2.2: AGENTS.md 핵심 링크 갱신**
|
||||||
- 파일 경로: `AGENTS.md`
|
- 파일 경로: `AGENTS.md`
|
||||||
- 검증 기준: 실행 명령어와 커밋 메시지 상세 규칙을 직접 중복하지 않고 별도 agent-guides 문서를 참조한다.
|
- 검증 기준: 실행 명령어와 커밋 메시지 상세 규칙을 직접 중복하지 않고 별도 agent-guides 문서를 참조한다.
|
||||||
@@ -22,13 +22,20 @@
|
|||||||
- 검증 기준: PRD 작성, 사용자 인터뷰, 계획/TASK 작성 후 구현, 범위 변경 시 계획 선갱신 절차가 포함된다.
|
- 검증 기준: PRD 작성, 사용자 인터뷰, 계획/TASK 작성 후 구현, 범위 변경 시 계획 선갱신 절차가 포함된다.
|
||||||
- [x] **Task 2.4: 문서 유지보수 가이드 갱신**
|
- [x] **Task 2.4: 문서 유지보수 가이드 갱신**
|
||||||
- 파일 경로: `docs/agent-guides/문서유지보수.md`
|
- 파일 경로: `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: 실행 명령어 가이드 분리**
|
- [x] **Task 2.5: 실행 명령어 가이드 분리**
|
||||||
- 파일 경로: `docs/agent-guides/실행명령어.md`
|
- 파일 경로: `docs/agent-guides/실행명령어.md`
|
||||||
- 검증 기준: Gradle 실행 명령어가 별도 문서에 정리된다.
|
- 검증 기준: Gradle 실행 명령어가 별도 문서에 정리된다.
|
||||||
- [x] **Task 2.6: 커밋 메시지 가이드 분리**
|
- [x] **Task 2.6: 커밋 메시지 가이드 분리**
|
||||||
- 파일 경로: `docs/agent-guides/커밋메시지.md`
|
- 파일 경로: `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: 검증
|
### Phase 3: 검증
|
||||||
- [x] **Task 3.1: 문서 변경 내용 확인**
|
- [x] **Task 3.1: 문서 변경 내용 확인**
|
||||||
@@ -39,6 +46,14 @@
|
|||||||
- 파일 경로: `build.gradle.kts`, `settings.gradle.kts`
|
- 파일 경로: `build.gradle.kts`, `settings.gradle.kts`
|
||||||
- 실행 명령: `./gradlew tasks --all`
|
- 실행 명령: `./gradlew tasks --all`
|
||||||
- 기대 결과: Gradle task 목록 조회가 성공한다.
|
- 기대 결과: 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/계획 작성
|
- 1차 PRD/계획 작성
|
||||||
@@ -53,3 +68,7 @@
|
|||||||
- 무엇을: 문서 저장 규칙을 `docs/[날짜]_구현할내용한글/prd.md`, `docs/[날짜]_구현할내용한글/plan-task.md` 형식으로 변경하고, 계획/TASK phase 형식과 검증 기록 누적 규칙을 보강했다. 실행 명령어와 커밋 메시지 규칙은 각각 `docs/agent-guides/실행명령어.md`, `docs/agent-guides/커밋메시지.md`로 분리했다.
|
- 무엇을: 문서 저장 규칙을 `docs/[날짜]_구현할내용한글/prd.md`, `docs/[날짜]_구현할내용한글/plan-task.md` 형식으로 변경하고, 계획/TASK phase 형식과 검증 기록 누적 규칙을 보강했다. 실행 명령어와 커밋 메시지 규칙은 각각 `docs/agent-guides/실행명령어.md`, `docs/agent-guides/커밋메시지.md`로 분리했다.
|
||||||
- 왜: 사용자 요청에 따라 구현 전 PRD/계획 문서 준비 절차를 더 명확히 하고, `AGENTS.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`를 확인했다.
|
- 어떻게: `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`로 반영 내용과 변경 범위를 확인했다.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
- PRD와 구현 계획/TASK 문서 저장 위치를 `docs/[날짜]_구현할내용한글/`로 명확히 한다.
|
- PRD와 구현 계획/TASK 문서 저장 위치를 `docs/[날짜]_구현할내용한글/`로 명확히 한다.
|
||||||
- PRD 파일명은 `prd.md`, 구현 계획/TASK 파일명은 `plan-task.md`로 고정한다.
|
- PRD 파일명은 `prd.md`, 구현 계획/TASK 파일명은 `plan-task.md`로 고정한다.
|
||||||
- 애매한 요구사항은 PRD 단계에서 사용자 인터뷰를 반복해 해소하도록 명시한다.
|
- 애매한 요구사항은 PRD 단계에서 사용자 인터뷰를 반복해 해소하도록 명시한다.
|
||||||
- 구현 계획/TASK 문서의 phase, task 체크박스, 파일 경로, 검증 기준, 검증 기록 누적 규칙을 명확히 한다.
|
- 구현 계획/TASK 문서의 phase, task 체크박스, 파일 경로, 검증 기준, task별 검증 기록과 전체 검증 기록 누적 규칙을 명확히 한다.
|
||||||
- 실행 명령어와 커밋 메시지 규칙을 별도 `docs/agent-guides/` 문서로 분리한다.
|
- 실행 명령어와 커밋 메시지 규칙을 별도 `docs/agent-guides/` 문서로 분리한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -60,7 +60,8 @@
|
|||||||
- 각 phase 또는 task에는 실행 명령, 기대 결과, 수동 확인 항목 등 검증 기준을 함께 작성한다.
|
- 각 phase 또는 task에는 실행 명령, 기대 결과, 수동 확인 항목 등 검증 기준을 함께 작성한다.
|
||||||
- 작업 도중 범위가 변경되면 계획 문서 체크리스트를 먼저 업데이트한 뒤 구현한다.
|
- 작업 도중 범위가 변경되면 계획 문서 체크리스트를 먼저 업데이트한 뒤 구현한다.
|
||||||
- 구현 완료 즉시 체크박스를 `- [x]`로 갱신한다.
|
- 구현 완료 즉시 체크박스를 `- [x]`로 갱신한다.
|
||||||
- 결과 보고 시 문서 하단에 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)을 한국어로 남긴다.
|
- 개별 task 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)은 해당 task 아래에 한국어로 남긴다.
|
||||||
|
- 여러 task/phase에 걸친 회귀 검증, 전체 빌드/포맷 검증, 문서 변경 범위 확인처럼 전체에 해당하는 검증 기록은 문서 하단에 한국어로 남긴다.
|
||||||
- 후속 수정이 발생해도 기존 검증 기록은 삭제하거나 덮어쓰지 않고 누적한다.
|
- 후속 수정이 발생해도 기존 검증 기록은 삭제하거나 덮어쓰지 않고 누적한다.
|
||||||
|
|
||||||
### 상세 가이드 분리
|
### 상세 가이드 분리
|
||||||
|
|||||||
@@ -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.ApiResponse
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryService
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacade
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.dto.CreatorChannelHomeResponse
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
@@ -14,7 +13,7 @@ import org.springframework.web.bind.annotation.RestController
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v2/creator-channels")
|
@RequestMapping("/api/v2/creator-channels")
|
||||||
class CreatorChannelHomeController(
|
class CreatorChannelHomeController(
|
||||||
private val creatorChannelHomeQueryService: CreatorChannelHomeQueryService
|
private val creatorChannelHomeFacade: CreatorChannelHomeFacade
|
||||||
) {
|
) {
|
||||||
@GetMapping("/{creatorId}/home")
|
@GetMapping("/{creatorId}/home")
|
||||||
fun getHome(
|
fun getHome(
|
||||||
@@ -22,11 +21,9 @@ class CreatorChannelHomeController(
|
|||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
) = run {
|
) = run {
|
||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
CreatorChannelHomeResponse.from(
|
creatorChannelHomeFacade.getHome(
|
||||||
creatorChannelHomeQueryService.getHome(
|
creatorId = creatorId,
|
||||||
creatorId = creatorId,
|
viewer = requireMember(member)
|
||||||
viewer = requireMember(member)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
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.home.domain.CreatorChannelActivity
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelAudioContent
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelAudioContent
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCommunityPost
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCommunityPost
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCreator
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCreator
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelDonation
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelDonation
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalk
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalk
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalkSummary
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalkSummary
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHome
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHome
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelLive
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelLive
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSchedule
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSchedule
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSeries
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSeries
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSns
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSns
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
@@ -122,7 +122,11 @@ data class CreatorChannelAudioContentResponse(
|
|||||||
val isFirstContent: Boolean,
|
val isFirstContent: Boolean,
|
||||||
val seriesName: String?,
|
val seriesName: String?,
|
||||||
@JsonProperty("isOriginalSeries")
|
@JsonProperty("isOriginalSeries")
|
||||||
val isOriginalSeries: Boolean?
|
val isOriginalSeries: Boolean?,
|
||||||
|
@JsonProperty("isOwned")
|
||||||
|
val isOwned: Boolean,
|
||||||
|
@JsonProperty("isRented")
|
||||||
|
val isRented: Boolean
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun from(audioContent: CreatorChannelAudioContent): CreatorChannelAudioContentResponse {
|
fun from(audioContent: CreatorChannelAudioContent): CreatorChannelAudioContentResponse {
|
||||||
@@ -136,7 +140,9 @@ data class CreatorChannelAudioContentResponse(
|
|||||||
isPointAvailable = audioContent.isPointAvailable,
|
isPointAvailable = audioContent.isPointAvailable,
|
||||||
isFirstContent = audioContent.isFirstContent,
|
isFirstContent = audioContent.isFirstContent,
|
||||||
seriesName = audioContent.seriesName,
|
seriesName = audioContent.seriesName,
|
||||||
isOriginalSeries = audioContent.isOriginalSeries
|
isOriginalSeries = audioContent.isOriginalSeries,
|
||||||
|
isOwned = audioContent.isOwned,
|
||||||
|
isRented = audioContent.isRented
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.common.domain
|
||||||
|
|
||||||
|
enum class ContentSort {
|
||||||
|
LATEST,
|
||||||
|
POPULAR,
|
||||||
|
OWNED,
|
||||||
|
PRICE_HIGH,
|
||||||
|
PRICE_LOW
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
@@ -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.Projections
|
||||||
import com.querydsl.core.types.dsl.BooleanExpression
|
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.chat.character.QChatCharacter.chatCharacter
|
||||||
import kr.co.vividnext.sodalive.content.ContentType
|
import kr.co.vividnext.sodalive.content.ContentType
|
||||||
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
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.QSeries.series
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers.creatorCheers
|
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.block.QBlockMember
|
||||||
import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing
|
import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
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.home.port.out.CreatorChannelActivityRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelAudioContentRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelAudioContentRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCommunityPostRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelCommunityPostRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCreatorRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelCreatorRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelDonationRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelDonationRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelFanTalkRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkSummaryRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelFanTalkSummaryRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelLiveRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelLiveRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelScheduleRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelScheduleRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSeriesRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.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.CreatorChannelSnsRecord
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
@@ -155,12 +157,15 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
override fun findLatestAudioContent(
|
override fun findLatestAudioContent(
|
||||||
creatorId: Long,
|
creatorId: Long,
|
||||||
now: LocalDateTime,
|
now: LocalDateTime,
|
||||||
canViewAdultContent: Boolean
|
canViewAdultContent: Boolean,
|
||||||
|
viewerId: Long?
|
||||||
): CreatorChannelAudioContentRecord? {
|
): CreatorChannelAudioContentRecord? {
|
||||||
val row = findAudioContentRows(creatorId, now, null, canViewAdultContent, 1).firstOrNull() ?: return null
|
val row = findAudioContentRows(creatorId, now, null, canViewAdultContent, 1).firstOrNull() ?: return null
|
||||||
|
val contentId = itAudioId(row)
|
||||||
return row.toAudioRecord(
|
return row.toAudioRecord(
|
||||||
firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent),
|
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,
|
now: LocalDateTime,
|
||||||
latestAudioContentId: Long?,
|
latestAudioContentId: Long?,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
|
viewerId: Long?,
|
||||||
limit: Int
|
limit: Int
|
||||||
): List<CreatorChannelAudioContentRecord> {
|
): List<CreatorChannelAudioContentRecord> {
|
||||||
val rows = findAudioContentRows(creatorId, now, latestAudioContentId, canViewAdultContent, limit)
|
val rows = findAudioContentRows(creatorId, now, latestAudioContentId, canViewAdultContent, limit)
|
||||||
|
val contentIds = rows.map { itAudioId(it) }
|
||||||
val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent)
|
val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent)
|
||||||
val seriesByContentId = audioSeriesByContentIds(rows.map { itAudioId(it) })
|
val seriesByContentId = audioSeriesByContentIds(contentIds)
|
||||||
return rows.map { it.toAudioRecord(firstContentId, seriesByContentId) }
|
val orderStatesByContentId = orderStatesByContentIds(viewerId, contentIds, now)
|
||||||
|
return rows.map { it.toAudioRecord(firstContentId, seriesByContentId, orderStatesByContentId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findSeries(
|
override fun findSeries(
|
||||||
@@ -549,10 +557,12 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
|
|
||||||
private fun com.querydsl.core.Tuple.toAudioRecord(
|
private fun com.querydsl.core.Tuple.toAudioRecord(
|
||||||
firstContentId: Long?,
|
firstContentId: Long?,
|
||||||
seriesByContentId: Map<Long, AudioSeriesSummary>
|
seriesByContentId: Map<Long, AudioSeriesSummary>,
|
||||||
|
orderStatesByContentId: Map<Long, AudioOrderState>
|
||||||
): CreatorChannelAudioContentRecord {
|
): CreatorChannelAudioContentRecord {
|
||||||
val audioContentId = get(audioContent.id)!!
|
val audioContentId = get(audioContent.id)!!
|
||||||
val seriesSummary = seriesByContentId[audioContentId]
|
val seriesSummary = seriesByContentId[audioContentId]
|
||||||
|
val orderState = orderStatesByContentId[audioContentId]
|
||||||
return CreatorChannelAudioContentRecord(
|
return CreatorChannelAudioContentRecord(
|
||||||
audioContentId = audioContentId,
|
audioContentId = audioContentId,
|
||||||
title = get(audioContent.title)!!,
|
title = get(audioContent.title)!!,
|
||||||
@@ -564,10 +574,39 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
isFirstContent = firstContentId == audioContentId,
|
isFirstContent = firstContentId == audioContentId,
|
||||||
publishedAt = get(audioContent.releaseDate)!!,
|
publishedAt = get(audioContent.releaseDate)!!,
|
||||||
seriesName = seriesSummary?.title,
|
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> {
|
private fun audioSeriesByContentIds(contentIds: List<Long>): Map<Long, AudioSeriesSummary> {
|
||||||
if (contentIds.isEmpty()) return emptyMap()
|
if (contentIds.isEmpty()) return emptyMap()
|
||||||
return queryFactory
|
return queryFactory
|
||||||
@@ -908,6 +947,11 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
val isOriginal: Boolean
|
val isOriginal: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class AudioOrderState(
|
||||||
|
val isOwned: Boolean,
|
||||||
|
val isRented: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
private data class SeriesContentStats(
|
private data class SeriesContentStats(
|
||||||
val contentCount: Int,
|
val contentCount: Int,
|
||||||
val latestPublishedAt: LocalDateTime
|
val latestPublishedAt: LocalDateTime
|
||||||
@@ -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.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
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.MemberRole
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
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.home.domain.CreatorChannelActivity
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelAudioContent
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelAudioContent
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCommunityPost
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCommunityPost
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCreator
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCreator
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelDonation
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelDonation
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalk
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalk
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalkSummary
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalkSummary
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHome
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHome
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicy
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHomeQueryPolicy
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelLive
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelLive
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSchedule
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSchedule
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSeries
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSeries
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSns
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSns
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelActivityRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelActivityRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelAudioContentRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelAudioContentRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCommunityPostRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelCommunityPostRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCreatorRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelCreatorRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelDonationRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelDonationRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelFanTalkRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkSummaryRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelFanTalkSummaryRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelHomeQueryPort
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelHomeQueryPort
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelLiveRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelLiveRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelScheduleRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelScheduleRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSeriesRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.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.CreatorChannelSnsRecord
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
@@ -72,14 +72,15 @@ class CreatorChannelHomeQueryService(
|
|||||||
val isViewerCreator = viewerId == creatorId
|
val isViewerCreator = viewerId == creatorId
|
||||||
val effectiveViewerGender = viewer.effectiveGender()
|
val effectiveViewerGender = viewer.effectiveGender()
|
||||||
val latestAudioContent = queryPort
|
val latestAudioContent = queryPort
|
||||||
.findLatestAudioContent(creatorId, now, canViewAdultContent)
|
.findLatestAudioContent(creatorId, now, canViewAdultContent, viewerId)
|
||||||
?.toDomain()
|
?.toDomain()
|
||||||
val audioContents = queryPolicy.excludeLatestAudioContent(
|
val audioContents = queryPolicy.excludeLatestAudioContent(
|
||||||
queryPort.findAudioContents(
|
queryPort.findAudioContents(
|
||||||
creatorId = creatorId,
|
creatorId = creatorId,
|
||||||
now = now,
|
now = now,
|
||||||
latestAudioContentId = latestAudioContent?.audioContentId,
|
latestAudioContentId = latestAudioContent?.audioContentId,
|
||||||
canViewAdultContent = canViewAdultContent
|
canViewAdultContent = canViewAdultContent,
|
||||||
|
viewerId = viewerId
|
||||||
).map { it.toDomain() },
|
).map { it.toDomain() },
|
||||||
latestAudioContent?.audioContentId
|
latestAudioContent?.audioContentId
|
||||||
)
|
)
|
||||||
@@ -179,7 +180,9 @@ class CreatorChannelHomeQueryService(
|
|||||||
isFirstContent = isFirstContent,
|
isFirstContent = isFirstContent,
|
||||||
publishedAt = publishedAt,
|
publishedAt = publishedAt,
|
||||||
seriesName = seriesName,
|
seriesName = seriesName,
|
||||||
isOriginalSeries = isOriginalSeries
|
isOriginalSeries = isOriginalSeries,
|
||||||
|
isOwned = isOwned,
|
||||||
|
isRented = isRented
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation(
|
private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation(
|
||||||
@@ -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 kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
@@ -51,7 +51,9 @@ data class CreatorChannelAudioContent(
|
|||||||
val isFirstContent: Boolean,
|
val isFirstContent: Boolean,
|
||||||
val publishedAt: LocalDateTime,
|
val publishedAt: LocalDateTime,
|
||||||
val seriesName: String?,
|
val seriesName: String?,
|
||||||
val isOriginalSeries: Boolean?
|
val isOriginalSeries: Boolean?,
|
||||||
|
val isOwned: Boolean,
|
||||||
|
val isRented: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
data class CreatorChannelDonation(
|
data class CreatorChannelDonation(
|
||||||
@@ -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 kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
@@ -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.content.ContentType
|
||||||
import kr.co.vividnext.sodalive.member.Gender
|
import kr.co.vividnext.sodalive.member.Gender
|
||||||
@@ -23,7 +23,8 @@ interface CreatorChannelHomeQueryPort {
|
|||||||
fun findLatestAudioContent(
|
fun findLatestAudioContent(
|
||||||
creatorId: Long,
|
creatorId: Long,
|
||||||
now: LocalDateTime,
|
now: LocalDateTime,
|
||||||
canViewAdultContent: Boolean
|
canViewAdultContent: Boolean,
|
||||||
|
viewerId: Long? = null
|
||||||
): CreatorChannelAudioContentRecord?
|
): CreatorChannelAudioContentRecord?
|
||||||
|
|
||||||
fun findChannelDonations(
|
fun findChannelDonations(
|
||||||
@@ -56,6 +57,7 @@ interface CreatorChannelHomeQueryPort {
|
|||||||
now: LocalDateTime,
|
now: LocalDateTime,
|
||||||
latestAudioContentId: Long?,
|
latestAudioContentId: Long?,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
|
viewerId: Long? = null,
|
||||||
limit: Int = 9
|
limit: Int = 9
|
||||||
): List<CreatorChannelAudioContentRecord>
|
): List<CreatorChannelAudioContentRecord>
|
||||||
|
|
||||||
@@ -109,7 +111,9 @@ data class CreatorChannelAudioContentRecord(
|
|||||||
val isFirstContent: Boolean,
|
val isFirstContent: Boolean,
|
||||||
val publishedAt: LocalDateTime,
|
val publishedAt: LocalDateTime,
|
||||||
val seriesName: String?,
|
val seriesName: String?,
|
||||||
val isOriginalSeries: Boolean?
|
val isOriginalSeries: Boolean?,
|
||||||
|
val isOwned: Boolean,
|
||||||
|
val isRented: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
data class CreatorChannelDonationRecord(
|
data class CreatorChannelDonationRecord(
|
||||||
@@ -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
|
||||||
@@ -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 = "다시듣기"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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.common.CountryContext
|
||||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
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.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
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.common.domain.CreatorActivityType
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryService
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelActivity
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelActivity
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelAudioContent
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelAudioContent
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCommunityPost
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCommunityPost
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCreator
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCreator
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelDonation
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelDonation
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalk
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalk
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalkSummary
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalkSummary
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHome
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHome
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelLive
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelLive
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSchedule
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSchedule
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSeries
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSeries
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSns
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSns
|
|
||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.mockito.Mockito
|
import org.mockito.Mockito
|
||||||
@@ -48,7 +49,7 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
|
|||||||
private val mockMvc: MockMvc
|
private val mockMvc: MockMvc
|
||||||
) {
|
) {
|
||||||
@MockBean
|
@MockBean
|
||||||
private lateinit var service: CreatorChannelHomeQueryService
|
private lateinit var facade: CreatorChannelHomeFacade
|
||||||
|
|
||||||
@MockBean
|
@MockBean
|
||||||
private lateinit var countryContext: CountryContext
|
private lateinit var countryContext: CountryContext
|
||||||
@@ -87,10 +88,10 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("크리에이터 채널 홈 조회는 인증 회원과 creatorId를 서비스에 전달하고 성공 응답을 반환한다")
|
@DisplayName("크리에이터 채널 홈 조회는 인증 회원과 creatorId를 facade에 전달하고 성공 응답을 반환한다")
|
||||||
fun shouldReturnCreatorChannelHomeForAuthenticatedMember() {
|
fun shouldReturnCreatorChannelHomeForAuthenticatedMember() {
|
||||||
val viewer = createMember(id = 10L)
|
val viewer = createMember(id = 10L)
|
||||||
Mockito.doReturn(createHome()).`when`(service).getHome(
|
Mockito.doReturn(CreatorChannelHomeResponse.from(createHome())).`when`(facade).getHome(
|
||||||
Mockito.eq(1L),
|
Mockito.eq(1L),
|
||||||
Mockito.any(Member::class.java) ?: viewer,
|
Mockito.any(Member::class.java) ?: viewer,
|
||||||
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
|
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.isPointAvailable").value(true))
|
||||||
.andExpect(jsonPath("$.data.latestAudioContent.isFirstContent").value(true))
|
.andExpect(jsonPath("$.data.latestAudioContent.isFirstContent").value(true))
|
||||||
.andExpect(jsonPath("$.data.latestAudioContent.isOriginalSeries").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.currentLive.isAdult").value(true))
|
||||||
.andExpect(jsonPath("$.data.schedules[0].isAdult").doesNotExist())
|
.andExpect(jsonPath("$.data.schedules[0].isAdult").doesNotExist())
|
||||||
.andExpect(jsonPath("$.data.channelDonations[0].donationId").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].isNew").value(true))
|
||||||
.andExpect(jsonPath("$.data.series[0].isOriginal").value(true))
|
.andExpect(jsonPath("$.data.series[0].isOriginal").value(true))
|
||||||
|
|
||||||
Mockito.verify(service).getHome(
|
Mockito.verify(facade).getHome(
|
||||||
Mockito.eq(1L),
|
Mockito.eq(1L),
|
||||||
Mockito.eq(viewer) ?: viewer,
|
Mockito.eq(viewer) ?: viewer,
|
||||||
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
|
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
|
||||||
@@ -195,7 +200,9 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
|
|||||||
isFirstContent = true,
|
isFirstContent = true,
|
||||||
publishedAt = LocalDateTime.of(2026, 6, 11, 1, 0),
|
publishedAt = LocalDateTime.of(2026, 6, 11, 1, 0),
|
||||||
seriesName = "series",
|
seriesName = "series",
|
||||||
isOriginalSeries = true
|
isOriginalSeries = true,
|
||||||
|
isOwned = true,
|
||||||
|
isRented = false
|
||||||
),
|
),
|
||||||
channelDonations = listOf(
|
channelDonations = listOf(
|
||||||
CreatorChannelDonation(
|
CreatorChannelDonation(
|
||||||
@@ -228,7 +235,9 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
|
|||||||
isFirstContent = false,
|
isFirstContent = false,
|
||||||
publishedAt = LocalDateTime.of(2026, 6, 10, 1, 0),
|
publishedAt = LocalDateTime.of(2026, 6, 10, 1, 0),
|
||||||
seriesName = null,
|
seriesName = null,
|
||||||
isOriginalSeries = null
|
isOriginalSeries = null,
|
||||||
|
isOwned = false,
|
||||||
|
isRented = true
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
series = listOf(
|
series = listOf(
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
|
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.configs.QueryDslConfig
|
||||||
import kr.co.vividnext.sodalive.content.AudioContent
|
import kr.co.vividnext.sodalive.content.AudioContent
|
||||||
import kr.co.vividnext.sodalive.content.ContentType
|
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.content.theme.AudioContentTheme
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
|
||||||
@@ -128,7 +130,7 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
|
|||||||
@DisplayName("홈 repository 조회는 Phase 3 projection/bulk 최적화 대상에서 entity 전체 fetch와 per-row helper를 사용하지 않는다")
|
@DisplayName("홈 repository 조회는 Phase 3 projection/bulk 최적화 대상에서 entity 전체 fetch와 per-row helper를 사용하지 않는다")
|
||||||
fun shouldUseProjectionAndBulkQueriesForPhaseThreeOptimizedMethods() {
|
fun shouldUseProjectionAndBulkQueriesForPhaseThreeOptimizedMethods() {
|
||||||
val source = Paths.get(
|
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"
|
"DefaultCreatorChannelHomeQueryRepository.kt"
|
||||||
)
|
)
|
||||||
.toFile()
|
.toFile()
|
||||||
@@ -209,6 +211,8 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
|
|||||||
val latestAudio = saveAudioContent(creator, now.minusDays(1), isAdult = false, price = 200)
|
val latestAudio = saveAudioContent(creator, now.minusDays(1), isAdult = false, price = 200)
|
||||||
val series = saveSeries("integrated-home-series", creator, isOriginal = true)
|
val series = saveSeries("integrated-home-series", creator, isOriginal = true)
|
||||||
saveSeriesContent(series, listAudio)
|
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 donation = saveDonation(creator, donor, 500, now.minusHours(3), additionalMessage = "integrated thanks")
|
||||||
val notice = saveCommunity(creator, isFixed = true, fixedAt = now.minusHours(4), price = 0)
|
val notice = saveCommunity(creator, isFixed = true, fixedAt = now.minusHours(4), price = 0)
|
||||||
val community = saveCommunity(
|
val community = saveCommunity(
|
||||||
@@ -236,7 +240,12 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
|
|||||||
isViewerCreator = false,
|
isViewerCreator = false,
|
||||||
effectiveViewerGender = null
|
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 donations = repository.findChannelDonations(creator.id!!, viewer.id!!, now, limit = 8)
|
||||||
val notices = repository.findCommunityPosts(creator.id!!, viewer.id!!, isFixed = true, false, limit = 3)
|
val notices = repository.findCommunityPosts(creator.id!!, viewer.id!!, isFixed = true, false, limit = 3)
|
||||||
val schedules = repository.findSchedules(
|
val schedules = repository.findSchedules(
|
||||||
@@ -253,6 +262,7 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
|
|||||||
now,
|
now,
|
||||||
latestAudioContentId = latestAudioRecord!!.audioContentId,
|
latestAudioContentId = latestAudioRecord!!.audioContentId,
|
||||||
canViewAdultContent = false,
|
canViewAdultContent = false,
|
||||||
|
viewerId = viewer.id!!,
|
||||||
limit = 9
|
limit = 9
|
||||||
)
|
)
|
||||||
val seriesRecords = repository.findSeries(creator.id!!, viewer.id!!, now, false, ContentType.ALL, limit = 8)
|
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("integrated introduce", creatorRecord.introduce)
|
||||||
assertEquals(currentLive.id, currentLiveRecord!!.liveId)
|
assertEquals(currentLive.id, currentLiveRecord!!.liveId)
|
||||||
assertEquals(latestAudio.id, latestAudioRecord.audioContentId)
|
assertEquals(latestAudio.id, latestAudioRecord.audioContentId)
|
||||||
|
assertTrue(latestAudioRecord.isOwned)
|
||||||
|
assertFalse(latestAudioRecord.isRented)
|
||||||
assertEquals(listOf(donation.can), donations.map { it.can })
|
assertEquals(listOf(donation.can), donations.map { it.can })
|
||||||
assertEquals("integrated thanks", donations.single().message)
|
assertEquals("integrated thanks", donations.single().message)
|
||||||
assertEquals(listOf(notice.id), notices.map { it.postId })
|
assertEquals(listOf(notice.id), notices.map { it.postId })
|
||||||
assertEquals(listOf(liveSchedule.id, audioSchedule.id), schedules.map { it.targetId })
|
assertEquals(listOf(liveSchedule.id, audioSchedule.id), schedules.map { it.targetId })
|
||||||
assertEquals(listOf(CreatorActivityType.LIVE, CreatorActivityType.AUDIO), schedules.map { it.type })
|
assertEquals(listOf(CreatorActivityType.LIVE, CreatorActivityType.AUDIO), schedules.map { it.type })
|
||||||
assertEquals(listOf(listAudio.id, firstAudio.id), audioContents.map { it.audioContentId })
|
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(listOf(series.id), seriesRecords.map { it.seriesId })
|
||||||
assertEquals(true, seriesRecords.single().isOriginal)
|
assertEquals(true, seriesRecords.single().isOriginal)
|
||||||
assertEquals(listOf(community.id), communities.map { it.postId })
|
assertEquals(listOf(community.id), communities.map { it.postId })
|
||||||
@@ -526,6 +540,44 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
|
|||||||
assertTrue(records.last().isPointAvailable)
|
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
|
@Test
|
||||||
@DisplayName("오디오 목록은 releaseDate가 null인 콘텐츠를 제외한다")
|
@DisplayName("오디오 목록은 releaseDate가 null인 콘텐츠를 제외한다")
|
||||||
fun shouldExcludeNullReleaseDateAudioContent() {
|
fun shouldExcludeNullReleaseDateAudioContent() {
|
||||||
@@ -1441,6 +1493,23 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
|
|||||||
return useCan
|
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(
|
private fun saveCheers(
|
||||||
member: Member,
|
member: Member,
|
||||||
creator: Member,
|
creator: Member,
|
||||||
@@ -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 com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
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.auth.Auth
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
|
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.common.domain.CreatorActivityType
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelActivity
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelActivity
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelAudioContent
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelAudioContent
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCommunityPost
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCommunityPost
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCreator
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCreator
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelDonation
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelDonation
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalk
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalk
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalkSummary
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalkSummary
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHome
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHome
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicy
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHomeQueryPolicy
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelLive
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelLive
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSchedule
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSchedule
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSeries
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSeries
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSns
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSns
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.dto.CreatorChannelHomeResponse
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelActivityRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelActivityRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelAudioContentRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelAudioContentRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelCommunityPostRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCommunityPostRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelCreatorRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCreatorRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelDonationRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelDonationRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelFanTalkRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelFanTalkSummaryRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkSummaryRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelHomeQueryPort
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelHomeQueryPort
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelLiveRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelLiveRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelScheduleRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelScheduleRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelSeriesRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSeriesRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelSnsRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSnsRecord
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
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/profile/creator.png", home.creator.profileImageUrl)
|
||||||
assertEquals("https://cdn.test/live.png", home.currentLive?.coverImageUrl)
|
assertEquals("https://cdn.test/live.png", home.currentLive?.coverImageUrl)
|
||||||
assertEquals("https://cdn.test/audio/latest.png", home.latestAudioContent?.imageUrl)
|
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(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 })
|
assertEquals(listOf(402L, 401L, 404L), home.schedules.map { it.targetId })
|
||||||
assertFalse(home.schedules.any { it.isAdult })
|
assertFalse(home.schedules.any { it.isAdult })
|
||||||
assertEquals("https://cdn.test/profile/fan.png", home.channelDonations.first().profileImageUrl)
|
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.creator.characterId, response.creator.characterId)
|
||||||
assertEquals(home.currentLive?.liveId, response.currentLive?.liveId)
|
assertEquals(home.currentLive?.liveId, response.currentLive?.liveId)
|
||||||
assertEquals(home.latestAudioContent?.audioContentId, response.latestAudioContent?.audioContentId)
|
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.channelDonations.first().message, response.channelDonations.first().message)
|
||||||
assertEquals(home.notices.first().postId, response.notices.first().postId)
|
assertEquals(home.notices.first().postId, response.notices.first().postId)
|
||||||
assertEquals(home.schedules.first().targetId, response.schedules.first().targetId)
|
assertEquals(home.schedules.first().targetId, response.schedules.first().targetId)
|
||||||
@@ -217,6 +223,8 @@ class CreatorChannelHomeQueryServiceTest {
|
|||||||
assertTrue(response.latestAudioContent?.isPointAvailable == true)
|
assertTrue(response.latestAudioContent?.isPointAvailable == true)
|
||||||
assertTrue(response.latestAudioContent?.isFirstContent == true)
|
assertTrue(response.latestAudioContent?.isFirstContent == true)
|
||||||
assertTrue(response.latestAudioContent?.isAdult == true)
|
assertTrue(response.latestAudioContent?.isAdult == true)
|
||||||
|
assertTrue(response.latestAudioContent?.isOwned == true)
|
||||||
|
assertFalse(response.latestAudioContent?.isRented == true)
|
||||||
assertTrue(response.series.first().isOriginal)
|
assertTrue(response.series.first().isOriginal)
|
||||||
assertNotNull(response.latestAudioContent?.isOriginalSeries)
|
assertNotNull(response.latestAudioContent?.isOriginalSeries)
|
||||||
}
|
}
|
||||||
@@ -239,6 +247,10 @@ class CreatorChannelHomeQueryServiceTest {
|
|||||||
assertFalse(json["latestAudioContent"].has("firstContent"))
|
assertFalse(json["latestAudioContent"].has("firstContent"))
|
||||||
assertTrue(json["latestAudioContent"]["isAdult"].asBoolean())
|
assertTrue(json["latestAudioContent"]["isAdult"].asBoolean())
|
||||||
assertFalse(json["latestAudioContent"].has("adult"))
|
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())
|
assertTrue(json["series"][0]["isOriginal"].asBoolean())
|
||||||
assertFalse(json["series"][0].has("original"))
|
assertFalse(json["series"][0].has("original"))
|
||||||
assertFalse(json["series"][0].has("published" + "DaysOfWeek"))
|
assertFalse(json["series"][0].has("published" + "DaysOfWeek"))
|
||||||
@@ -297,7 +309,9 @@ class CreatorChannelHomeQueryServiceTest {
|
|||||||
isFirstContent = true,
|
isFirstContent = true,
|
||||||
publishedAt = LocalDateTime.of(2026, 6, 11, 1, 0),
|
publishedAt = LocalDateTime.of(2026, 6, 11, 1, 0),
|
||||||
seriesName = "series",
|
seriesName = "series",
|
||||||
isOriginalSeries = true
|
isOriginalSeries = true,
|
||||||
|
isOwned = true,
|
||||||
|
isRented = false
|
||||||
),
|
),
|
||||||
channelDonations = listOf(
|
channelDonations = listOf(
|
||||||
CreatorChannelDonation(
|
CreatorChannelDonation(
|
||||||
@@ -330,7 +344,9 @@ class CreatorChannelHomeQueryServiceTest {
|
|||||||
isFirstContent = false,
|
isFirstContent = false,
|
||||||
publishedAt = LocalDateTime.of(2026, 6, 10, 1, 0),
|
publishedAt = LocalDateTime.of(2026, 6, 10, 1, 0),
|
||||||
seriesName = null,
|
seriesName = null,
|
||||||
isOriginalSeries = null
|
isOriginalSeries = null,
|
||||||
|
isOwned = false,
|
||||||
|
isRented = true
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
series = listOf(
|
series = listOf(
|
||||||
@@ -484,7 +500,8 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
|
|||||||
override fun findLatestAudioContent(
|
override fun findLatestAudioContent(
|
||||||
creatorId: Long,
|
creatorId: Long,
|
||||||
now: LocalDateTime,
|
now: LocalDateTime,
|
||||||
canViewAdultContent: Boolean
|
canViewAdultContent: Boolean,
|
||||||
|
viewerId: Long?
|
||||||
): CreatorChannelAudioContentRecord? = audioContentRecord(201L, "audio/latest.png")
|
): CreatorChannelAudioContentRecord? = audioContentRecord(201L, "audio/latest.png")
|
||||||
|
|
||||||
override fun findChannelDonations(
|
override fun findChannelDonations(
|
||||||
@@ -553,6 +570,7 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
|
|||||||
now: LocalDateTime,
|
now: LocalDateTime,
|
||||||
latestAudioContentId: Long?,
|
latestAudioContentId: Long?,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
|
viewerId: Long?,
|
||||||
limit: Int
|
limit: Int
|
||||||
): List<CreatorChannelAudioContentRecord> {
|
): List<CreatorChannelAudioContentRecord> {
|
||||||
audioContentsLatestAudioContentId = latestAudioContentId
|
audioContentsLatestAudioContentId = latestAudioContentId
|
||||||
@@ -630,7 +648,9 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
|
|||||||
isFirstContent = false,
|
isFirstContent = false,
|
||||||
publishedAt = LocalDateTime.of(2026, 6, 10, 0, audioContentId.toInt() % 60),
|
publishedAt = LocalDateTime.of(2026, 6, 10, 0, audioContentId.toInt() % 60),
|
||||||
seriesName = null,
|
seriesName = null,
|
||||||
isOriginalSeries = null
|
isOriginalSeries = null,
|
||||||
|
isOwned = audioContentId == 201L || audioContentId == 202L,
|
||||||
|
isRented = audioContentId == 203L
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
@@ -127,7 +127,9 @@ class CreatorChannelHomeQueryPolicyTest {
|
|||||||
isFirstContent = false,
|
isFirstContent = false,
|
||||||
publishedAt = publishedAt,
|
publishedAt = publishedAt,
|
||||||
seriesName = null,
|
seriesName = null,
|
||||||
isOriginalSeries = null
|
isOriginalSeries = null,
|
||||||
|
isOwned = false,
|
||||||
|
isRented = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user