Compare commits
24 Commits
f27074167a
...
2a7d74b018
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a7d74b018 | |||
| abecbb694b | |||
| b34585afd2 | |||
| e252f5d9bb | |||
| 3f3497d376 | |||
| 3ac6a48f73 | |||
| e03cd7526b | |||
| e84b60418e | |||
| a0375aa29c | |||
| 9987595fe2 | |||
| cf73263505 | |||
| ab67e36d96 | |||
| 6a6deb33a3 | |||
| 1c7bac3a73 | |||
| 70346b911f | |||
| b7052f03f6 | |||
| 7212067101 | |||
| 33b3d3e41b | |||
| 45d2d616e0 | |||
| 9c4ec03624 | |||
| 3df66d98ef | |||
| cf7fea156b | |||
| d387030a38 | |||
| 2dbe339245 |
681
docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md
Normal file
681
docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md
Normal file
@@ -0,0 +1,681 @@
|
||||
# 메인 콘텐츠 추천 탭 API Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
||||
|
||||
**Goal:** `GET /api/v2/audio/recommendations`로 메인 콘텐츠 추천 탭의 배너, 오리지널 시리즈, 최신/무료/포인트/추천 오디오, New & Hot, 최근 댓글 많은 오디오 섹션을 한 번에 조회할 수 있게 한다.
|
||||
|
||||
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.content.recommendation` 조립 계층에 둔다. 추천 조회 service, 점수 정책, 조회 domain model, port, QueryDSL/native SQL repository, scheduler는 `kr.co.vividnext.sodalive.v2.content.recommendation` 하위에 두고 `v2.api.*`에 의존하지 않는다. `content` 패키지는 오디오 콘텐츠뿐 아니라 오리지널 시리즈 등 추천 탭에 포함될 수 있는 콘텐츠 범주를 포괄하기 위한 명칭이다. 배너 값 모델은 `v2.common.domain`, 배너 응답 DTO는 `v2.api.common.dto`로 분리하고, 기존 `recommendation_snapshot`은 section enum 확장 방식으로 재사용한다.
|
||||
|
||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, native SQL, Redisson, JUnit 5, MockMvc, Gradle Wrapper
|
||||
|
||||
---
|
||||
|
||||
## 0. 구현 전 확정 사항
|
||||
|
||||
- API endpoint: `GET /api/v2/audio/recommendations`
|
||||
- 최종 패키지 구조: 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.content.recommendation`, 도메인 조회 계층은 `kr.co.vividnext.sodalive.v2.content.recommendation`을 사용한다.
|
||||
- 기존 Phase 1-5 구현 산출물이 `audio.recommendation` 패키지에 있으면 Phase 6에서 `content.recommendation` 패키지로 이동한다.
|
||||
- 인증 정책: 비회원 조회 가능. 인증 회원이면 회원의 콘텐츠 조회 설정과 19금 노출 가능 여부를 반영한다.
|
||||
- 응답 wrapper: `ApiResponse.ok(...)`
|
||||
- 기본 노출 수:
|
||||
- `banners`: 메인 홈 추천 배너와 동일
|
||||
- `originalSeries`: 최신순 12개
|
||||
- `latestAudios`: 최신순 12개
|
||||
- `newAndHotAudios`: 최대 12개
|
||||
- `freeAudios`: 최대 10개 랜덤
|
||||
- `pointAudios`: 최대 10개 랜덤
|
||||
- `mostCommentedAudios`: 최대 5개
|
||||
- `recommendedAudios`: 최대 10개
|
||||
- 공개 오디오 공통 조건: `AudioContent.isActive == true`, `AudioContent.duration != null`, `AudioContent.releaseDate != null`, `AudioContent.releaseDate <= now`, 크리에이터 회원 활성.
|
||||
- 비회원과 19금 노출 불가 회원은 성인 콘텐츠를 제외하고 `SAFE` 스냅샷을 조회한다.
|
||||
- 19금 노출 가능 회원은 성인/비성인 콘텐츠를 모두 포함하는 `ALL` 스냅샷을 조회한다.
|
||||
- 스냅샷 기준: KST 매일 00:00 실행, 전날 23:59:59 KST까지의 데이터를 UTC 변환 없이 KST-local `LocalDateTime`으로 반영.
|
||||
- 스냅샷 저장 방식: 기존 `recommendation_snapshot` 테이블을 재사용하고 `RecommendedSectionType` enum에 `NEW_AND_HOT_AUDIO_SAFE`, `NEW_AND_HOT_AUDIO_ALL`, `MOST_COMMENTED_AUDIO_SAFE`, `MOST_COMMENTED_AUDIO_ALL`, `RECOMMENDED_AUDIO_SAFE`, `RECOMMENDED_AUDIO_ALL`을 추가한다. 신규 테이블 DDL은 작성하지 않는다.
|
||||
- New & Hot 점수: 최신성 35%, 상세 조회수 35%, 좋아요 15%, 댓글 수 15%. 상세 조회수는 `creator_content_view_history`의 `content_id`별 count를 사용한다.
|
||||
- 추천 오디오 점수: 상세 조회수 45%, 좋아요 25%, 댓글 수 20%, 최신성 10%.
|
||||
- 최근 댓글 많은 오디오 점수: 댓글 수 80%, 댓글 최신성 20%.
|
||||
- 조회수/좋아요/댓글 수는 후보 내 정규화 없이 원본 count를 그대로 사용한다.
|
||||
- 무료/포인트/추천 오디오 섹션 사이에는 같은 콘텐츠가 중복 노출될 수 있다.
|
||||
- `isOriginalSeries`는 시리즈 미소속 오디오이면 `false`로 내려준다.
|
||||
- 전체보기/페이징 API, 관리자 화면, 수동 편집 기능은 이번 범위에 포함하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 파일 구조 계획
|
||||
|
||||
### API 공통 DTO
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/RecommendationBanner.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/common/dto/RecommendationBannerResponse.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt`
|
||||
|
||||
### 신규 API 조립 계층
|
||||
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationController.kt`
|
||||
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt`
|
||||
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt`
|
||||
|
||||
### 신규 도메인 조회 계층
|
||||
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
|
||||
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt`
|
||||
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendation.kt`
|
||||
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationScorePolicy.kt`
|
||||
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationVisibility.kt`
|
||||
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt`
|
||||
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/AudioRecommendationQueryRepository.kt`
|
||||
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
||||
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedSectionType.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationScorePolicyTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt`
|
||||
|
||||
### 기존 재사용 파일 확인
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshot.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/RecommendationSnapshotPort.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/CreatorContentViewHistory.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`
|
||||
|
||||
---
|
||||
|
||||
## 2. Response data class 초안
|
||||
|
||||
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
|
||||
|
||||
```kotlin
|
||||
package kr.co.vividnext.sodalive.v2.api.content.recommendation.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries
|
||||
|
||||
data class AudioRecommendationsResponse(
|
||||
val banners: List<RecommendationBannerResponse>,
|
||||
val originalSeries: List<OriginalSeriesResponse>,
|
||||
val latestAudios: List<AudioCardResponse>,
|
||||
val newAndHotAudios: List<AudioCardResponse>,
|
||||
val freeAudios: List<AudioCardResponse>,
|
||||
val pointAudios: List<AudioCardResponse>,
|
||||
val mostCommentedAudios: List<CommentedAudioResponse>,
|
||||
val recommendedAudios: List<AudioCardResponse>
|
||||
) {
|
||||
companion object {
|
||||
fun from(recommendations: AudioRecommendations): AudioRecommendationsResponse {
|
||||
return AudioRecommendationsResponse(
|
||||
banners = recommendations.banners.map(RecommendationBannerResponse::from),
|
||||
originalSeries = recommendations.originalSeries.map(OriginalSeriesResponse::from),
|
||||
latestAudios = recommendations.latestAudios.map(AudioCardResponse::from),
|
||||
newAndHotAudios = recommendations.newAndHotAudios.map(AudioCardResponse::from),
|
||||
freeAudios = recommendations.freeAudios.map(AudioCardResponse::from),
|
||||
pointAudios = recommendations.pointAudios.map(AudioCardResponse::from),
|
||||
mostCommentedAudios = recommendations.mostCommentedAudios.map(CommentedAudioResponse::from),
|
||||
recommendedAudios = recommendations.recommendedAudios.map(AudioCardResponse::from)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class OriginalSeriesResponse(
|
||||
val seriesId: Long,
|
||||
val coverImageUrl: String?
|
||||
) {
|
||||
companion object {
|
||||
fun from(series: OriginalSeries): OriginalSeriesResponse {
|
||||
return OriginalSeriesResponse(series.seriesId, series.coverImageUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class AudioCardResponse(
|
||||
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,
|
||||
@JsonProperty("isOriginalSeries")
|
||||
val isOriginalSeries: Boolean,
|
||||
val creatorNickname: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(audio: AudioCard): AudioCardResponse {
|
||||
return AudioCardResponse(
|
||||
audioContentId = audio.audioContentId,
|
||||
title = audio.title,
|
||||
duration = audio.duration,
|
||||
imageUrl = audio.imageUrl,
|
||||
price = audio.price,
|
||||
isAdult = audio.isAdult,
|
||||
isPointAvailable = audio.isPointAvailable,
|
||||
isFirstContent = audio.isFirstContent,
|
||||
isOriginalSeries = audio.isOriginalSeries,
|
||||
creatorNickname = audio.creatorNickname
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class CommentedAudioResponse(
|
||||
val audioContentId: Long,
|
||||
val title: String,
|
||||
val imageUrl: String?,
|
||||
val latestComment: String,
|
||||
val latestCommentWriterProfileImageUrl: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(audio: CommentedAudio): CommentedAudioResponse {
|
||||
return CommentedAudioResponse(
|
||||
audioContentId = audio.audioContentId,
|
||||
title = audio.title,
|
||||
imageUrl = audio.imageUrl,
|
||||
latestComment = audio.latestComment,
|
||||
latestCommentWriterProfileImageUrl = audio.latestCommentWriterProfileImageUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Domain / Port 초안
|
||||
|
||||
```kotlin
|
||||
package kr.co.vividnext.sodalive.v2.content.recommendation.domain
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
|
||||
|
||||
data class AudioRecommendations(
|
||||
val banners: List<RecommendationBanner>,
|
||||
val originalSeries: List<OriginalSeries>,
|
||||
val latestAudios: List<AudioCard>,
|
||||
val newAndHotAudios: List<AudioCard>,
|
||||
val freeAudios: List<AudioCard>,
|
||||
val pointAudios: List<AudioCard>,
|
||||
val mostCommentedAudios: List<CommentedAudio>,
|
||||
val recommendedAudios: List<AudioCard>
|
||||
)
|
||||
|
||||
data class OriginalSeries(
|
||||
val seriesId: Long,
|
||||
val coverImageUrl: String?
|
||||
)
|
||||
|
||||
data class AudioCard(
|
||||
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 isOriginalSeries: Boolean,
|
||||
val creatorNickname: String
|
||||
)
|
||||
|
||||
data class CommentedAudio(
|
||||
val audioContentId: Long,
|
||||
val title: String,
|
||||
val imageUrl: String?,
|
||||
val latestComment: String,
|
||||
val latestCommentWriterProfileImageUrl: String
|
||||
)
|
||||
|
||||
enum class AudioRecommendationVisibility {
|
||||
SAFE,
|
||||
ALL
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
package kr.co.vividnext.sodalive.v2.content.recommendation.port.out
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries
|
||||
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface AudioRecommendationQueryPort {
|
||||
fun findBanners(limit: Int, memberId: Long?, canViewAdultContent: Boolean): List<RecommendationBanner>
|
||||
fun findOriginalSeries(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<OriginalSeries>
|
||||
fun findLatestAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
|
||||
fun findFreeAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
|
||||
fun findPointAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
|
||||
fun findAudioCardsByIds(contentIds: List<Long>, memberId: Long?, canViewAdultContent: Boolean): List<AudioCard>
|
||||
fun findCommentedAudiosByIds(contentIds: List<Long>, memberId: Long?, canViewAdultContent: Boolean): List<CommentedAudio>
|
||||
fun findNewAndHotSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime, visibility: AudioRecommendationVisibility, limit: Int): List<RecommendationSnapshotRecord>
|
||||
fun findMostCommentedSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime, visibility: AudioRecommendationVisibility, limit: Int): List<RecommendationSnapshotRecord>
|
||||
fun findRecommendedAudioSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime, visibility: AudioRecommendationVisibility, limit: Int): List<RecommendationSnapshotRecord>
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: 공통 DTO와 API 계약
|
||||
|
||||
- [x] **Task 1.1: 배너 응답 DTO를 공통 패키지로 분리**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/RecommendationBanner.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/common/dto/RecommendationBannerResponse.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt`
|
||||
- RED: `HomeRecommendationResponse`의 `banners`가 공통 `RecommendationBannerResponse` 타입을 사용하고 기존 JSON 필드 `imageUrl`, `eventItem`, `creatorId`, `seriesId`, `link`를 유지하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`
|
||||
- GREEN: `HomeBannerItem` 필드 구조를 `RecommendationBanner` domain model과 `RecommendationBannerResponse` DTO로 분리하고 홈 추천 DTO/facade import를 갱신한다.
|
||||
- REFACTOR: 홈 탭 전용 controller/facade 로직은 이동하지 않고 DTO 타입만 공통화한다.
|
||||
- 기대 결과: 기존 홈 추천 배너 JSON 계약은 유지되고 신규 오디오 추천 API가 같은 DTO를 재사용할 수 있다.
|
||||
|
||||
- [x] **Task 1.2: 오디오 추천 응답 DTO와 facade 변환 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/dto/AudioRecommendationsResponse.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacade.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacadeTest.kt`
|
||||
- RED: facade가 도메인 `AudioRecommendations`를 `AudioRecommendationsResponse`로 변환하고 `originalSeries`, `latestAudios`, `newAndHotAudios`, `freeAudios`, `pointAudios`, `mostCommentedAudios`, `recommendedAudios` 필드를 모두 채우는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest`
|
||||
- GREEN: facade는 `AudioRecommendationQueryService.getRecommendations(member)`만 호출하고 공개 DTO 변환만 담당한다.
|
||||
- REFACTOR: `isOriginalSeries`는 `Boolean`으로 유지하고 nullable 변환을 만들지 않는다.
|
||||
- 기대 결과: API 조립 계층은 도메인 조회 계층에만 의존하고, 도메인 조회 계층은 API DTO에 의존하지 않는다.
|
||||
|
||||
- [x] **Task 1.3: 비회원 허용 controller 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationController.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt`
|
||||
- RED: `GET /api/v2/audio/recommendations`가 비회원과 인증 회원 모두 `200 OK`를 반환하고 `ApiResponse.ok` wrapper를 사용하는 MockMvc 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationControllerTest`
|
||||
- GREEN: `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴으로 member nullable을 facade에 전달한다.
|
||||
- REFACTOR: request parameter는 추가하지 않고 controller에는 인증/응답 경계만 남긴다.
|
||||
- 기대 결과: 비회원 조회 가능 계약과 endpoint 경로가 controller 테스트로 고정된다.
|
||||
|
||||
### Phase 2: 도메인 모델과 점수 정책
|
||||
|
||||
- [x] **Task 2.1: 도메인 모델과 visibility enum 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendation.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationVisibility.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryServiceTest.kt`
|
||||
- RED: `AudioRecommendationVisibility.SAFE`는 `NEW_AND_HOT_AUDIO_SAFE`, `ALL`은 `NEW_AND_HOT_AUDIO_ALL`처럼 section type을 선택해야 한다는 service 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest`
|
||||
- GREEN: `AudioRecommendations`, `OriginalSeries`, `AudioCard`, `CommentedAudio`, `AudioRecommendationVisibility`를 추가한다.
|
||||
- REFACTOR: domain model에는 API DTO import를 두지 않는다. `AudioRecommendations.banners`는 `v2.common.domain.RecommendationBanner`만 사용한다.
|
||||
- 기대 결과: SAFE/ALL 선택이 문자열이 아니라 enum으로 고정된다.
|
||||
|
||||
- [x] **Task 2.2: 오디오 추천 점수 정책 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicy.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicyTest.kt`
|
||||
- RED: New & Hot 최신성 배수 3/7/14일/그 외, 추천 오디오 최신성 배수 3/7/30일/그 외, 최근 댓글 최신성 배수 3/7/14일/그 이상을 검증하는 테스트를 작성한다. 원본 count 가중합이 정규화 없이 계산되는 테스트도 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest`
|
||||
- GREEN: `calculateNewAndHotScore`, `calculateRecommendedAudioScore`, `calculateCommentScore`와 각 recency multiplier 함수를 구현한다.
|
||||
- REFACTOR: 가중치와 일수 경계는 `companion object` 상수로 모아 테스트 기대값과 용어를 맞춘다.
|
||||
- 기대 결과: PRD 산식과 최신성 경계가 순수 단위 테스트로 고정된다.
|
||||
|
||||
- [x] **Task 2.3: 스냅샷 section enum 확장**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedSectionType.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryServiceTest.kt`
|
||||
- RED: visibility와 섹션 조합이 `NEW_AND_HOT_AUDIO_SAFE`, `NEW_AND_HOT_AUDIO_ALL`, `MOST_COMMENTED_AUDIO_SAFE`, `MOST_COMMENTED_AUDIO_ALL`, `RECOMMENDED_AUDIO_SAFE`, `RECOMMENDED_AUDIO_ALL`로 매핑되는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest`
|
||||
- GREEN: 기존 `RecommendedSectionType`에 오디오 추천 섹션 enum 값을 추가하고 service 내부 매핑 함수를 구현한다.
|
||||
- REFACTOR: `recommendation_snapshot.section_type` 길이 50 안에 모든 enum 이름이 들어가는지 확인한다.
|
||||
- 기대 결과: 신규 테이블 없이 기존 스냅샷 저장 구조를 재사용한다.
|
||||
|
||||
### Phase 3: 실시간 조회 섹션 repository
|
||||
|
||||
- [x] **Task 3.1: 배너/오리지널 시리즈/최신 오디오 조회 구현**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/port/out/AudioRecommendationQueryPort.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/AudioRecommendationQueryRepository.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
||||
- RED: 배너는 기존 홈 추천 배너와 동일 필드/활성/차단 정책을 적용하고, 오리지널 시리즈는 `isOriginal = true` 최신순 12개, 최신 오디오는 `releaseDate desc`, `audioContentId desc` 12개를 반환하는 repository 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
||||
- GREEN: QueryDSL로 `findBanners`, `findOriginalSeries`, `findLatestAudios`를 구현한다. 이미지 경로는 `toCdnUrl(cloudFrontHost)`를 사용한다.
|
||||
- REFACTOR: 공개 오디오 조건, 성인 콘텐츠 조건, 차단 관계 조건을 private 조건 함수로 분리한다.
|
||||
- 기대 결과: 비회원은 성인 콘텐츠를 제외하고, 인증 회원은 성인 노출 가능 여부에 따라 결과가 달라진다.
|
||||
|
||||
- [x] **Task 3.2: 무료/포인트 랜덤 오디오 조회 구현**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
||||
- RED: 무료 오디오는 `price = 0` 공개 오디오 중 최대 10개, 포인트 오디오는 `isPointAvailable = true` 공개 오디오 중 최대 10개를 반환하고 두 섹션 간 중복을 제거하지 않는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
||||
- GREEN: `findFreeAudios`, `findPointAudios`를 구현하고 DB 랜덤 정렬은 기존 repository 관례에 맞춰 `Expressions.numberTemplate(Double::class.java, "function('rand')")` 또는 동일 프로젝트에서 쓰는 랜덤 정렬 방식을 사용한다.
|
||||
- REFACTOR: 무료/포인트 조회가 같은 공통 projection 함수를 사용하게 정리한다.
|
||||
- 기대 결과: 랜덤 섹션도 공개/성인/차단 조건을 동일하게 적용한다.
|
||||
|
||||
- [x] **Task 3.3: 공통 오디오 카드 enrichment 구현**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
||||
- RED: `AudioCard`가 `audioContentId`, `title`, `duration`, `imageUrl`, `price`, `isAdult`, `isPointAvailable`, `isFirstContent`, `isOriginalSeries`, `creatorNickname`을 채우고, 시리즈 미소속이면 `isOriginalSeries = false`인 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
||||
- GREEN: first content 판정은 기존 크리에이터 채널 오디오 조회 repository의 첫 콘텐츠 계산 패턴을 참고해 구현한다. 원본 시리즈 연결이 없으면 `false`, 연결 시리즈가 있으면 `series.isOriginal`을 사용한다.
|
||||
- REFACTOR: latest/free/point/snapshot 상세 조회 모두 같은 `toAudioCard` 변환을 사용한다.
|
||||
- 기대 결과: 섹션별 오디오 카드 필드 의미가 동일하게 유지된다.
|
||||
|
||||
### Phase 4: 스냅샷 산정과 일 배치
|
||||
|
||||
- [x] **Task 4.1: New & Hot 스냅샷 후보 산정 구현**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/port/out/AudioRecommendationQueryPort.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
||||
- RED: 최근 3일 `creator_content_view_history` count, `content_like` active count, `audio_content_comment` active count, 최신성 배수를 원본 count 가중합으로 계산하고 `SAFE`는 비성인만, `ALL`은 성인/비성인을 모두 포함하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
||||
- GREEN: native SQL CTE 또는 QueryDSL aggregate로 `findNewAndHotSnapshots(windowStart, snapshotAt, visibility, limit)`를 구현한다. 정렬은 `score desc`, `randomTieBreaker asc`로 한다.
|
||||
- REFACTOR: Kotlin `AudioRecommendationScorePolicy` 기대값과 DB score가 일치하는 parity 테스트 데이터를 유지한다.
|
||||
- 기대 결과: `NEW_AND_HOT_AUDIO_SAFE/ALL`에 저장할 top 12 후보가 정확한 점수순으로 산출된다.
|
||||
|
||||
- [x] **Task 4.2: 최근 댓글 많은 오디오 스냅샷 후보 산정 구현**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
||||
- RED: 최근 7일 댓글 데이터 기반으로 댓글 수 80%, 댓글 최신성 20% 점수를 계산하고 데이터가 없으면 빈 후보를 반환하는 테스트를 작성한다. 가장 최신 댓글 1개의 본문과 작성자 프로필 이미지가 상세 조회에서 내려가는 테스트도 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
||||
- GREEN: `findMostCommentedSnapshots(...)`와 `findCommentedAudiosByIds(...)`를 구현한다. 상세 조회 결과에는 가장 최신 댓글 본문과 작성자 프로필 이미지를 포함한다. 비활성 댓글, 삭제된 댓글, 차단 관계의 댓글 작성자는 제외한다.
|
||||
- REFACTOR: 댓글 최신성 배수 계산은 repository SQL과 `AudioRecommendationScorePolicy`가 같은 경계값을 사용하도록 테스트로 고정한다.
|
||||
- 기대 결과: 스냅샷이 없거나 후보가 없으면 `mostCommentedAudios`는 빈 배열이다.
|
||||
|
||||
- [x] **Task 4.3: 추천 오디오 스냅샷 후보 산정 구현**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
||||
- RED: 상세 조회수 45%, 좋아요 25%, 댓글 수 20%, 최신성 10% 점수를 계산하고 `SAFE/ALL` visibility별 최대 10개 후보를 반환하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
||||
- GREEN: `findRecommendedAudioSnapshots(...)`를 구현한다. 상세 조회수는 `creator_content_view_history` count를 사용하고 `AudioContent.playCount`를 사용하지 않는다.
|
||||
- REFACTOR: New & Hot과 공유 가능한 조회수/좋아요/댓글 aggregate CTE를 private SQL fragment 또는 QueryDSL helper로 정리한다.
|
||||
- 기대 결과: `RECOMMENDED_AUDIO_SAFE/ALL`에 저장할 top 10 후보가 정확한 점수순으로 산출된다.
|
||||
|
||||
- [x] **Task 4.4: 스냅샷 refresh service와 lazy 보강 구현**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt`
|
||||
- RED: `refreshDailySnapshots(now)`가 KST 전날 23:59:59 기준으로 여섯 section type(`NEW_AND_HOT_AUDIO_SAFE/ALL`, `MOST_COMMENTED_AUDIO_SAFE/ALL`, `RECOMMENDED_AUDIO_SAFE/ALL`)을 replace하고, New & Hot 조회 시 최신 스냅샷이 없으면 lazy refresh를 1회 호출하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest`
|
||||
- GREEN: 기존 `RecommendationSnapshotPort.replaceSnapshots(...)`와 `findLatestSnapshots(...)`를 재사용한다. lazy 보강은 New & Hot에만 적용하고, 최근 댓글 많은 오디오는 스냅샷이 없으면 빈 배열로 유지한다.
|
||||
- REFACTOR: 기준 시각 계산은 private 함수로 분리하고 KST-local `LocalDateTime` 경계 테스트를 유지한다. 보강 후에도 New & Hot 후보가 0개이면 Redis marker 기준 같은 KST 날짜에는 lazy refresh를 반복하지 않는다.
|
||||
- 기대 결과: 일 배치와 lazy 보강 모두 같은 산정 함수를 사용한다.
|
||||
|
||||
- [x] **Task 4.5: 00:00 KST 스케줄러와 Redisson lock 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt`
|
||||
- RED: cron이 `0 0 0 * * *`, zone이 `Asia/Seoul`, lock key가 `lock:audio-recommendation-snapshot-refresh`이고 lock 획득 성공 시에만 refresh service를 호출하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.scheduler.AudioRecommendationSnapshotSchedulerTest`
|
||||
- GREEN: `RedissonClient`를 주입하고 기존 추천 스냅샷 scheduler 패턴처럼 `tryLock` 성공 시 `refreshDailySnapshots()`를 호출한다.
|
||||
- REFACTOR: 스케줄러에는 lock과 service 호출만 남기고 집계 로직을 두지 않는다.
|
||||
- 기대 결과: 다중 서버에서 하루 한 번만 오디오 추천 스냅샷을 갱신한다.
|
||||
|
||||
### Phase 5: 통합 조회 service와 API 연결
|
||||
|
||||
- [x] **Task 5.1: AudioRecommendationQueryService 통합 조립**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryServiceTest.kt`
|
||||
- RED: 비회원은 `SAFE` visibility와 19금 제외 조건을 사용하고, 19금 노출 가능 회원은 `ALL` visibility를 사용하며, 각 섹션 limit이 PRD와 일치하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest`
|
||||
- GREEN: query service가 real-time 섹션과 snapshot 섹션을 조립해 `AudioRecommendations`를 반환한다. `MemberContentPreferenceService`는 facade가 아니라 query service 또는 별도 resolver에서 사용해 도메인 조회 조건을 만든다.
|
||||
- REFACTOR: 섹션 limit은 companion object 상수로 고정하고 테스트에서 같은 값을 검증한다.
|
||||
- 기대 결과: 특정 섹션이 빈 배열이어도 전체 응답은 성공 가능한 domain model로 조립된다.
|
||||
|
||||
- [x] **Task 5.2: Facade 성인 정책/이미지 URL 변환 연결**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacade.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacadeTest.kt`
|
||||
- RED: 회원/비회원별 성인 노출 정책이 query service에 전달되고, CDN URL이 포함된 domain 응답이 공개 DTO로 변환되는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest`
|
||||
- GREEN: facade는 member를 그대로 query service에 전달하고 `AudioRecommendationsResponse.from(...)`만 수행한다.
|
||||
- REFACTOR: Home 탭 전용 `HomeRecommendationFacade`를 주입하거나 호출하지 않는지 import를 확인한다.
|
||||
- 기대 결과: API 조립 계층은 신규 audio recommendation use case만 호출한다.
|
||||
|
||||
- [x] **Task 5.3: Controller/E2E 통합 검증**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationController.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt`
|
||||
- RED: MockMvc controller 테스트와 최소 E2E 테스트를 작성해 JSON path `$.data.originalSeries`, `$.data.latestAudios`, `$.data.recommendedAudios`, `$.data.latestAudios[0].isOriginalSeries`, `$.data.mostCommentedAudios[0].latestComment`, `$.data.mostCommentedAudios[0].latestCommentWriterProfileImageUrl`가 존재하는지 검증한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationEndToEndTest`
|
||||
- GREEN: controller와 Spring bean wiring을 완성한다.
|
||||
- REFACTOR: 응답 필드명이 PRD와 plan-task의 DTO 초안과 같은지 검색으로 확인한다.
|
||||
- 기대 결과: 비회원과 인증 회원 모두 endpoint 호출이 성공한다.
|
||||
|
||||
### Phase 6: 패키지 구조 content.recommendation 이동
|
||||
|
||||
- [x] **Task 6.1: 공개 API 조립 계층 패키지 이동**
|
||||
- Files:
|
||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationController.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationController.kt`
|
||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacade.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt`
|
||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/dto/AudioRecommendationsResponse.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt`
|
||||
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt`
|
||||
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacadeTest.kt` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt`
|
||||
- TDD 예외 사유: 동작 변경 없이 패키지/디렉터리만 이동하는 구조 정리 task이므로 신규 실패 테스트 작성 대상이 아니다.
|
||||
- 대체 검증 방법:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.*`
|
||||
- Run: `rg -n "v2\\.api\\.audio\\.recommendation" src/main/kotlin src/test/kotlin`
|
||||
- GREEN: `package` 선언과 import를 `kr.co.vividnext.sodalive.v2.api.content.recommendation` 기준으로 갱신한다.
|
||||
- REFACTOR: endpoint `GET /api/v2/audio/recommendations`, response DTO class/field 이름은 공개 API 계약이므로 변경하지 않는다.
|
||||
- 기대 결과: 공개 API 조립 계층은 `v2.api.content.recommendation` 아래에만 존재한다.
|
||||
|
||||
- [x] **Task 6.2: 도메인 조회 계층 패키지 이동**
|
||||
- Files:
|
||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryService.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
|
||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshService.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt`
|
||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendation.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendation.kt`
|
||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicy.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationScorePolicy.kt`
|
||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationVisibility.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationVisibility.kt`
|
||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/port/out/AudioRecommendationQueryPort.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt`
|
||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/AudioRecommendationQueryRepository.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/AudioRecommendationQueryRepository.kt`
|
||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
||||
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt`
|
||||
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/**` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/**`
|
||||
- TDD 예외 사유: 동작 변경 없이 패키지/디렉터리만 이동하는 구조 정리 task이므로 신규 실패 테스트 작성 대상이 아니다.
|
||||
- 대체 검증 방법:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.*`
|
||||
- Run: `rg -n "v2\\.audio\\.recommendation" src/main/kotlin src/test/kotlin`
|
||||
- GREEN: 도메인 조회 계층의 `package` 선언과 import를 `kr.co.vividnext.sodalive.v2.content.recommendation` 기준으로 갱신한다.
|
||||
- REFACTOR: class 이름(`AudioRecommendation*`)은 현재 API/섹션 의미가 오디오 중심이므로 유지하고, 패키지명만 콘텐츠 범주로 확장한다.
|
||||
- 기대 결과: 도메인 조회 계층은 `v2.content.recommendation` 아래에만 존재하고 `v2.api.*`에 의존하지 않는다.
|
||||
|
||||
- [x] **Task 6.3: 패키지 잔여 참조와 문서 동기화 확인**
|
||||
- Files:
|
||||
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md`
|
||||
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`
|
||||
- Verify: `src/main/kotlin`
|
||||
- Verify: `src/test/kotlin`
|
||||
- TDD 예외 사유: 문서와 package/import 잔여 참조 확인 task이므로 신규 실패 테스트 작성 대상이 아니다.
|
||||
- 대체 검증 방법:
|
||||
- Run: `rg -n "api\\.audio\\.recommendation|v2\\.audio\\.recommendation|api/audio/recommendation|v2/audio/recommendation" docs/20260623_메인_콘텐츠_추천_탭_API src/main/kotlin src/test/kotlin`
|
||||
- Run: `rg -n "api\\.content\\.recommendation|v2\\.content\\.recommendation|api/content/recommendation|v2/content/recommendation" docs/20260623_메인_콘텐츠_추천_탭_API src/main/kotlin src/test/kotlin`
|
||||
- 기대 결과: 잔여 `audio.recommendation` 패키지 참조는 과거 검증 기록을 제외하고 남지 않고, PRD/plan-task의 최종 구조가 `content.recommendation` 기준으로 일치한다.
|
||||
- 검증 기록: 구현 완료 시 실행 명령, 결과, 잔여 참조가 남은 경우 사유를 이 task 아래에 한국어로 누적 기록한다.
|
||||
- 2026-06-23 Phase 6 구현 기록:
|
||||
- 공개 API 조립 계층을 `kr.co.vividnext.sodalive.v2.api.content.recommendation` 패키지로 이동했다. endpoint `GET /api/v2/audio/recommendations`, class 이름, response DTO field 이름은 변경하지 않았다.
|
||||
- 도메인 조회 계층을 `kr.co.vividnext.sodalive.v2.content.recommendation` 패키지로 이동했다. `AudioRecommendation*` class 이름과 repository/query/scheduler 동작은 변경하지 않았다.
|
||||
- `rg -n "kr\.co\.vividnext\.sodalive\.v2\.(api\.)?audio\.recommendation|v2\.api\.audio\.recommendation|v2\.audio\.recommendation" src/main/kotlin src/test/kotlin`: 결과 없음.
|
||||
- `rg --files src/main/kotlin src/test/kotlin | rg "/v2/(api/)?audio/recommendation/"`: 결과 없음.
|
||||
- `rg -n "/api/v2/audio/recommendations" src/main/kotlin src/test/kotlin`: controller, controller test, E2E test, `SecurityConfig`에서 기존 endpoint 유지 확인.
|
||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.*'`: `BUILD SUCCESSFUL`.
|
||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.*'`: 병렬 Gradle 실행 중 XML test result 파일 쓰기 충돌로 1회 실패 후 단독 재실행해 `BUILD SUCCESSFUL`.
|
||||
- 2026-06-23 Phase 6 코드 리뷰 및 검증 기록:
|
||||
- `rg -n "v2\\.api\\.audio\\.recommendation|v2\\.audio\\.recommendation|api/audio/recommendation|/v2/audio/recommendation" src/main/kotlin src/test/kotlin`: endpoint 문자열을 제외하고 이전 패키지/경로 참조 없음.
|
||||
- `rg -n "api\\.audio\\.recommendation|v2\\.audio\\.recommendation|api/audio/recommendation|v2/audio/recommendation" docs/20260623_메인_콘텐츠_추천_탭_API src/main/kotlin src/test/kotlin`: 문서의 Phase 1-6 과거 작업 경로/검증 기록과 endpoint 문자열만 확인됨.
|
||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.*'`: `BUILD SUCCESSFUL`.
|
||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.*'`: `BUILD SUCCESSFUL`.
|
||||
- `./gradlew ktlintCheck`: 최초 sandbox 실행은 Gradle wrapper의 `~/.gradle` lock 파일 접근 권한으로 실패했고, 승인 후 재실행해 `BUILD SUCCESSFUL`.
|
||||
|
||||
### Phase 7: 성인 콘텐츠 조회 정책 계산 경로 통일
|
||||
|
||||
- [x] **Task 7.1: MemberContentPreferenceService에 성인 콘텐츠 조회 가능 여부 메서드 추가**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt`
|
||||
- RED: `canViewAdultContent(member)`가 저장된 `isAdultContentVisible` 설정, 국가 정책, 성인 인증 여부를 반영해 `ViewerContentPreference.isAdult`와 같은 값을 반환하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest`
|
||||
- GREEN: `MemberContentPreferenceService.canViewAdultContent(member: Member): Boolean`을 추가하고 내부 구현은 `getStoredPreference(member).isAdult`를 반환한다.
|
||||
- REFACTOR: 성인 콘텐츠 조회 가능 여부를 계산하는 신규 호출부는 `isAdultVisibleByPolicy(...)`를 직접 호출하지 않고 service 메서드를 사용한다.
|
||||
- 기대 결과: 사용자 설정(`isAdultContentVisible`), 국가 정책, 성인 인증 여부가 하나의 공개 service 메서드로 일관되게 계산된다.
|
||||
- 검증 기록: 구현 완료 시 실행 명령, 결과, 실패 시 원인과 수정 내용을 이 task 아래에 한국어로 누적 기록한다.
|
||||
- 2026-06-23 Phase 7 구현 기록:
|
||||
- RED: `MemberContentPreferenceServiceTest.shouldReturnStoredPreferenceAdultPolicyForCanViewAdultContent`를 추가하고 `./gradlew test --tests kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest --tests '*shouldReturnStoredPreferenceAdultPolicyForCanViewAdultContent'`를 실행해 `Unresolved reference: canViewAdultContent` 실패를 확인했다.
|
||||
- GREEN: `MemberContentPreferenceService.canViewAdultContent(member: Member): Boolean`을 추가해 `getStoredPreference(member).isAdult`를 반환하도록 했고, 동일 테스트 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.member.contentpreference.*'`: 따옴표 없이 실행한 첫 명령은 zsh glob 해석으로 실행 전 실패했고, 따옴표로 감싸 재실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||
|
||||
- [x] **Task 7.2: 추천 탭과 v2 조회 계층의 성인 정책 호출부를 service 메서드로 교체**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
|
||||
- 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/series/application/CreatorChannelSeriesQueryService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt`
|
||||
- Test: 기존 v2 조회 service 테스트 중 성인 콘텐츠 노출 정책을 검증하는 테스트 파일
|
||||
- RED: `AudioRecommendationQueryServiceTest`에서 `memberContentPreferenceService.canViewAdultContent(member)`가 호출되고 `getStoredPreference(...)` 또는 `isAdultVisibleByPolicy(...)` 직접 조합을 사용하지 않는 테스트를 작성한다. 기존 v2 조회 service 테스트에는 성인 콘텐츠 노출 가능/불가 회원별 조회 조건이 유지되는 회귀 테스트를 추가한다.
|
||||
- 실패 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest`
|
||||
- Run: 변경한 기존 v2 조회 service 테스트
|
||||
- GREEN: 각 호출부의 `getStoredPreference(...)` + `isAdultVisibleByPolicy(...)` 조합 또는 `getStoredPreference(...).isAdult` 직접 사용을 `memberContentPreferenceService.canViewAdultContent(member)`로 교체한다.
|
||||
- REFACTOR: 더 이상 필요 없는 `isAdultVisibleByPolicy` import와 중간 `preference` 지역 변수를 제거한다.
|
||||
- 기대 결과: v2 조회 계층과 추천 탭 API의 성인 콘텐츠 조회 정책 계산 경로가 `MemberContentPreferenceService.canViewAdultContent(...)`로 통일된다.
|
||||
- 검증 기록: 구현 완료 시 실행 명령, 결과, 실패 시 원인과 수정 내용을 이 task 아래에 한국어로 누적 기록한다.
|
||||
- 2026-06-23 Phase 7 구현 기록:
|
||||
- `AudioRecommendationQueryService`, `HomeRecommendationFacade`, v2 creator channel audio/community/home/live/series 조회 service의 성인 콘텐츠 조회 가능 여부 계산을 `memberContentPreferenceService.canViewAdultContent(...)` 호출로 통일했다.
|
||||
- `CreatorChannelHomeQueryService`는 기존 `preference.contentType` 전달이 필요하므로 `getStoredPreference(viewer)`는 유지하고, 성인 콘텐츠 조회 가능 여부 계산만 service 메서드로 교체했다.
|
||||
- 변경한 v2 service/controller 테스트 묶음 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest`: `BUILD SUCCESSFUL`.
|
||||
|
||||
- [x] **Task 7.3: 성인 정책 직접 호출 잔여 참조 확인**
|
||||
- Files:
|
||||
- Verify: `src/main/kotlin`
|
||||
- Verify: `src/test/kotlin`
|
||||
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`
|
||||
- TDD 예외 사유: 검색 기반 잔여 참조 확인 task이므로 신규 실패 테스트 작성 대상이 아니다.
|
||||
- 대체 검증 방법:
|
||||
- Run: `rg -n "isAdultVisibleByPolicy|getStoredPreference\\([^\\n]*\\)\\.isAdult" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2`
|
||||
- Run: `rg -n "canViewAdultContent\\(" src/main/kotlin/kr/co/vividnext/sodalive src/test/kotlin/kr/co/vividnext/sodalive`
|
||||
- 기대 결과: v2 조회 계층에는 성인 콘텐츠 조회 가능 여부 계산을 위한 `isAdultVisibleByPolicy(...)` 직접 호출이나 `getStoredPreference(...).isAdult` 직접 사용이 남지 않고, `canViewAdultContent(...)` 호출로 통일된다.
|
||||
- 검증 기록: 구현 완료 시 실행 명령, 결과, 잔여 참조가 남은 경우 사유를 이 task 아래에 한국어로 누적 기록한다.
|
||||
- 2026-06-23 Phase 7 구현 기록:
|
||||
- `rg -n "isAdultVisibleByPolicy|getStoredPreference\([^\n]*\)\.isAdult" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2`: 결과 없음.
|
||||
- `rg -n "canViewAdultContent\(" src/main/kotlin/kr/co/vividnext/sodalive src/test/kotlin/kr/co/vividnext/sodalive`: `MemberContentPreferenceService`와 Phase 7 변경 호출부에서 canonical 메서드 사용 확인.
|
||||
- `./gradlew ktlintCheck`: `BUILD SUCCESSFUL`.
|
||||
- `git diff --check`: 출력 없음.
|
||||
- Phase 7 리뷰어 검토 결과: `PASS` (차단 이슈 없음).
|
||||
|
||||
- [x] **Task 7.4: 중복 성인 정책 함수 정리**
|
||||
- Files:
|
||||
- Modify/Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicy.kt`
|
||||
- Modify/Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`
|
||||
- Verify: `src/main/kotlin`
|
||||
- Verify: `src/test/kotlin`
|
||||
- TDD 예외 사유: 정책 계산 로직의 공개 진입점 정리와 잔여 사용처 확인 task이며, Task 7.1/7.2의 회귀 테스트가 동작 동일성을 검증한다.
|
||||
- 대체 검증 방법:
|
||||
- Run: `rg -n "isAdultVisibleByPolicy|resolveCountryCodeByPolicy" src/main/kotlin src/test/kotlin`
|
||||
- Run: `rg -n "calculateIsAdultForQuery|canViewAdultContent\\(" src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference`
|
||||
- GREEN: `isAdultVisibleByPolicy(...)`와 `resolveCountryCodeByPolicy(...)`의 production 사용처가 모두 없어졌으면 제거한다. 아직 v2 외부 사용처가 남아 있으면 즉시 제거하지 않고 `@Deprecated("Use MemberContentPreferenceService.canViewAdultContent(member)")`로 표시한 뒤 별도 후속 task를 남긴다.
|
||||
- REFACTOR: 성인 콘텐츠 조회 가능 여부 정책의 canonical 진입점은 `MemberContentPreferenceService.canViewAdultContent(member)`로 문서화하고, 내부 계산은 기존 `calculateIsAdultForQuery(...)`를 재사용한다.
|
||||
- 기대 결과: 동일한 정책을 중복 구현한 `isAdultVisibleByPolicy(...)` 경로가 제거되거나 명확히 deprecated 처리되어, 신규 호출부가 다시 분산되지 않는다.
|
||||
- 검증 기록: 구현 완료 시 실행 명령, 결과, 제거하지 못한 사용처가 있으면 사유와 후속 task를 이 task 아래에 한국어로 누적 기록한다.
|
||||
- 2026-06-23 Phase 7 구현 기록:
|
||||
- `rg -n "isAdultVisibleByPolicy|resolveCountryCodeByPolicy" src/main/kotlin src/test/kotlin` 실행 결과 v2 외부 기존 production 사용처(`content/main`, `content/series`, `content/theme`, `content/AudioContentService` 등)가 남아 있어 즉시 제거하지 않았다.
|
||||
- `MemberContentPreferencePolicy.resolveCountryCodeByPolicy(...)`와 `isAdultVisibleByPolicy(...)`에 `@Deprecated("Use MemberContentPreferenceService.canViewAdultContent(member)")`를 추가했다.
|
||||
- 성인 콘텐츠 조회 가능 여부 정책의 신규 canonical 진입점은 `MemberContentPreferenceService.canViewAdultContent(member)`로 정리했다.
|
||||
- 2026-06-23 Phase 7 코드 리뷰 및 추가 검증 기록:
|
||||
- 코드 리뷰: `canViewAdultContent(member)`가 `getStoredPreference(member).isAdult`를 반환해 기본 preference 초기화, 국가 정책, 성인 인증 여부 계산 경로를 그대로 재사용함을 확인했다. v2 추천 탭/홈/creator channel 호출부도 해당 service 메서드로 통일되어 차단 이슈 없음.
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest`: `BUILD SUCCESSFUL`.
|
||||
- `rg -n "isAdultVisibleByPolicy|getStoredPreference\([^\n]*\)\.isAdult" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2`: 결과 없음.
|
||||
- `rg -n "canViewAdultContent\(" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2 src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference`: `MemberContentPreferenceService`와 Phase 7 v2 변경 호출부에서 canonical 메서드 사용 확인.
|
||||
- `git diff --check`: 출력 없음.
|
||||
- `./gradlew ktlintCheck`: sandbox 환경에서는 Gradle wrapper lock 파일 접근 제한으로 실패했으나, 승인 후 sandbox 밖에서 재실행해 `BUILD SUCCESSFUL` 확인.
|
||||
|
||||
### Phase 8: 회귀 검증과 문서 기록
|
||||
|
||||
- [ ] **Task 8.1: 전체 관련 테스트와 ktlint 실행**
|
||||
- Files:
|
||||
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md`
|
||||
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`
|
||||
- TDD 예외 사유: 구현 완료 후 회귀 검증과 문서 기록 task이므로 신규 실패 테스트 작성 대상이 아니다.
|
||||
- 대체 검증 방법:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.*`
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.*`
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest`
|
||||
- Run: `./gradlew ktlintCheck`
|
||||
- 기대 결과: 모든 관련 테스트와 ktlint가 `BUILD SUCCESSFUL`이다.
|
||||
- 검증 기록: 구현 완료 시 실행 명령, 결과, 실패 시 원인과 수정 내용을 이 task 아래에 한국어로 누적 기록한다.
|
||||
|
||||
- [ ] **Task 8.2: 문서/스키마 영향 최종 확인**
|
||||
- Files:
|
||||
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md`
|
||||
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedSectionType.kt`
|
||||
- TDD 예외 사유: 문서와 enum 확장 범위 확인 task이므로 신규 실패 테스트 작성 대상이 아니다.
|
||||
- 대체 검증 방법:
|
||||
- Run: `rg -n "GET /api/v2/audio/recommendations|AudioRecommendationsResponse|NEW_AND_HOT_AUDIO_SAFE|RECOMMENDED_AUDIO_ALL" docs src/main/kotlin src/test/kotlin`
|
||||
- Run: `rg -n "api\\.audio\\.recommendation|v2\\.audio\\.recommendation|api/audio/recommendation|v2/audio/recommendation" src/main/kotlin src/test/kotlin`
|
||||
- Run: `rg -n "isAdultVisibleByPolicy|getStoredPreference\\([^\\n]*\\)\\.isAdult" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2`
|
||||
- Run: `rg -n "isAdultVisibleByPolicy|resolveCountryCodeByPolicy" src/main/kotlin src/test/kotlin`
|
||||
- Run: `./gradlew tasks --all`
|
||||
- 기대 결과: 공개 API endpoint와 응답 필드명이 문서/코드/테스트에서 일치하고, 신규 DB 테이블 DDL이 필요하지 않으며, 코드의 최종 패키지 구조가 `content.recommendation` 기준이고, v2 성인 콘텐츠 조회 정책 계산 경로가 service 메서드로 통일됐음이 확인된다.
|
||||
- 검증 기록: 구현 완료 시 문서와 코드 검색 결과를 이 task 아래에 한국어로 누적 기록한다.
|
||||
|
||||
---
|
||||
|
||||
## 전체 검증 기록
|
||||
|
||||
- 계획 문서 생성 시점에는 구현 코드를 변경하지 않았으므로 테스트 실행 대상은 없다.
|
||||
- 문서 변경 후 명령 유효성 확인은 `./gradlew tasks --all`로 수행한다.
|
||||
- 패키지 구조 변경 계획 문서 수정 후 `./gradlew tasks --all`을 실행했다. 최초 sandbox 실행은 Gradle wrapper lock 파일의 `~/.gradle` 접근 권한 문제로 실패했고, 승인 후 재실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||
|
||||
|
||||
## Phase 1-3 검증 기록
|
||||
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`: `BUILD SUCCESSFUL` (홈 배너 공통 DTO 직렬화 필드 포함 확인).
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest`: 최초 실행 시 점수 정책 테스트 기대값 산식 오산으로 실패 후 기대값 수정.
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`: `BUILD SUCCESSFUL`.
|
||||
- Phase 4 범위로 보일 수 있는 snapshot 후보 조회 stub 제거 후 동일한 6개 타깃 테스트 명령을 재실행했고 `BUILD SUCCESSFUL`.
|
||||
- reviewer 지적 사항 반영: `latestComment` 응답 필드 추가, PRD 기준 최신성 배수 수정, JSON boolean 필드명과 공개 오디오 필터 테스트 보강.
|
||||
- `./gradlew test --rerun-tasks --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`: `BUILD SUCCESSFUL`.
|
||||
- `./gradlew ktlintCheck`: `BUILD SUCCESSFUL`.
|
||||
- 추가 code review 지적 사항 반영: production `SecurityConfig`에 `GET /api/v2/audio/recommendations` 비회원 허용 추가, controller 테스트의 테스트 전용 permitAll 보안 체인 제거, 오디오 추천 배너의 성인 배너 필터 제거로 홈 추천 배너와 동일 정책 유지, 오리지널 시리즈 최신순/12개 limit 및 무료/포인트 10개 limit 테스트 보강.
|
||||
- 동일 targeted test 명령과 `./gradlew ktlintCheck`를 재실행했고 모두 `BUILD SUCCESSFUL`.
|
||||
|
||||
## Phase 4-5 검증 기록
|
||||
|
||||
- RED: `DefaultAudioRecommendationQueryRepositoryTest`, `AudioRecommendationSnapshotRefreshServiceTest`, `AudioRecommendationSnapshotSchedulerTest`, `AudioRecommendationQueryServiceTest`에 Phase 4/5 실패 테스트를 먼저 추가했다. 초기 실행에서 query service Mockito matcher 오류와 동시 Gradle 실행으로 인한 XML 결과 파일 쓰기 충돌, ktlint formatting 실패를 확인했다.
|
||||
- GREEN: snapshot 후보 native SQL, 최신 댓글 상세 조회, KST 기준 refresh service, 00:00 KST Redisson lock scheduler, query service snapshot 조립과 New & Hot lazy refresh를 구현하고 실패 원인을 수정했다.
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`: `BUILD SUCCESSFUL` (New & Hot/최근 댓글/추천 후보 산정, 댓글 상세, 기존 실시간 섹션 회귀 포함).
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest`: `BUILD SUCCESSFUL` (KST 전날 23:59:59 기준과 여섯 section replace 확인).
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.scheduler.AudioRecommendationSnapshotSchedulerTest`: `BUILD SUCCESSFUL` (cron/zone, lock 획득/skip/unlock 확인).
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest`: `BUILD SUCCESSFUL` (SAFE snapshot 조회, New & Hot lazy refresh, 빈 mostCommented/recommended 허용 확인).
|
||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.audio.recommendation.*'`: `BUILD SUCCESSFUL` (facade DTO 변환과 controller permitAll 응답 계약 확인).
|
||||
- `./gradlew ktlintCheck`: `BUILD SUCCESSFUL`.
|
||||
- 추가 점검: 댓글 상세 조회에서 차단 작성자 제외 후 최신 active 댓글을 선택하도록 보강하고 `DefaultAudioRecommendationQueryRepositoryTest`, `./gradlew ktlintCheck`를 재실행해 모두 `BUILD SUCCESSFUL`을 확인했다.
|
||||
- 추가 code review 지적 사항 반영: `findCommentedAudiosByIds`의 최신 댓글 상세 조회가 스냅샷 산정 SQL과 동일하게 크리에이터-댓글 작성자 간 차단 댓글을 제외하도록 보강하고, 해당 작성자의 더 최신 댓글이 이전 정상 댓글 선택을 막지 않도록 `newer` 후보에도 같은 차단 조건을 적용했다.
|
||||
- `DefaultAudioRecommendationQueryRepositoryTest`에 viewer와 무관한 크리에이터-댓글 작성자 차단 회귀 테스트를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`: `BUILD SUCCESSFUL`.
|
||||
- Task 5.3 보강: `AudioRecommendationEndToEndTest`를 추가해 `@SpringBootTest` + `@AutoConfigureMockMvc`로 production SecurityConfig, controller, facade, query service, repository, snapshot 조회 조합을 통과하는 최소 E2E를 검증했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationEndToEndTest`: `BUILD SUCCESSFUL`.
|
||||
- 2026-06-23 리뷰 보정: 스냅샷 기준/윈도우를 UTC 변환 `LocalDateTime`이 아니라 KST-local `LocalDateTime`으로 저장/조회하도록 보정하고, 최신성 일수는 24시간 경과 기준으로 Kotlin 정책을 맞췄다. New & Hot lazy refresh는 보강 후에도 row가 없으면 Redis marker 기준 같은 KST 날짜에 반복 실행하지 않도록 보강했다.
|
||||
- 2026-06-23 리뷰 보정 후 추가 보정: post-implementation review에서 `getRecommendations()`의 read-only transaction 안에서 lazy refresh 후 재조회하면 MySQL `REPEATABLE_READ` read view 때문에 새 스냅샷이 같은 요청에서 보이지 않을 수 있다고 지적해, query service의 외부 read-only transaction을 제거했다. 또한 인메모리 guard는 프로세스 재시작/다중 서버에서 KST 날짜별 1회를 보장하지 못하므로 `RedissonClient` Redis marker(`audio-recommendation:new-and-hot:lazy-refresh-attempted:{yyyy-MM-dd}`, TTL 2일)로 변경했다.
|
||||
- 2026-06-23 리뷰 보정 검증: `./gradlew --stop && ./gradlew clean test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest`: `BUILD SUCCESSFUL`.
|
||||
- 2026-06-23 리뷰 보정 검증: repository focused test는 병렬 Gradle 실행 중 `kaptGenerateStubsTestKotlin` 출력 디렉터리 충돌로 1회 실패해 단독 재실행했다. H2 `MODE=MySQL`의 `TIMESTAMPDIFF` 경계 동작이 운영 MySQL 공식 기준과 달라 신규 repository 경계 테스트는 제거하고 Kotlin 정책 테스트로 24시간 경계를 고정했다. 최종 `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`: `BUILD SUCCESSFUL`.
|
||||
- 2026-06-23 리뷰 보정 검증: `./gradlew ktlintCheck`: `BUILD SUCCESSFUL`, `git diff --check`: 출력 없음.
|
||||
302
docs/20260623_메인_콘텐츠_추천_탭_API/prd.md
Normal file
302
docs/20260623_메인_콘텐츠_추천_탭_API/prd.md
Normal file
@@ -0,0 +1,302 @@
|
||||
# PRD: 메인 콘텐츠 추천 탭 API
|
||||
|
||||
## 1. Overview
|
||||
메인 콘텐츠 탭의 내부 추천 탭에서 사용할 배너, 오리지널 시리즈, 신규/추천/무료/포인트 오디오, New & Hot, 최근 댓글 많은 오디오 섹션을 한 번에 조회하는 v2 API를 제공한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. Problem
|
||||
- 기존 `content.main.tab.home` API는 콘텐츠 홈 화면 전체 구성을 조립하지만, 신규 내부 추천 탭의 섹션 구성과 응답 필드가 다르다.
|
||||
- 신규 추천 탭은 실시간 최신순/랜덤 조회와 일 단위 스냅샷 기반 점수 섹션이 섞여 있어, API 조립 계층과 도메인 조회 계층의 책임을 분리해야 한다.
|
||||
- 기존 v2 패키지에 홈 추천 API, 스냅샷, 배너 조회, 오디오 응답 DTO와 유사한 코드가 있으므로 구현 전 재사용 범위를 명확히 해야 한다.
|
||||
- New & Hot, 최근 댓글 많은 오디오처럼 매일 갱신되는 섹션은 데이터가 없을 때 표시/스케줄 보강 정책이 필요하다.
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals
|
||||
- 메인 콘텐츠 추천 탭 조회 API를 `kr.co.vividnext.sodalive.v2` 하위 신규 코드로 제공한다.
|
||||
- 기존 패턴과 동일하게 공개 API 조립 계층과 도메인 조회 계층을 분리한다.
|
||||
- 메인 배너는 메인 홈 추천 배너와 동일한 데이터를 응답한다.
|
||||
- 오리지널 시리즈, 최신 오디오, 무료 오디오, 포인트 오디오는 요청 시점 기준으로 조회한다.
|
||||
- New & Hot, 최근 댓글 많은 오디오, 추천 오디오는 KST 매일 00:00에 전날 23:59:59 KST까지의 데이터를 반영한 스냅샷을 사용한다.
|
||||
- New & Hot 스냅샷 데이터가 없으면 조회 시점에 lazy로 스케줄/집계 보강을 요청할 수 있어야 한다.
|
||||
- 최근 댓글 많은 오디오는 스냅샷 데이터가 없으면 섹션을 빈 배열로 내려주어 앱에서 표시하지 않게 한다.
|
||||
- PRD에 API endpoint와 Response data class 초안을 포함한다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Non-Goals
|
||||
- 기존 `content.main.tab.home` 공개 API 스키마를 변경하지 않는다.
|
||||
- 기존 메인 홈 추천 API의 공개 스키마를 변경하지 않는다.
|
||||
- 관리자 화면, 수동 편집 기능, 추천 결과 강제 고정 기능은 포함하지 않는다.
|
||||
- 개인화 추천 모델, A/B 테스트, 머신러닝 기반 추천은 포함하지 않는다.
|
||||
- 전체보기/페이징 API는 이번 요구사항에 포함하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 5. Target Users
|
||||
- 회원: 콘텐츠 메인 탭에서 추천 오디오와 오리지널 시리즈를 탐색하는 사용자
|
||||
- 비회원: 인증 없이 조회 가능한 추천 콘텐츠를 탐색하는 사용자
|
||||
- 앱 클라이언트: 추천 탭 첫 화면 섹션을 한 API 응답으로 구성하는 클라이언트
|
||||
|
||||
---
|
||||
|
||||
## 6. User Stories
|
||||
- 사용자는 추천 탭 진입 시 메인 홈 추천 배너와 동일한 배너를 보고 싶다.
|
||||
- 사용자는 오직 보이스 온에서만 볼 수 있는 오리지널 시리즈를 최신순으로 보고 싶다.
|
||||
- 사용자는 새로 올라온 오디오를 최신순으로 확인하고 싶다.
|
||||
- 사용자는 최근 반응이 좋은 New & Hot 오디오를 보고 싶다.
|
||||
- 사용자는 무료 오디오와 포인트 사용 가능 오디오를 빠르게 탐색하고 싶다.
|
||||
- 사용자는 최근 댓글이 많은 오디오와 해당 오디오의 최신 댓글, 최신 댓글 작성자 프로필 이미지를 보고 싶다.
|
||||
- 사용자는 서버 추천 점수 기반의 추천 오디오를 보고 싶다.
|
||||
|
||||
---
|
||||
|
||||
## 7. Core Features
|
||||
|
||||
### Feature A. 메인 콘텐츠 추천 탭 통합 조회
|
||||
|
||||
#### Requirements
|
||||
- 신규 API endpoint는 `GET /api/v2/audio/recommendations`로 정의한다.
|
||||
- 응답 wrapper는 기존 패턴과 동일하게 `ApiResponse.ok(...)`를 사용한다.
|
||||
- 인증 회원이면 회원의 콘텐츠 조회 설정과 19금 노출 가능 여부를 반영한다.
|
||||
- 비회원이면 19금 콘텐츠를 노출하지 않는다.
|
||||
- 회원이 차단했거나 회원을 차단한 크리에이터의 시리즈/오디오는 노출하지 않는다.
|
||||
- 섹션별 기본 노출 수는 아래와 같다.
|
||||
- `banners`: 메인 홈 추천 배너와 동일
|
||||
- `originalSeries`: 최신순 12개
|
||||
- `latestAudios`: 최신순 12개
|
||||
- `newAndHotAudios`: 최대 12개
|
||||
- `freeAudios`: 최대 10개 랜덤
|
||||
- `pointAudios`: 최대 10개 랜덤
|
||||
- `mostCommentedAudios`: 최대 5개
|
||||
- `recommendedAudios`: 최대 10개
|
||||
- 특정 섹션 데이터가 부족하면 가능한 개수만 내려주고 전체 API는 성공 처리한다.
|
||||
- 무료/포인트/추천 오디오 섹션 사이에는 같은 오디오가 중복 노출될 수 있다.
|
||||
|
||||
#### Edge Cases
|
||||
- 한 섹션 조회 실패가 전체 API 실패로 이어질지는 구현 계획 단계에서 기존 v2 통합 조회 API의 로깅/실패 정책과 비교해 결정한다.
|
||||
- 예약 공개 콘텐츠는 공개 전에는 노출하지 않는다.
|
||||
- 비활성 콘텐츠, duration이 없는 콘텐츠, 비활성 크리에이터의 콘텐츠는 노출하지 않는다.
|
||||
|
||||
### Feature B. 메인 배너
|
||||
|
||||
#### Requirements
|
||||
- 메인 홈 추천 배너와 동일한 데이터를 사용한다.
|
||||
- 기존 v2 홈 추천 API의 배너 응답 구조를 공통 DTO로 분리해 재사용한다.
|
||||
- 배너 응답 필드는 `imageUrl`, `eventItem`, `creatorId`, `seriesId`, `link`를 유지한다.
|
||||
- 배너 대상 엔티티가 비활성 처리되었거나 차단 관계에 있으면 기존 홈 추천 배너 정책과 동일하게 제외한다.
|
||||
|
||||
### Feature C. 오직 보이스 온에서만
|
||||
|
||||
#### Requirements
|
||||
- 오리지널 시리즈를 최신순으로 12개 조회한다.
|
||||
- `series.isOriginal = true`인 시리즈만 대상으로 한다.
|
||||
- 활성 시리즈와 활성 크리에이터만 노출한다.
|
||||
- Response 필드는 `seriesId`, `coverImageUrl`만 포함하고, 최상위 응답 필드명은 `originalSeries`로 한다.
|
||||
|
||||
### Feature D. 새로 올라온 오디오
|
||||
|
||||
#### Requirements
|
||||
- 공개된 오디오 콘텐츠를 최신순으로 12개 조회한다.
|
||||
- 최신순 기준은 `releaseDate desc`, 동률이면 `audioContentId desc`로 한다.
|
||||
- Response는 공통 오디오 카드 응답을 사용한다.
|
||||
|
||||
### Feature E. New & Hot
|
||||
|
||||
#### Requirements
|
||||
- 최대 12개를 표시한다.
|
||||
- KST 매일 00:00에 전날 23:59:59 KST까지의 데이터를 반영해 스냅샷을 갱신한다.
|
||||
- 최근 3일 데이터를 기반으로 최종 점수를 산출한다.
|
||||
- 최종 점수는 `최신성 35% + 조회수 35% + 좋아요 15% + 댓글 수 15%`로 계산한다.
|
||||
- 조회수는 `creator_content_view_history`의 상세 페이지 조회 이력을 기준으로 최근 3일 `content_id`별 count를 사용한다.
|
||||
- 조회수, 좋아요 수, 댓글 수는 후보 내 정규화 없이 원본 count를 그대로 사용한다.
|
||||
- 최신성 배수는 공개 3일 이내 1.3, 7일 이내 1.15, 14일 이내 1.0, 그 외 0.8을 적용한다.
|
||||
- 19금 노출 정책은 스냅샷 variant로 분리한다.
|
||||
- `SAFE`: 19금이 아닌 콘텐츠만 포함한다.
|
||||
- `ALL`: 19금 콘텐츠와 19금이 아닌 콘텐츠를 모두 포함한다.
|
||||
- 19금 노출이 불가능한 사용자와 비회원은 `SAFE` 스냅샷을 조회한다.
|
||||
- 19금 노출이 가능한 회원은 `ALL` 스냅샷을 조회한다.
|
||||
- 산출된 스냅샷 데이터가 없으면 lazy로 스케줄/집계 보강을 추가한다.
|
||||
- Response는 공통 오디오 카드 응답을 사용한다.
|
||||
|
||||
#### Edge Cases
|
||||
- lazy 보강 중에도 즉시 산출 가능한 결과가 없으면 빈 배열로 내려준다.
|
||||
|
||||
### Feature F. 무료 오디오
|
||||
|
||||
#### Requirements
|
||||
- 무료 오디오 중 랜덤으로 최대 10개 조회한다.
|
||||
- 무료 오디오는 `price = 0`인 공개 오디오로 정의한다.
|
||||
- Response는 공통 오디오 카드 응답을 사용한다.
|
||||
|
||||
### Feature G. 포인트 오디오
|
||||
|
||||
#### Requirements
|
||||
- 포인트 사용 가능 오디오 중 랜덤으로 최대 10개 조회한다.
|
||||
- 포인트 오디오는 `isPointAvailable = true`인 공개 오디오로 정의한다.
|
||||
- Response는 공통 오디오 카드 응답을 사용한다.
|
||||
|
||||
### Feature H. 최근 댓글이 많은 오디오
|
||||
|
||||
#### Requirements
|
||||
- 댓글 점수는 `댓글 수 80% + 댓글 최신성 20%`로 계산한다.
|
||||
- 댓글 최신성 점수는 댓글 작성 3일 이내 1.3, 7일 이내 1.15, 14일 이내 1.0, 그 이상 0을 적용한다.
|
||||
- KST 매일 00:00에 전날 23:59:59 KST까지의 데이터를 반영해 스냅샷을 갱신한다.
|
||||
- 최근 7일 댓글 데이터를 기반으로 최종 점수를 산출한다.
|
||||
- 데이터가 없으면 섹션을 표시하지 않도록 빈 배열로 내려준다.
|
||||
- 최대 5개를 표시한다.
|
||||
- 오디오별 가장 최신 댓글 1개의 본문과 글쓴이 프로필 이미지를 함께 내려준다.
|
||||
|
||||
#### Edge Cases
|
||||
- 비활성 댓글, 삭제된 댓글, 차단 관계의 댓글 작성자 프로필은 노출하지 않는다.
|
||||
- 최신 댓글 작성자 프로필 이미지가 없으면 기본 프로필 이미지 URL 정책을 적용한다.
|
||||
|
||||
### Feature I. 추천 오디오
|
||||
|
||||
#### Requirements
|
||||
- 최대 10개를 표시한다.
|
||||
- KST 매일 00:00에 전날 23:59:59 KST까지의 데이터를 반영해 스냅샷을 갱신한다.
|
||||
- 추천 점수는 `조회수 45% + 좋아요 25% + 댓글 수 20% + 최신성 10%`로 계산한다.
|
||||
- 조회수는 `creator_content_view_history`의 상세 페이지 조회 이력을 기준으로 스냅샷 집계 기간 내 `content_id`별 count를 사용한다.
|
||||
- 조회수, 좋아요 수, 댓글 수는 후보 내 정규화 없이 원본 count를 그대로 사용한다.
|
||||
- 최신성 배수는 공개 3일 이내 1.3, 7일 이내 1.15, 30일 이내 1.1, 그 외 1.0을 적용한다.
|
||||
- 19금 노출 정책은 New & Hot과 동일하게 `SAFE`, `ALL` 스냅샷 variant로 분리한다.
|
||||
- Response는 공통 오디오 카드 응답을 사용한다.
|
||||
|
||||
---
|
||||
|
||||
## 8. API Endpoint
|
||||
|
||||
```http
|
||||
GET /api/v2/audio/recommendations
|
||||
Authorization: Bearer {accessToken} (optional)
|
||||
```
|
||||
|
||||
- 비회원 조회를 허용한다.
|
||||
- 회원 조회 시 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴을 사용한다.
|
||||
- 별도 request query parameter는 정의하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 9. Response Data Class
|
||||
|
||||
```kotlin
|
||||
data class AudioRecommendationsResponse(
|
||||
val banners: List<AudioBannerResponse>,
|
||||
val originalSeries: List<OriginalSeriesResponse>,
|
||||
val latestAudios: List<AudioCardResponse>,
|
||||
val newAndHotAudios: List<AudioCardResponse>,
|
||||
val freeAudios: List<AudioCardResponse>,
|
||||
val pointAudios: List<AudioCardResponse>,
|
||||
val mostCommentedAudios: List<CommentedAudioResponse>,
|
||||
val recommendedAudios: List<AudioCardResponse>
|
||||
)
|
||||
|
||||
data class AudioBannerResponse(
|
||||
val imageUrl: String,
|
||||
val eventItem: EventItem?,
|
||||
val creatorId: Long?,
|
||||
val seriesId: Long?,
|
||||
val link: String?
|
||||
)
|
||||
|
||||
data class OriginalSeriesResponse(
|
||||
val seriesId: Long,
|
||||
val coverImageUrl: String?
|
||||
)
|
||||
|
||||
data class AudioCardResponse(
|
||||
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,
|
||||
@JsonProperty("isOriginalSeries")
|
||||
val isOriginalSeries: Boolean,
|
||||
val creatorNickname: String
|
||||
)
|
||||
|
||||
data class CommentedAudioResponse(
|
||||
val audioContentId: Long,
|
||||
val title: String,
|
||||
val imageUrl: String?,
|
||||
val latestComment: String,
|
||||
val latestCommentWriterProfileImageUrl: String
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Technical Constraints
|
||||
|
||||
### 패키지 구조
|
||||
- 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.content.recommendation` 하위에 둔다.
|
||||
- Controller: `...adapter.in.web`
|
||||
- Facade: `...application`
|
||||
- Response DTO: `...dto`
|
||||
- 도메인 조회 계층은 `kr.co.vividnext.sodalive.v2.content.recommendation` 하위에 둔다.
|
||||
- Query service: `...application`
|
||||
- 점수 정책/domain model: `...domain`
|
||||
- 조회 port: `...port.out`
|
||||
- QueryDSL/JPA 구현: `...adapter.out.persistence`
|
||||
- scheduler: `...adapter.out.scheduler`
|
||||
- `content` 패키지는 오디오 콘텐츠뿐 아니라 오리지널 시리즈 등 추천 탭에 포함될 수 있는 콘텐츠 범주를 포괄하기 위한 명칭이다.
|
||||
- 의존 방향은 `v2.api.content.recommendation -> v2.content.recommendation`만 허용한다.
|
||||
|
||||
### V2 공통화/재사용 대상
|
||||
- `HomeBannerItem`은 메인 홈 전용 DTO가 아니라 여러 추천 화면에서 사용할 배너 응답 구조이므로 `v2.api.common.dto` 계열 공통 DTO로 분리한다.
|
||||
- `v2.recommendation.adapter.out.persistence.RecommendationSnapshot`: 일 단위 추천 스냅샷 저장 구조
|
||||
- `v2.recommendation.adapter.out.scheduler.RecommendationSnapshotScheduler`: Redisson 분산 lock이 적용된 스케줄러 패턴
|
||||
- `v2.recommendation.adapter.out.persistence.CreatorContentViewHistory`: 오디오 상세 페이지 조회 이력 저장 구조
|
||||
- `v2.recommendation.application.CreatorContentViewHistoryService`: `AudioContentService.getDetail(...)`에서 상세 조회 이력을 기록하는 서비스
|
||||
- `v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponse`: 오디오 카드 응답 필드와 `JsonProperty` 네이밍 패턴
|
||||
- `v2.common.domain.CdnUrlExtensions`: 이미지 URL 변환 공통 함수
|
||||
|
||||
### 참고할 기존 패턴
|
||||
- `v2.api.home.adapter.in.web.HomeRecommendationController`와 `v2.api.home.application.HomeRecommendationFacade`는 메인 페이지 Home 탭 전용이므로 직접 재사용하지 않는다.
|
||||
- 신규 API도 controller가 인증/요청 경계를 담당하고 facade가 도메인 조회 결과를 공개 응답 DTO로 변환하는 계층 분리 방식만 참고한다.
|
||||
|
||||
### 스냅샷/스케줄
|
||||
- New & Hot, 최근 댓글 많은 오디오, 추천 오디오는 스냅샷 기반 조회를 우선한다.
|
||||
- 스케줄러는 `@Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul")` 기준으로 설계한다.
|
||||
- 다중 서버 환경에서 중복 실행을 막기 위해 기존 Redisson lock 패턴을 따른다.
|
||||
- 스냅샷 기준 시각은 KST 전날 `23:59:59`를 UTC 변환 없이 KST-local `LocalDateTime`으로 저장한다.
|
||||
- 스냅샷 집계 window도 KST-local `00:00:00`부터 KST-local `23:59:59`까지를 기준으로 계산한다.
|
||||
- 19금 노출 영향을 받는 스냅샷 섹션은 visibility variant를 저장한다.
|
||||
- `SAFE`: 19금이 아닌 콘텐츠만 포함한다.
|
||||
- `ALL`: 19금 콘텐츠와 19금이 아닌 콘텐츠를 모두 포함한다.
|
||||
- `SAFE`와 `ALL`을 분리하는 이유는 스냅샷 조회 후 19금 콘텐츠를 필터링할 경우 비회원/19금 노출 불가 회원에게 최대 노출 개수를 안정적으로 채우기 어렵기 때문이다.
|
||||
- 기존 `recommendation_snapshot`을 확장 재사용할지, 콘텐츠 추천 전용 스냅샷 테이블을 만들지는 구현 계획에서 DDL 영향과 enum 확장 범위를 비교해 결정한다.
|
||||
|
||||
### 조회 정책
|
||||
- 모든 오디오 섹션은 활성 콘텐츠, 활성 크리에이터, `duration is not null`, `releaseDate <= now` 조건을 기본으로 한다.
|
||||
- 성인 콘텐츠는 회원의 `MemberContentPreference`와 본인인증 정책을 반영한다.
|
||||
- 비회원은 성인 콘텐츠를 제외한다.
|
||||
- 차단 관계 필터는 기존 v2 홈/크리에이터 채널 조회 패턴을 따른다.
|
||||
- 조회수 점수의 조회수는 `AudioContent.playCount`가 아니라 `creator_content_view_history`의 상세 페이지 조회 이력 count를 사용한다.
|
||||
- 최신성 점수의 일수는 날짜 경계가 아니라 시간까지 포함한 24시간 경과 일수 기준으로 계산한다.
|
||||
- New & Hot lazy 보강은 스냅샷 row가 없을 때 Redis marker 기준 KST 날짜별 1회만 시도하고, 보강 후 후보가 0개인 정상 상황에서는 같은 날짜의 다음 조회가 전체 refresh를 반복하지 않는다.
|
||||
- 공통 오디오 카드 응답의 `isOriginalSeries`는 시리즈 미소속 오디오이면 클라이언트 편의를 위해 `false`로 내려준다.
|
||||
- 무료/포인트/추천 오디오처럼 서로 다른 추천 섹션에 같은 콘텐츠가 동시에 포함되어도 서버에서 중복 제거하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 11. Metrics
|
||||
- API 성공/실패 로그
|
||||
- 섹션별 응답 개수
|
||||
- 스냅샷 갱신 성공/실패 로그
|
||||
- 스냅샷 갱신 대상 개수
|
||||
- lazy 보강 발생 횟수
|
||||
- 빈 섹션 목록
|
||||
|
||||
---
|
||||
|
||||
## 12. Open Questions
|
||||
- 없음
|
||||
@@ -102,6 +102,7 @@ class SecurityConfig(
|
||||
.antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll()
|
||||
.antMatchers(HttpMethod.POST, "/charge/payverse/webhook").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/api/v2/audio/recommendations").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/api/v2/home/rankings/creators").permitAll()
|
||||
// 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수
|
||||
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated()
|
||||
|
||||
@@ -4,12 +4,14 @@ import kr.co.vividnext.sodalive.member.Member
|
||||
import org.springframework.web.context.request.RequestContextHolder
|
||||
import org.springframework.web.context.request.ServletRequestAttributes
|
||||
|
||||
@Deprecated("Use MemberContentPreferenceService.canViewAdultContent(member)")
|
||||
fun resolveCountryCodeByPolicy(member: Member): String {
|
||||
val requestAttributes = RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes
|
||||
val requestCountryCode = requestAttributes?.request?.getHeader("CloudFront-Viewer-Country")
|
||||
return resolveCountryCodeWithForcedMapping(member, requestCountryCode)
|
||||
}
|
||||
|
||||
@Deprecated("Use MemberContentPreferenceService.canViewAdultContent(member)")
|
||||
fun isAdultVisibleByPolicy(member: Member, isAdultContentVisible: Boolean): Boolean {
|
||||
return if (resolveCountryCodeByPolicy(member) == "KR") {
|
||||
member.auth != null && isAdultContentVisible
|
||||
|
||||
@@ -153,6 +153,10 @@ class MemberContentPreferenceService(
|
||||
)
|
||||
}
|
||||
|
||||
fun canViewAdultContent(member: Member): Boolean {
|
||||
return getStoredPreference(member).isAdult
|
||||
}
|
||||
|
||||
fun resolveCountryCode(member: Member): String {
|
||||
requireMemberId(member)
|
||||
return resolveCountryCodeWithForcedMapping(member, countryContext.countryCode)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.common.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.event.EventItem
|
||||
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
|
||||
|
||||
data class RecommendationBannerResponse(
|
||||
val imageUrl: String,
|
||||
val eventItem: EventItem?,
|
||||
val creatorId: Long?,
|
||||
val seriesId: Long?,
|
||||
val link: String?
|
||||
) {
|
||||
companion object {
|
||||
fun from(banner: RecommendationBanner): RecommendationBannerResponse {
|
||||
return RecommendationBannerResponse(
|
||||
imageUrl = banner.imageUrl,
|
||||
eventItem = banner.eventItem,
|
||||
creatorId = banner.creatorId,
|
||||
seriesId = banner.seriesId,
|
||||
link = banner.link
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.`in`.web
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacade
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v2/audio/recommendations")
|
||||
class AudioRecommendationController(
|
||||
private val audioRecommendationFacade: AudioRecommendationFacade
|
||||
) {
|
||||
@GetMapping
|
||||
fun getRecommendations(
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
ApiResponse.ok(audioRecommendationFacade.getRecommendations(member))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.content.recommendation.application
|
||||
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.v2.api.content.recommendation.dto.AudioRecommendationsResponse
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryService
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Component
|
||||
class AudioRecommendationFacade(
|
||||
private val queryService: AudioRecommendationQueryService
|
||||
) {
|
||||
fun getRecommendations(member: Member?): AudioRecommendationsResponse {
|
||||
return AudioRecommendationsResponse.from(queryService.getRecommendations(member))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.content.recommendation.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries
|
||||
|
||||
data class AudioRecommendationsResponse(
|
||||
val banners: List<RecommendationBannerResponse>,
|
||||
val originalSeries: List<OriginalSeriesResponse>,
|
||||
val latestAudios: List<AudioCardResponse>,
|
||||
val newAndHotAudios: List<AudioCardResponse>,
|
||||
val freeAudios: List<AudioCardResponse>,
|
||||
val pointAudios: List<AudioCardResponse>,
|
||||
val mostCommentedAudios: List<CommentedAudioResponse>,
|
||||
val recommendedAudios: List<AudioCardResponse>
|
||||
) {
|
||||
companion object {
|
||||
fun from(recommendations: AudioRecommendations): AudioRecommendationsResponse {
|
||||
return AudioRecommendationsResponse(
|
||||
banners = recommendations.banners.map(RecommendationBannerResponse::from),
|
||||
originalSeries = recommendations.originalSeries.map(OriginalSeriesResponse::from),
|
||||
latestAudios = recommendations.latestAudios.map(AudioCardResponse::from),
|
||||
newAndHotAudios = recommendations.newAndHotAudios.map(AudioCardResponse::from),
|
||||
freeAudios = recommendations.freeAudios.map(AudioCardResponse::from),
|
||||
pointAudios = recommendations.pointAudios.map(AudioCardResponse::from),
|
||||
mostCommentedAudios = recommendations.mostCommentedAudios.map(CommentedAudioResponse::from),
|
||||
recommendedAudios = recommendations.recommendedAudios.map(AudioCardResponse::from)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class OriginalSeriesResponse(
|
||||
val seriesId: Long,
|
||||
val coverImageUrl: String?
|
||||
) {
|
||||
companion object {
|
||||
fun from(series: OriginalSeries): OriginalSeriesResponse {
|
||||
return OriginalSeriesResponse(series.seriesId, series.coverImageUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class AudioCardResponse(
|
||||
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,
|
||||
@JsonProperty("isOriginalSeries")
|
||||
val isOriginalSeries: Boolean,
|
||||
val creatorNickname: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(audio: AudioCard): AudioCardResponse {
|
||||
return AudioCardResponse(
|
||||
audioContentId = audio.audioContentId,
|
||||
title = audio.title,
|
||||
duration = audio.duration,
|
||||
imageUrl = audio.imageUrl,
|
||||
price = audio.price,
|
||||
isAdult = audio.isAdult,
|
||||
isPointAvailable = audio.isPointAvailable,
|
||||
isFirstContent = audio.isFirstContent,
|
||||
isOriginalSeries = audio.isOriginalSeries,
|
||||
creatorNickname = audio.creatorNickname
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class CommentedAudioResponse(
|
||||
val audioContentId: Long,
|
||||
val title: String,
|
||||
val imageUrl: String?,
|
||||
val latestComment: String,
|
||||
val latestCommentWriterProfileImageUrl: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(audio: CommentedAudio): CommentedAudioResponse {
|
||||
return CommentedAudioResponse(
|
||||
audioContentId = audio.audioContentId,
|
||||
title = audio.title,
|
||||
imageUrl = audio.imageUrl,
|
||||
latestComment = audio.latestComment,
|
||||
latestCommentWriterProfileImageUrl = audio.latestCommentWriterProfileImageUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,9 @@ package kr.co.vividnext.sodalive.v2.api.home.application
|
||||
import kr.co.vividnext.sodalive.event.EventItem
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||
import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeActiveCreatorItem
|
||||
import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeAiCharacterItem
|
||||
import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeBannerItem
|
||||
import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeCreatorItem
|
||||
import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeFirstAudioContentItem
|
||||
import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeGenreCreatorGroupItem
|
||||
@@ -17,6 +16,7 @@ import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendatio
|
||||
import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.imageUrl
|
||||
import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.profileImageUrl
|
||||
import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.toUtcIso
|
||||
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeBannerRecommendationRecord
|
||||
@@ -53,7 +53,8 @@ class HomeRecommendationFacade(
|
||||
memberId = member?.id,
|
||||
includeAdultLives = includeAdult
|
||||
).map { it.toItem() },
|
||||
banners = queryService.findHomeBanners(HOME_BANNER_LIMIT, member?.id).map { it.toItem() },
|
||||
banners = queryService.findHomeBanners(HOME_BANNER_LIMIT, member?.id)
|
||||
.map { RecommendationBannerResponse.from(it.toBanner()) },
|
||||
recentlyActiveCreators = queryService.findRecentlyActiveCreators(
|
||||
HOME_ACTIVE_CREATOR_LIMIT,
|
||||
member?.id,
|
||||
@@ -213,8 +214,7 @@ class HomeRecommendationFacade(
|
||||
|
||||
private fun resolveAdultVisibility(member: Member?): Boolean {
|
||||
if (member == null) return false
|
||||
val preference = memberContentPreferenceService.initializeDefaultPreference(member)
|
||||
return isAdultVisibleByPolicy(member, preference.isAdultContentVisible)
|
||||
return memberContentPreferenceService.canViewAdultContent(member)
|
||||
}
|
||||
|
||||
private fun Int.toOffset(size: Int): Int = this * size
|
||||
@@ -235,7 +235,7 @@ class HomeRecommendationFacade(
|
||||
creatorProfileImage = profileImageUrl(cloudFrontHost, creatorProfileImage)
|
||||
)
|
||||
|
||||
private fun HomeBannerRecommendationRecord.toItem() = HomeBannerItem(
|
||||
private fun HomeBannerRecommendationRecord.toBanner() = RecommendationBanner(
|
||||
imageUrl = imageUrl(cloudFrontHost, thumbnailImage) ?: "",
|
||||
eventItem = eventItem(),
|
||||
creatorId = creatorId,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.home.dto.recommendation
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import kr.co.vividnext.sodalive.event.EventItem
|
||||
import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
@@ -19,7 +19,7 @@ internal fun profileImageUrl(cloudFrontHost: String, path: String?): String {
|
||||
|
||||
data class HomeRecommendationResponse(
|
||||
val lives: List<HomeLiveItem>,
|
||||
val banners: List<HomeBannerItem>,
|
||||
val banners: List<RecommendationBannerResponse>,
|
||||
val recentlyActiveCreators: List<HomeActiveCreatorItem>,
|
||||
val recentDebutCreators: List<HomeCreatorItem>,
|
||||
val firstAudioContents: List<HomeFirstAudioContentItem>,
|
||||
@@ -35,14 +35,6 @@ data class HomeLiveItem(
|
||||
val creatorProfileImage: String
|
||||
)
|
||||
|
||||
data class HomeBannerItem(
|
||||
val imageUrl: String,
|
||||
val eventItem: EventItem?,
|
||||
val creatorId: Long?,
|
||||
val seriesId: Long?,
|
||||
val link: String?
|
||||
)
|
||||
|
||||
data class HomeActiveCreatorItem(
|
||||
val creatorNickname: String,
|
||||
val creatorProfileImage: String,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package kr.co.vividnext.sodalive.v2.common.domain
|
||||
|
||||
import kr.co.vividnext.sodalive.event.EventItem
|
||||
|
||||
data class RecommendationBanner(
|
||||
val imageUrl: String,
|
||||
val eventItem: EventItem?,
|
||||
val creatorId: Long?,
|
||||
val seriesId: Long?,
|
||||
val link: String?
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
package kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort
|
||||
|
||||
interface AudioRecommendationQueryRepository : AudioRecommendationQueryPort
|
||||
@@ -0,0 +1,582 @@
|
||||
package kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence
|
||||
|
||||
import com.querydsl.core.Tuple
|
||||
import com.querydsl.core.types.Expression
|
||||
import com.querydsl.core.types.Projections
|
||||
import com.querydsl.core.types.dsl.BooleanExpression
|
||||
import com.querydsl.core.types.dsl.Expressions
|
||||
import com.querydsl.jpa.JPAExpressions
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
||||
import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner
|
||||
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.event.EventItem
|
||||
import kr.co.vividnext.sodalive.event.QEvent.event
|
||||
import kr.co.vividnext.sodalive.member.QMember
|
||||
import kr.co.vividnext.sodalive.member.QMember.member
|
||||
import kr.co.vividnext.sodalive.member.block.QBlockMember
|
||||
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
|
||||
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@Repository
|
||||
class DefaultAudioRecommendationQueryRepository(
|
||||
private val queryFactory: JPAQueryFactory,
|
||||
private val entityManager: EntityManager,
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val cloudFrontHost: String
|
||||
) : AudioRecommendationQueryRepository {
|
||||
override fun findBanners(limit: Int, memberId: Long?, canViewAdultContent: Boolean): List<RecommendationBanner> {
|
||||
val bannerCreator = QMember("audioRecommendationBannerCreator")
|
||||
val seriesOwner = QMember("audioRecommendationSeriesOwner")
|
||||
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
audioContentBanner.thumbnailImage,
|
||||
event.id,
|
||||
event.thumbnailImage,
|
||||
event.detailImage,
|
||||
event.link,
|
||||
bannerCreator.id,
|
||||
series.id,
|
||||
audioContentBanner.link
|
||||
)
|
||||
.from(audioContentBanner)
|
||||
.leftJoin(audioContentBanner.event, event)
|
||||
.leftJoin(audioContentBanner.creator, bannerCreator)
|
||||
.leftJoin(audioContentBanner.series, series)
|
||||
.leftJoin(series.member, seriesOwner)
|
||||
.where(
|
||||
audioContentBanner.isActive.isTrue,
|
||||
audioContentBanner.tab.isNull,
|
||||
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
|
||||
)
|
||||
.orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc())
|
||||
.limit(limit.toLong())
|
||||
.fetch()
|
||||
.map { row ->
|
||||
RecommendationBanner(
|
||||
imageUrl = row.get(audioContentBanner.thumbnailImage).toCdnUrl(cloudFrontHost) ?: "",
|
||||
eventItem = row.toEventItem(),
|
||||
creatorId = row.get(bannerCreator.id),
|
||||
seriesId = row.get(series.id),
|
||||
link = row.get(audioContentBanner.link)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun findOriginalSeries(
|
||||
limit: Int,
|
||||
memberId: Long?,
|
||||
canViewAdultContent: Boolean,
|
||||
now: LocalDateTime
|
||||
): List<OriginalSeries> {
|
||||
return queryFactory
|
||||
.select(Projections.constructor(OriginalSeries::class.java, series.id, series.coverImage))
|
||||
.from(series)
|
||||
.join(series.member, member)
|
||||
.where(
|
||||
series.isActive.isTrue,
|
||||
series.isOriginal.isTrue,
|
||||
member.isActive.isTrue,
|
||||
adultSeriesCondition(canViewAdultContent),
|
||||
notBlockedCreatorCondition(memberId, member.id)
|
||||
)
|
||||
.orderBy(series.createdAt.desc(), series.id.desc())
|
||||
.limit(limit.toLong())
|
||||
.fetch()
|
||||
.map { it.copy(coverImageUrl = it.coverImageUrl.toCdnUrl(cloudFrontHost)) }
|
||||
}
|
||||
|
||||
override fun findLatestAudios(
|
||||
limit: Int,
|
||||
memberId: Long?,
|
||||
canViewAdultContent: Boolean,
|
||||
now: LocalDateTime
|
||||
): List<AudioCard> {
|
||||
val rows = audioRows(memberId, canViewAdultContent, now) {
|
||||
orderBy(audioContent.releaseDate.desc(), audioContent.id.desc()).limit(limit.toLong())
|
||||
}
|
||||
return rows.toAudioCards(now, canViewAdultContent)
|
||||
}
|
||||
|
||||
override fun findFreeAudios(
|
||||
limit: Int,
|
||||
memberId: Long?,
|
||||
canViewAdultContent: Boolean,
|
||||
now: LocalDateTime
|
||||
): List<AudioCard> {
|
||||
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
|
||||
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.price.eq(0)) {
|
||||
orderBy(randomTieBreaker.asc()).limit(limit.toLong())
|
||||
}
|
||||
return rows.toAudioCards(now, canViewAdultContent)
|
||||
}
|
||||
|
||||
override fun findPointAudios(
|
||||
limit: Int,
|
||||
memberId: Long?,
|
||||
canViewAdultContent: Boolean,
|
||||
now: LocalDateTime
|
||||
): List<AudioCard> {
|
||||
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
|
||||
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.isPointAvailable.isTrue) {
|
||||
orderBy(randomTieBreaker.asc()).limit(limit.toLong())
|
||||
}
|
||||
return rows.toAudioCards(now, canViewAdultContent)
|
||||
}
|
||||
|
||||
override fun findAudioCardsByIds(
|
||||
contentIds: List<Long>,
|
||||
memberId: Long?,
|
||||
canViewAdultContent: Boolean,
|
||||
now: LocalDateTime
|
||||
): List<AudioCard> {
|
||||
if (contentIds.isEmpty()) return emptyList()
|
||||
val orderById = contentIds.withIndex().associate { it.value to it.index }
|
||||
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.id.`in`(contentIds)) { this }
|
||||
return rows.toAudioCards(now, canViewAdultContent).sortedBy { orderById[it.audioContentId] ?: Int.MAX_VALUE }
|
||||
}
|
||||
|
||||
override fun findCommentedAudiosByIds(
|
||||
contentIds: List<Long>,
|
||||
memberId: Long?,
|
||||
canViewAdultContent: Boolean
|
||||
): List<CommentedAudio> {
|
||||
if (contentIds.isEmpty()) return emptyList()
|
||||
val contentOrder = contentIds.withIndex().associate { it.value to it.index }
|
||||
val sql = """
|
||||
select c.id, c.title, c.cover_image, latest.comment, writer.profile_image
|
||||
from content c
|
||||
join member creator on creator.id = c.member_id
|
||||
join content_theme theme on theme.id = c.theme_id
|
||||
join content_comment latest on latest.content_id = c.id
|
||||
and latest.is_active = true
|
||||
and latest.parent_id is null
|
||||
and latest.is_secret = false
|
||||
join member writer on writer.id = latest.member_id and writer.is_active = true
|
||||
where c.id in (:contentIds)
|
||||
and c.is_active = true
|
||||
and c.duration is not null
|
||||
and c.release_date is not null
|
||||
and c.release_date <= CURRENT_TIMESTAMP
|
||||
and creator.is_active = true
|
||||
and theme.is_active = true
|
||||
and (:canViewAdultContent = true or c.is_adult = false)
|
||||
and (:memberId is null or not exists (
|
||||
select 1 from block_member bm
|
||||
where bm.is_active = true
|
||||
and ((bm.member_id = :memberId and bm.blocked_member_id = creator.id)
|
||||
or (bm.member_id = creator.id and bm.blocked_member_id = :memberId))
|
||||
))
|
||||
and (:memberId is null or not exists (
|
||||
select 1 from block_member bm
|
||||
where bm.is_active = true
|
||||
and ((bm.member_id = :memberId and bm.blocked_member_id = writer.id)
|
||||
or (bm.member_id = writer.id and bm.blocked_member_id = :memberId))
|
||||
))
|
||||
and not exists (
|
||||
select 1 from block_member bm
|
||||
where bm.is_active = true
|
||||
and ((bm.member_id = creator.id and bm.blocked_member_id = writer.id)
|
||||
or (bm.member_id = writer.id and bm.blocked_member_id = creator.id))
|
||||
)
|
||||
and not exists (
|
||||
select 1
|
||||
from content_comment newer
|
||||
join member newer_writer on newer_writer.id = newer.member_id and newer_writer.is_active = true
|
||||
where newer.content_id = c.id
|
||||
and newer.is_active = true
|
||||
and newer.parent_id is null
|
||||
and newer.is_secret = false
|
||||
and (
|
||||
newer.created_at > latest.created_at
|
||||
or (newer.created_at = latest.created_at and newer.id > latest.id)
|
||||
)
|
||||
and (:memberId is null or not exists (
|
||||
select 1 from block_member bm
|
||||
where bm.is_active = true
|
||||
and ((bm.member_id = :memberId and bm.blocked_member_id = newer_writer.id)
|
||||
or (bm.member_id = newer_writer.id and bm.blocked_member_id = :memberId))
|
||||
))
|
||||
and not exists (
|
||||
select 1 from block_member bm
|
||||
where bm.is_active = true
|
||||
and ((bm.member_id = creator.id and bm.blocked_member_id = newer_writer.id)
|
||||
or (bm.member_id = newer_writer.id and bm.blocked_member_id = creator.id))
|
||||
)
|
||||
)
|
||||
""".trimIndent()
|
||||
return entityManager.createNativeQuery(sql)
|
||||
.setParameter("contentIds", contentIds)
|
||||
.setParameter("memberId", memberId)
|
||||
.setParameter("canViewAdultContent", canViewAdultContent)
|
||||
.resultList
|
||||
.map { row ->
|
||||
val values = row as Array<*>
|
||||
CommentedAudio(
|
||||
audioContentId = values[0].toLongValue(),
|
||||
title = values[1] as String,
|
||||
imageUrl = (values[2] as String?).toCdnUrl(cloudFrontHost),
|
||||
latestComment = values[3] as String,
|
||||
latestCommentWriterProfileImageUrl = (values[4] as String?).toCdnUrl(cloudFrontHost)
|
||||
?: "$cloudFrontHost/profile/default-profile.png"
|
||||
)
|
||||
}
|
||||
.sortedBy { contentOrder[it.audioContentId] ?: Int.MAX_VALUE }
|
||||
}
|
||||
|
||||
override fun findNewAndHotSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
visibility: AudioRecommendationVisibility,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> {
|
||||
return findScoredSnapshots(
|
||||
windowStart = windowStart,
|
||||
snapshotAt = snapshotAt,
|
||||
visibility = visibility,
|
||||
limit = limit,
|
||||
sectionType = visibility.newAndHotSectionType(),
|
||||
scoreExpression = """
|
||||
coalesce(v.view_count, 0) * 35.0
|
||||
+ coalesce(l.like_count, 0) * 15.0
|
||||
+ coalesce(cm.comment_count, 0) * 15.0
|
||||
+ case
|
||||
when timestampdiff(day, c.release_date, :snapshotAt) <= 3 then 1.3
|
||||
when timestampdiff(day, c.release_date, :snapshotAt) <= 7 then 1.15
|
||||
when timestampdiff(day, c.release_date, :snapshotAt) <= 14 then 1.0
|
||||
else 0.8
|
||||
end * 35.0
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
override fun findMostCommentedSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
visibility: AudioRecommendationVisibility,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> {
|
||||
return findScoredSnapshots(
|
||||
windowStart = windowStart,
|
||||
snapshotAt = snapshotAt,
|
||||
visibility = visibility,
|
||||
limit = limit,
|
||||
sectionType = visibility.mostCommentedSectionType(),
|
||||
scoreExpression = """
|
||||
coalesce(cm.comment_count, 0) * 80.0
|
||||
+ case
|
||||
when timestampdiff(day, cm.latest_comment_at, :snapshotAt) <= 3 then 1.3
|
||||
when timestampdiff(day, cm.latest_comment_at, :snapshotAt) <= 7 then 1.15
|
||||
when timestampdiff(day, cm.latest_comment_at, :snapshotAt) <= 14 then 1.0
|
||||
else 0.0
|
||||
end * 20.0
|
||||
""".trimIndent(),
|
||||
requireComments = true
|
||||
)
|
||||
}
|
||||
|
||||
override fun findRecommendedAudioSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
visibility: AudioRecommendationVisibility,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> {
|
||||
return findScoredSnapshots(
|
||||
windowStart = windowStart,
|
||||
snapshotAt = snapshotAt,
|
||||
visibility = visibility,
|
||||
limit = limit,
|
||||
sectionType = visibility.recommendedAudioSectionType(),
|
||||
scoreExpression = """
|
||||
coalesce(v.view_count, 0) * 45.0
|
||||
+ coalesce(l.like_count, 0) * 25.0
|
||||
+ coalesce(cm.comment_count, 0) * 20.0
|
||||
+ case
|
||||
when timestampdiff(day, c.release_date, :snapshotAt) <= 3 then 1.3
|
||||
when timestampdiff(day, c.release_date, :snapshotAt) <= 7 then 1.15
|
||||
when timestampdiff(day, c.release_date, :snapshotAt) <= 30 then 1.1
|
||||
else 1.0
|
||||
end * 10.0
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
private fun findScoredSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
visibility: AudioRecommendationVisibility,
|
||||
limit: Int,
|
||||
sectionType: RecommendedSectionType,
|
||||
scoreExpression: String,
|
||||
requireComments: Boolean = false
|
||||
): List<RecommendationSnapshotRecord> {
|
||||
val commentJoin = if (requireComments) "join" else "left join"
|
||||
val commentRequirement = if (requireComments) "and cm.comment_count is not null" else ""
|
||||
val sql = """
|
||||
select c.id, ($scoreExpression) score, rand() random_tie_breaker
|
||||
from content c
|
||||
join member creator on creator.id = c.member_id
|
||||
join content_theme theme on theme.id = c.theme_id
|
||||
left join (
|
||||
select content_id, count(*) view_count
|
||||
from creator_content_view_history
|
||||
where viewed_at >= :windowStart and viewed_at <= :snapshotAt
|
||||
group by content_id
|
||||
) v on v.content_id = c.id
|
||||
left join (
|
||||
select content_id, count(*) like_count
|
||||
from content_like
|
||||
where is_active = true and created_at >= :windowStart and created_at <= :snapshotAt
|
||||
group by content_id
|
||||
) l on l.content_id = c.id
|
||||
$commentJoin (
|
||||
select cc.content_id, count(*) comment_count, max(cc.created_at) latest_comment_at
|
||||
from content_comment cc
|
||||
join content comment_content on comment_content.id = cc.content_id
|
||||
join member comment_writer on comment_writer.id = cc.member_id
|
||||
where cc.is_active = true
|
||||
and cc.parent_id is null
|
||||
and cc.is_secret = false
|
||||
and comment_writer.is_active = true
|
||||
and not exists (
|
||||
select 1 from block_member bm
|
||||
where bm.is_active = true
|
||||
and ((bm.member_id = comment_content.member_id and bm.blocked_member_id = comment_writer.id)
|
||||
or (bm.member_id = comment_writer.id and bm.blocked_member_id = comment_content.member_id))
|
||||
)
|
||||
and cc.created_at >= :windowStart and cc.created_at <= :snapshotAt
|
||||
group by cc.content_id
|
||||
) cm on cm.content_id = c.id
|
||||
where c.is_active = true
|
||||
and c.duration is not null
|
||||
and c.release_date is not null
|
||||
and c.release_date <= :snapshotAt
|
||||
and creator.is_active = true
|
||||
and theme.is_active = true
|
||||
and (:includeAdult = true or c.is_adult = false)
|
||||
$commentRequirement
|
||||
order by score desc, random_tie_breaker asc
|
||||
limit :limit
|
||||
""".trimIndent()
|
||||
return entityManager.createNativeQuery(sql)
|
||||
.setParameter("windowStart", windowStart)
|
||||
.setParameter("snapshotAt", snapshotAt)
|
||||
.setParameter("includeAdult", visibility == AudioRecommendationVisibility.ALL)
|
||||
.setParameter("limit", limit)
|
||||
.resultList
|
||||
.map { row ->
|
||||
val values = row as Array<*>
|
||||
RecommendationSnapshotRecord(
|
||||
sectionType = sectionType,
|
||||
targetId = values[0].toLongValue(),
|
||||
score = values[1].toDoubleValue(),
|
||||
snapshotAt = snapshotAt,
|
||||
randomTieBreaker = values[2].toDoubleValue()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun audioRows(
|
||||
memberId: Long?,
|
||||
canViewAdultContent: Boolean,
|
||||
now: LocalDateTime,
|
||||
extraCondition: BooleanExpression? = null,
|
||||
customize: com.querydsl.jpa.impl.JPAQuery<Tuple>.() -> com.querydsl.jpa.impl.JPAQuery<Tuple>
|
||||
): List<Tuple> {
|
||||
return queryFactory
|
||||
.select(
|
||||
audioContent.id,
|
||||
audioContent.title,
|
||||
audioContent.duration,
|
||||
audioContent.coverImage,
|
||||
audioContent.price,
|
||||
audioContent.isAdult,
|
||||
audioContent.isPointAvailable,
|
||||
audioContent.member.id,
|
||||
member.nickname
|
||||
)
|
||||
.from(audioContent)
|
||||
.join(audioContent.member, member)
|
||||
.join(audioContent.theme, audioContentTheme)
|
||||
.where(publicAudioCondition(memberId, canViewAdultContent, now), extraCondition)
|
||||
.customize()
|
||||
.fetch()
|
||||
}
|
||||
|
||||
private fun List<Tuple>.toAudioCards(now: LocalDateTime, canViewAdultContent: Boolean): List<AudioCard> {
|
||||
if (isEmpty()) return emptyList()
|
||||
val contentIds = map { it.get(audioContent.id)!! }
|
||||
val creatorIds = map { it.get(audioContent.member.id)!! }.distinct()
|
||||
val firstContentIdByCreatorId = firstAudioContentIds(creatorIds, now, canViewAdultContent)
|
||||
val isOriginalSeriesByContentId = originalSeriesFlags(contentIds)
|
||||
return map { row ->
|
||||
val contentId = row.get(audioContent.id)!!
|
||||
val creatorId = row.get(audioContent.member.id)!!
|
||||
AudioCard(
|
||||
audioContentId = contentId,
|
||||
title = row.get(audioContent.title)!!,
|
||||
duration = row.get(audioContent.duration),
|
||||
imageUrl = row.get(audioContent.coverImage).toCdnUrl(cloudFrontHost),
|
||||
price = row.get(audioContent.price)!!,
|
||||
isAdult = row.get(audioContent.isAdult)!!,
|
||||
isPointAvailable = row.get(audioContent.isPointAvailable)!!,
|
||||
isFirstContent = firstContentIdByCreatorId[creatorId] == contentId,
|
||||
isOriginalSeries = isOriginalSeriesByContentId[contentId] ?: false,
|
||||
creatorNickname = row.get(member.nickname)!!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun firstAudioContentIds(
|
||||
creatorIds: List<Long>,
|
||||
now: LocalDateTime,
|
||||
canViewAdultContent: Boolean
|
||||
): Map<Long, Long> {
|
||||
return creatorIds.associateWith { creatorId ->
|
||||
queryFactory
|
||||
.select(audioContent.id)
|
||||
.from(audioContent)
|
||||
.join(audioContent.member, member)
|
||||
.join(audioContent.theme, audioContentTheme)
|
||||
.where(
|
||||
audioContent.member.id.eq(creatorId),
|
||||
publicAudioCondition(memberId = null, canViewAdultContent, now)
|
||||
)
|
||||
.orderBy(audioContent.releaseDate.asc(), audioContent.id.asc())
|
||||
.fetchFirst()
|
||||
}.filterValues { it != null }.mapValues { it.value!! }
|
||||
}
|
||||
|
||||
private fun originalSeriesFlags(contentIds: List<Long>): Map<Long, Boolean> {
|
||||
if (contentIds.isEmpty()) return emptyMap()
|
||||
return queryFactory
|
||||
.select(seriesContent.content.id, series.isOriginal)
|
||||
.from(seriesContent)
|
||||
.join(seriesContent.series, series)
|
||||
.where(seriesContent.content.id.`in`(contentIds))
|
||||
.fetch()
|
||||
.associate { it.get(seriesContent.content.id)!! to it.get(series.isOriginal)!! }
|
||||
}
|
||||
|
||||
private fun publicAudioCondition(memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): BooleanExpression {
|
||||
return audioContent.isActive.isTrue
|
||||
.and(audioContent.duration.isNotNull)
|
||||
.and(audioContent.releaseDate.isNotNull)
|
||||
.and(audioContent.releaseDate.loe(now))
|
||||
.and(audioContent.member.isActive.isTrue)
|
||||
.and(audioContentTheme.isActive.isTrue)
|
||||
.withOptionalAnd(adultAudioCondition(canViewAdultContent))
|
||||
.withOptionalAnd(notBlockedCreatorCondition(memberId, audioContent.member.id))
|
||||
}
|
||||
|
||||
private fun activeBannerTargetCondition(memberId: Long?, bannerCreator: QMember, seriesOwner: QMember): BooleanExpression {
|
||||
val creatorCondition = audioContentBanner.type.eq(AudioContentBannerType.CREATOR)
|
||||
.and(bannerCreator.isActive.isTrue)
|
||||
.withOptionalAnd(notBlockedCreatorCondition(memberId, bannerCreator.id))
|
||||
val seriesCondition = audioContentBanner.type.eq(AudioContentBannerType.SERIES)
|
||||
.and(series.isActive.isTrue)
|
||||
.and(seriesOwner.isActive.isTrue)
|
||||
.withOptionalAnd(notBlockedCreatorCondition(memberId, seriesOwner.id))
|
||||
|
||||
return audioContentBanner.type.eq(AudioContentBannerType.LINK)
|
||||
.or(audioContentBanner.type.eq(AudioContentBannerType.EVENT).and(event.isActive.isTrue))
|
||||
.or(creatorCondition)
|
||||
.or(seriesCondition)
|
||||
}
|
||||
|
||||
private fun Tuple.toEventItem(): EventItem? {
|
||||
val eventId = get(event.id) ?: return null
|
||||
val thumbnailImage = get(event.thumbnailImage) ?: return null
|
||||
return EventItem(
|
||||
id = eventId,
|
||||
thumbnailImageUrl = thumbnailImage.toCdnUrl(cloudFrontHost) ?: thumbnailImage,
|
||||
detailImageUrl = get(event.detailImage).toCdnUrl(cloudFrontHost),
|
||||
popupImageUrl = null,
|
||||
link = get(event.link)
|
||||
)
|
||||
}
|
||||
|
||||
private fun adultSeriesCondition(canViewAdultContent: Boolean): BooleanExpression? {
|
||||
return if (canViewAdultContent) null else series.isAdult.isFalse
|
||||
}
|
||||
|
||||
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
|
||||
return if (canViewAdultContent) null else audioContent.isAdult.isFalse
|
||||
}
|
||||
|
||||
private fun notBlockedCreatorCondition(memberId: Long?, creatorIdPath: Expression<Long>): BooleanExpression? {
|
||||
if (memberId == null) return null
|
||||
val blockMember = QBlockMember("audioRecommendationBlockMember")
|
||||
return JPAExpressions
|
||||
.selectOne()
|
||||
.from(blockMember)
|
||||
.where(
|
||||
blockMember.isActive.isTrue,
|
||||
blockMember.member.id.eq(memberId).and(blockMember.blockedMember.id.eq(creatorIdPath))
|
||||
.or(blockMember.member.id.eq(creatorIdPath).and(blockMember.blockedMember.id.eq(memberId)))
|
||||
)
|
||||
.notExists()
|
||||
}
|
||||
|
||||
private fun BooleanExpression.withOptionalAnd(condition: BooleanExpression?): BooleanExpression {
|
||||
return if (condition == null) this else and(condition)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AudioRecommendationVisibility.newAndHotSectionType(): RecommendedSectionType {
|
||||
return when (this) {
|
||||
AudioRecommendationVisibility.SAFE -> RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE
|
||||
AudioRecommendationVisibility.ALL -> RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL
|
||||
}
|
||||
}
|
||||
|
||||
private fun AudioRecommendationVisibility.mostCommentedSectionType(): RecommendedSectionType {
|
||||
return when (this) {
|
||||
AudioRecommendationVisibility.SAFE -> RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE
|
||||
AudioRecommendationVisibility.ALL -> RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL
|
||||
}
|
||||
}
|
||||
|
||||
private fun AudioRecommendationVisibility.recommendedAudioSectionType(): RecommendedSectionType {
|
||||
return when (this) {
|
||||
AudioRecommendationVisibility.SAFE -> RecommendedSectionType.RECOMMENDED_AUDIO_SAFE
|
||||
AudioRecommendationVisibility.ALL -> RecommendedSectionType.RECOMMENDED_AUDIO_ALL
|
||||
}
|
||||
}
|
||||
|
||||
private fun Any?.toLongValue(): Long {
|
||||
return when (this) {
|
||||
is Long -> this
|
||||
is Int -> toLong()
|
||||
is BigInteger -> toLong()
|
||||
is Number -> toLong()
|
||||
else -> error("Unsupported numeric value: $this")
|
||||
}
|
||||
}
|
||||
|
||||
private fun Any?.toDoubleValue(): Double {
|
||||
return when (this) {
|
||||
is Double -> this
|
||||
is Float -> toDouble()
|
||||
is BigDecimal -> toDouble()
|
||||
is Number -> toDouble()
|
||||
else -> error("Unsupported numeric value: $this")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.scheduler
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshService
|
||||
import org.redisson.api.RedissonClient
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Component
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Component
|
||||
class AudioRecommendationSnapshotScheduler(
|
||||
private val refreshService: AudioRecommendationSnapshotRefreshService,
|
||||
private val redissonClient: RedissonClient
|
||||
) {
|
||||
@Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul")
|
||||
fun refreshDailySnapshots() {
|
||||
val lock = redissonClient.getLock(LOCK_KEY)
|
||||
|
||||
try {
|
||||
if (lock.tryLock(0, -1, TimeUnit.SECONDS)) {
|
||||
refreshService.refreshDailySnapshots()
|
||||
}
|
||||
} finally {
|
||||
if (lock.isHeldByCurrentThread) {
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val LOCK_KEY = "lock:audio-recommendation-snapshot-refresh"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package kr.co.vividnext.sodalive.v2.content.recommendation.application
|
||||
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
|
||||
import org.redisson.api.RedissonClient
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
|
||||
@Service
|
||||
class AudioRecommendationQueryService(
|
||||
private val queryPort: AudioRecommendationQueryPort,
|
||||
private val memberContentPreferenceService: MemberContentPreferenceService,
|
||||
private val snapshotPort: RecommendationSnapshotPort,
|
||||
private val snapshotRefreshService: AudioRecommendationSnapshotRefreshService,
|
||||
private val redissonClient: RedissonClient
|
||||
) {
|
||||
fun getRecommendations(member: Member?): AudioRecommendations {
|
||||
val now = LocalDateTime.now()
|
||||
val canViewAdultContent = canViewAdultContent(member)
|
||||
val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE
|
||||
val memberId = member?.id
|
||||
val newAndHotSectionType = newAndHotSectionType(visibility)
|
||||
val newAndHotSnapshots = snapshotPort.findLatestSnapshots(newAndHotSectionType, limit = NEW_AND_HOT_AUDIO_LIMIT)
|
||||
val mostCommentedSnapshots = snapshotPort.findLatestSnapshots(
|
||||
mostCommentedSectionType(visibility),
|
||||
limit = MOST_COMMENTED_AUDIO_LIMIT
|
||||
)
|
||||
val recommendedSnapshots = snapshotPort.findLatestSnapshots(
|
||||
recommendedAudioSectionType(visibility),
|
||||
limit = RECOMMENDED_AUDIO_LIMIT
|
||||
)
|
||||
val refreshedNewAndHotSnapshots = refreshMissingNewAndHotSnapshots(newAndHotSectionType, newAndHotSnapshots)
|
||||
|
||||
return AudioRecommendations(
|
||||
banners = queryPort.findBanners(BANNER_LIMIT, memberId, canViewAdultContent),
|
||||
originalSeries = queryPort.findOriginalSeries(ORIGINAL_SERIES_LIMIT, memberId, canViewAdultContent, now),
|
||||
latestAudios = queryPort.findLatestAudios(LATEST_AUDIO_LIMIT, memberId, canViewAdultContent, now),
|
||||
newAndHotAudios = queryPort.findAudioCardsByIds(
|
||||
refreshedNewAndHotSnapshots.map { it.targetId },
|
||||
memberId,
|
||||
canViewAdultContent,
|
||||
now
|
||||
),
|
||||
freeAudios = queryPort.findFreeAudios(FREE_AUDIO_LIMIT, memberId, canViewAdultContent, now),
|
||||
pointAudios = queryPort.findPointAudios(POINT_AUDIO_LIMIT, memberId, canViewAdultContent, now),
|
||||
mostCommentedAudios = queryPort.findCommentedAudiosByIds(
|
||||
mostCommentedSnapshots.map { it.targetId },
|
||||
memberId,
|
||||
canViewAdultContent
|
||||
),
|
||||
recommendedAudios = queryPort.findAudioCardsByIds(
|
||||
recommendedSnapshots.map { it.targetId },
|
||||
memberId,
|
||||
canViewAdultContent,
|
||||
now
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun resolveVisibility(member: Member?): AudioRecommendationVisibility {
|
||||
return if (canViewAdultContent(member)) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE
|
||||
}
|
||||
|
||||
fun newAndHotSectionType(visibility: AudioRecommendationVisibility): RecommendedSectionType {
|
||||
return when (visibility) {
|
||||
AudioRecommendationVisibility.SAFE -> RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE
|
||||
AudioRecommendationVisibility.ALL -> RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL
|
||||
}
|
||||
}
|
||||
|
||||
fun mostCommentedSectionType(visibility: AudioRecommendationVisibility): RecommendedSectionType {
|
||||
return when (visibility) {
|
||||
AudioRecommendationVisibility.SAFE -> RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE
|
||||
AudioRecommendationVisibility.ALL -> RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL
|
||||
}
|
||||
}
|
||||
|
||||
fun recommendedAudioSectionType(visibility: AudioRecommendationVisibility): RecommendedSectionType {
|
||||
return when (visibility) {
|
||||
AudioRecommendationVisibility.SAFE -> RecommendedSectionType.RECOMMENDED_AUDIO_SAFE
|
||||
AudioRecommendationVisibility.ALL -> RecommendedSectionType.RECOMMENDED_AUDIO_ALL
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshMissingNewAndHotSnapshots(
|
||||
sectionType: RecommendedSectionType,
|
||||
snapshots: List<RecommendationSnapshotRecord>
|
||||
): List<RecommendationSnapshotRecord> {
|
||||
if (snapshots.isNotEmpty()) return snapshots
|
||||
val today = LocalDate.now(KST_ZONE)
|
||||
val marker = redissonClient.getBucket<String>(newAndHotLazyRefreshMarkerKey(today))
|
||||
if (!marker.setIfAbsent(LAZY_REFRESH_ATTEMPTED_VALUE, LAZY_REFRESH_MARKER_TTL)) {
|
||||
return snapshots
|
||||
}
|
||||
runCatching {
|
||||
snapshotRefreshService.refreshDailySnapshots()
|
||||
}.onFailure { ex ->
|
||||
marker.delete()
|
||||
throw ex
|
||||
}
|
||||
return snapshotPort.findLatestSnapshots(sectionType, limit = NEW_AND_HOT_AUDIO_LIMIT)
|
||||
}
|
||||
|
||||
private fun newAndHotLazyRefreshMarkerKey(date: LocalDate): String {
|
||||
return "$LAZY_REFRESH_MARKER_KEY_PREFIX:$date"
|
||||
}
|
||||
|
||||
private fun canViewAdultContent(member: Member?): Boolean {
|
||||
if (member == null) return false
|
||||
return memberContentPreferenceService.canViewAdultContent(member)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BANNER_LIMIT = 20
|
||||
const val ORIGINAL_SERIES_LIMIT = 12
|
||||
const val LATEST_AUDIO_LIMIT = 12
|
||||
const val FREE_AUDIO_LIMIT = 10
|
||||
const val POINT_AUDIO_LIMIT = 10
|
||||
const val NEW_AND_HOT_AUDIO_LIMIT = 12
|
||||
const val MOST_COMMENTED_AUDIO_LIMIT = 5
|
||||
const val RECOMMENDED_AUDIO_LIMIT = 10
|
||||
private const val LAZY_REFRESH_MARKER_KEY_PREFIX = "audio-recommendation:new-and-hot:lazy-refresh-attempted"
|
||||
private const val LAZY_REFRESH_ATTEMPTED_VALUE = "1"
|
||||
private val LAZY_REFRESH_MARKER_TTL: Duration = Duration.ofDays(2)
|
||||
private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package kr.co.vividnext.sodalive.v2.content.recommendation.application
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
@Service
|
||||
class AudioRecommendationSnapshotRefreshService(
|
||||
private val snapshotPort: RecommendationSnapshotPort,
|
||||
private val queryPort: AudioRecommendationQueryPort
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
fun refreshDailySnapshots() {
|
||||
refreshDailySnapshots(ZonedDateTime.now(KST_ZONE))
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
fun refreshDailySnapshots(now: LocalDateTime) {
|
||||
refreshDailySnapshots(now.atZone(KST_ZONE))
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
fun refreshDailySnapshots(now: ZonedDateTime) {
|
||||
val startedAt = System.currentTimeMillis()
|
||||
val snapshotAt = snapshotAt(now)
|
||||
val newAndHotWindowStart = windowStart(snapshotAt, days = 3)
|
||||
val mostCommentedWindowStart = windowStart(snapshotAt, days = 7)
|
||||
val recommendedWindowStart = mostCommentedWindowStart
|
||||
|
||||
runCatching {
|
||||
replaceNewAndHotSnapshots(newAndHotWindowStart, snapshotAt, AudioRecommendationVisibility.SAFE)
|
||||
replaceNewAndHotSnapshots(newAndHotWindowStart, snapshotAt, AudioRecommendationVisibility.ALL)
|
||||
replaceMostCommentedSnapshots(mostCommentedWindowStart, snapshotAt, AudioRecommendationVisibility.SAFE)
|
||||
replaceMostCommentedSnapshots(mostCommentedWindowStart, snapshotAt, AudioRecommendationVisibility.ALL)
|
||||
replaceRecommendedAudioSnapshots(recommendedWindowStart, snapshotAt, AudioRecommendationVisibility.SAFE)
|
||||
replaceRecommendedAudioSnapshots(recommendedWindowStart, snapshotAt, AudioRecommendationVisibility.ALL)
|
||||
}.onSuccess {
|
||||
log.info(
|
||||
"event=audio_recommendation_snapshot_refresh_success snapshotAt={} elapsedMs={}",
|
||||
snapshotAt,
|
||||
System.currentTimeMillis() - startedAt
|
||||
)
|
||||
}.onFailure { ex ->
|
||||
log.warn(
|
||||
"event=audio_recommendation_snapshot_refresh_failure snapshotAt={} elapsedMs={} error={}",
|
||||
snapshotAt,
|
||||
System.currentTimeMillis() - startedAt,
|
||||
ex.message,
|
||||
ex
|
||||
)
|
||||
throw ex
|
||||
}
|
||||
}
|
||||
|
||||
private fun replaceNewAndHotSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
visibility: AudioRecommendationVisibility
|
||||
) {
|
||||
val sectionType = visibility.newAndHotSectionType()
|
||||
val snapshots = queryPort.findNewAndHotSnapshots(windowStart, snapshotAt, visibility, NEW_AND_HOT_LIMIT)
|
||||
snapshotPort.replaceSnapshots(sectionType, snapshotAt, snapshots)
|
||||
}
|
||||
|
||||
private fun replaceMostCommentedSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
visibility: AudioRecommendationVisibility
|
||||
) {
|
||||
val sectionType = visibility.mostCommentedSectionType()
|
||||
val snapshots = queryPort.findMostCommentedSnapshots(windowStart, snapshotAt, visibility, MOST_COMMENTED_LIMIT)
|
||||
snapshotPort.replaceSnapshots(sectionType, snapshotAt, snapshots)
|
||||
}
|
||||
|
||||
private fun replaceRecommendedAudioSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
visibility: AudioRecommendationVisibility
|
||||
) {
|
||||
val sectionType = visibility.recommendedAudioSectionType()
|
||||
val snapshots = queryPort.findRecommendedAudioSnapshots(windowStart, snapshotAt, visibility, RECOMMENDED_AUDIO_LIMIT)
|
||||
snapshotPort.replaceSnapshots(sectionType, snapshotAt, snapshots)
|
||||
}
|
||||
|
||||
private fun snapshotAt(now: ZonedDateTime): LocalDateTime {
|
||||
val nowKst = now
|
||||
.withZoneSameInstant(KST_ZONE)
|
||||
return nowKst.toLocalDate()
|
||||
.minusDays(1)
|
||||
.atTime(23, 59, 59)
|
||||
}
|
||||
|
||||
private fun windowStart(snapshotAt: LocalDateTime, days: Long): LocalDateTime {
|
||||
return snapshotAt.toLocalDate()
|
||||
.minusDays(days - 1)
|
||||
.atStartOfDay()
|
||||
}
|
||||
|
||||
private fun AudioRecommendationVisibility.newAndHotSectionType(): RecommendedSectionType {
|
||||
return when (this) {
|
||||
AudioRecommendationVisibility.SAFE -> RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE
|
||||
AudioRecommendationVisibility.ALL -> RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL
|
||||
}
|
||||
}
|
||||
|
||||
private fun AudioRecommendationVisibility.mostCommentedSectionType(): RecommendedSectionType {
|
||||
return when (this) {
|
||||
AudioRecommendationVisibility.SAFE -> RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE
|
||||
AudioRecommendationVisibility.ALL -> RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL
|
||||
}
|
||||
}
|
||||
|
||||
private fun AudioRecommendationVisibility.recommendedAudioSectionType(): RecommendedSectionType {
|
||||
return when (this) {
|
||||
AudioRecommendationVisibility.SAFE -> RecommendedSectionType.RECOMMENDED_AUDIO_SAFE
|
||||
AudioRecommendationVisibility.ALL -> RecommendedSectionType.RECOMMENDED_AUDIO_ALL
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NEW_AND_HOT_LIMIT = 12
|
||||
const val MOST_COMMENTED_LIMIT = 5
|
||||
const val RECOMMENDED_AUDIO_LIMIT = 10
|
||||
private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package kr.co.vividnext.sodalive.v2.content.recommendation.domain
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
|
||||
|
||||
data class AudioRecommendations(
|
||||
val banners: List<RecommendationBanner>,
|
||||
val originalSeries: List<OriginalSeries>,
|
||||
val latestAudios: List<AudioCard>,
|
||||
val newAndHotAudios: List<AudioCard>,
|
||||
val freeAudios: List<AudioCard>,
|
||||
val pointAudios: List<AudioCard>,
|
||||
val mostCommentedAudios: List<CommentedAudio>,
|
||||
val recommendedAudios: List<AudioCard>
|
||||
)
|
||||
|
||||
data class OriginalSeries(
|
||||
val seriesId: Long,
|
||||
val coverImageUrl: String?
|
||||
)
|
||||
|
||||
data class AudioCard(
|
||||
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 isOriginalSeries: Boolean,
|
||||
val creatorNickname: String
|
||||
)
|
||||
|
||||
data class CommentedAudio(
|
||||
val audioContentId: Long,
|
||||
val title: String,
|
||||
val imageUrl: String?,
|
||||
val latestComment: String,
|
||||
val latestCommentWriterProfileImageUrl: String
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
package kr.co.vividnext.sodalive.v2.content.recommendation.domain
|
||||
|
||||
import java.time.LocalDateTime
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
class AudioRecommendationScorePolicy {
|
||||
fun calculateNewAndHotScore(
|
||||
viewCount: Long,
|
||||
likeCount: Long,
|
||||
commentCount: Long,
|
||||
releaseDate: LocalDateTime,
|
||||
now: LocalDateTime
|
||||
): Double {
|
||||
return viewCount * NEW_AND_HOT_VIEW_WEIGHT +
|
||||
likeCount * NEW_AND_HOT_LIKE_WEIGHT +
|
||||
commentCount * NEW_AND_HOT_COMMENT_WEIGHT +
|
||||
newAndHotRecencyMultiplier(releaseDate, now) * NEW_AND_HOT_RECENCY_WEIGHT
|
||||
}
|
||||
|
||||
fun calculateRecommendedAudioScore(
|
||||
viewCount: Long,
|
||||
likeCount: Long,
|
||||
commentCount: Long,
|
||||
releaseDate: LocalDateTime,
|
||||
now: LocalDateTime
|
||||
): Double {
|
||||
return viewCount * RECOMMENDED_VIEW_WEIGHT +
|
||||
likeCount * RECOMMENDED_LIKE_WEIGHT +
|
||||
commentCount * RECOMMENDED_COMMENT_WEIGHT +
|
||||
recommendedAudioRecencyMultiplier(releaseDate, now) * RECOMMENDED_RECENCY_WEIGHT
|
||||
}
|
||||
|
||||
fun calculateCommentScore(commentCount: Long, latestCommentAt: LocalDateTime, now: LocalDateTime): Double {
|
||||
return commentCount * COMMENT_COUNT_WEIGHT + commentRecencyMultiplier(latestCommentAt, now) * COMMENT_RECENCY_WEIGHT
|
||||
}
|
||||
|
||||
fun newAndHotRecencyMultiplier(releaseDate: LocalDateTime, now: LocalDateTime): Double {
|
||||
val days = daysBetween(releaseDate, now)
|
||||
return when {
|
||||
days <= 3 -> 1.3
|
||||
days <= 7 -> 1.15
|
||||
days <= 14 -> 1.0
|
||||
else -> 0.8
|
||||
}
|
||||
}
|
||||
|
||||
fun recommendedAudioRecencyMultiplier(releaseDate: LocalDateTime, now: LocalDateTime): Double {
|
||||
val days = daysBetween(releaseDate, now)
|
||||
return when {
|
||||
days <= 3 -> 1.3
|
||||
days <= 7 -> 1.15
|
||||
days <= 30 -> 1.1
|
||||
else -> 1.0
|
||||
}
|
||||
}
|
||||
|
||||
fun commentRecencyMultiplier(latestCommentAt: LocalDateTime, now: LocalDateTime): Double {
|
||||
val days = daysBetween(latestCommentAt, now)
|
||||
return when {
|
||||
days <= 3 -> 1.3
|
||||
days <= 7 -> 1.15
|
||||
days <= 14 -> 1.0
|
||||
else -> 0.0
|
||||
}
|
||||
}
|
||||
|
||||
private fun daysBetween(from: LocalDateTime, now: LocalDateTime): Long {
|
||||
return ChronoUnit.DAYS.between(from, now).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NEW_AND_HOT_RECENCY_WEIGHT = 35.0
|
||||
const val NEW_AND_HOT_VIEW_WEIGHT = 35.0
|
||||
const val NEW_AND_HOT_LIKE_WEIGHT = 15.0
|
||||
const val NEW_AND_HOT_COMMENT_WEIGHT = 15.0
|
||||
const val RECOMMENDED_VIEW_WEIGHT = 45.0
|
||||
const val RECOMMENDED_LIKE_WEIGHT = 25.0
|
||||
const val RECOMMENDED_COMMENT_WEIGHT = 20.0
|
||||
const val RECOMMENDED_RECENCY_WEIGHT = 10.0
|
||||
const val COMMENT_COUNT_WEIGHT = 80.0
|
||||
const val COMMENT_RECENCY_WEIGHT = 20.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.v2.content.recommendation.domain
|
||||
|
||||
enum class AudioRecommendationVisibility {
|
||||
SAFE,
|
||||
ALL
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package kr.co.vividnext.sodalive.v2.content.recommendation.port.out
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface AudioRecommendationQueryPort {
|
||||
fun findBanners(limit: Int, memberId: Long?, canViewAdultContent: Boolean): List<RecommendationBanner>
|
||||
fun findOriginalSeries(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<OriginalSeries>
|
||||
fun findLatestAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
|
||||
fun findFreeAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
|
||||
fun findPointAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
|
||||
fun findAudioCardsByIds(
|
||||
contentIds: List<Long>,
|
||||
memberId: Long?,
|
||||
canViewAdultContent: Boolean,
|
||||
now: LocalDateTime
|
||||
): List<AudioCard>
|
||||
fun findCommentedAudiosByIds(contentIds: List<Long>, memberId: Long?, canViewAdultContent: Boolean): List<CommentedAudio>
|
||||
fun findNewAndHotSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
visibility: AudioRecommendationVisibility,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord>
|
||||
fun findMostCommentedSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
visibility: AudioRecommendationVisibility,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord>
|
||||
fun findRecommendedAudioSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
visibility: AudioRecommendationVisibility,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord>
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicy
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTab
|
||||
@@ -58,8 +57,7 @@ class CreatorChannelAudioQueryService(
|
||||
|
||||
validateCreatorRole(creator)
|
||||
|
||||
val preference = memberContentPreferenceService.getStoredPreference(viewer)
|
||||
val canViewAdultContent = isAdultVisibleByPolicy(viewer, preference.isAdultContentVisible)
|
||||
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer)
|
||||
val resolvedThemeId = themeId?.let(queryPort::findActiveThemeId)
|
||||
val locale = langContext.lang.code
|
||||
val fetchedContents = queryPort.findAudioContents(
|
||||
|
||||
@@ -7,7 +7,6 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicy
|
||||
@@ -55,8 +54,7 @@ class CreatorChannelCommunityQueryService(
|
||||
|
||||
validateCreatorRole(creator)
|
||||
|
||||
val preference = memberContentPreferenceService.getStoredPreference(viewer)
|
||||
val canViewAdultContent = isAdultVisibleByPolicy(viewer, preference.isAdultContentVisible)
|
||||
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer)
|
||||
val fetchedPosts = queryPort.findCommunityPosts(
|
||||
creatorId = creatorId,
|
||||
viewerId = viewerId,
|
||||
|
||||
@@ -7,7 +7,6 @@ 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.toCdnUrl
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryService
|
||||
@@ -69,7 +68,7 @@ class CreatorChannelHomeQueryService(
|
||||
validateCreatorRole(creator)
|
||||
|
||||
val preference = memberContentPreferenceService.getStoredPreference(viewer)
|
||||
val canViewAdultContent = isAdultVisibleByPolicy(viewer, preference.isAdultContentVisible)
|
||||
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer)
|
||||
val isViewerCreator = viewerId == creatorId
|
||||
val effectiveViewerGender = viewer.effectiveGender()
|
||||
val latestAudioContent = queryPort
|
||||
|
||||
@@ -7,7 +7,6 @@ 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.common.domain.toCdnUrl
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
|
||||
@@ -58,8 +57,7 @@ class CreatorChannelLiveQueryService(
|
||||
|
||||
validateCreatorRole(creator)
|
||||
|
||||
val preference = memberContentPreferenceService.getStoredPreference(viewer)
|
||||
val canViewAdultContent = isAdultVisibleByPolicy(viewer, preference.isAdultContentVisible)
|
||||
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer)
|
||||
val isViewerCreator = viewerId == creatorId
|
||||
val effectiveViewerGender = viewer.effectiveGender()
|
||||
val fetchedContents = queryPort.findLiveReplayAudioContents(
|
||||
|
||||
@@ -7,7 +7,6 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeries
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicy
|
||||
@@ -56,8 +55,7 @@ class CreatorChannelSeriesQueryService(
|
||||
|
||||
validateCreatorRole(creator)
|
||||
|
||||
val preference = memberContentPreferenceService.getStoredPreference(viewer)
|
||||
val canViewAdultContent = isAdultVisibleByPolicy(viewer, preference.isAdultContentVisible)
|
||||
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer)
|
||||
val locale = langContext.lang.code
|
||||
val fetchedSeries = queryPort.findSeries(
|
||||
creatorId = creatorId,
|
||||
|
||||
@@ -9,5 +9,11 @@ enum class RecommendedSectionType(val code: String) {
|
||||
AI_CHARACTER("AI_CHARACTER"),
|
||||
GENRE_CREATOR("GENRE_CREATOR"),
|
||||
CHEER_CREATOR("CHEER_CREATOR"),
|
||||
POPULAR_COMMUNITY("POPULAR_COMMUNITY")
|
||||
POPULAR_COMMUNITY("POPULAR_COMMUNITY"),
|
||||
NEW_AND_HOT_AUDIO_SAFE("NEW_AND_HOT_AUDIO_SAFE"),
|
||||
NEW_AND_HOT_AUDIO_ALL("NEW_AND_HOT_AUDIO_ALL"),
|
||||
MOST_COMMENTED_AUDIO_SAFE("MOST_COMMENTED_AUDIO_SAFE"),
|
||||
MOST_COMMENTED_AUDIO_ALL("MOST_COMMENTED_AUDIO_ALL"),
|
||||
RECOMMENDED_AUDIO_SAFE("RECOMMENDED_AUDIO_SAFE"),
|
||||
RECOMMENDED_AUDIO_ALL("RECOMMENDED_AUDIO_ALL")
|
||||
}
|
||||
|
||||
@@ -449,6 +449,23 @@ class MemberContentPreferenceServiceTest {
|
||||
assertTrue(service.calculateIsAdultForQuery(noAuthMember, "US", true))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("성인 콘텐츠 조회 가능 여부는 저장 preference의 조회용 성인 정책 결과를 반환한다")
|
||||
fun shouldReturnStoredPreferenceAdultPolicyForCanViewAdultContent() {
|
||||
val member = createMember(id = 2200L)
|
||||
val preference = MemberContentPreference(
|
||||
isAdultContentVisible = true,
|
||||
contentType = ContentType.ALL,
|
||||
adultContentVisibilityChangedAt = LocalDateTime.now().minusDays(1),
|
||||
contentTypeChangedAt = LocalDateTime.now().minusDays(1)
|
||||
)
|
||||
preference.member = member
|
||||
countryContext.setCountryCode("KR")
|
||||
Mockito.`when`(repository.findByMemberId(2200L)).thenReturn(preference)
|
||||
|
||||
assertFalse(service.canViewAdultContent(member))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("직접 설정 API 입력이 모두 누락되면 예외를 발생시킨다")
|
||||
fun shouldThrowWhenAllPreferenceFieldsAreMissing() {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.`in`.web
|
||||
|
||||
import kr.co.vividnext.sodalive.common.CountryContext
|
||||
import kr.co.vividnext.sodalive.configs.SecurityConfig
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
|
||||
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
|
||||
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacade
|
||||
import kr.co.vividnext.sodalive.v2.api.content.recommendation.dto.AudioRecommendationsResponse
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
|
||||
@WebMvcTest(AudioRecommendationController::class)
|
||||
@Import(SecurityConfig::class)
|
||||
class AudioRecommendationControllerTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var facade: AudioRecommendationFacade
|
||||
|
||||
@MockBean
|
||||
private lateinit var countryContext: CountryContext
|
||||
|
||||
@MockBean
|
||||
private lateinit var langContext: LangContext
|
||||
|
||||
@MockBean
|
||||
private lateinit var sodaMessageSource: SodaMessageSource
|
||||
|
||||
@MockBean
|
||||
private lateinit var tokenProvider: TokenProvider
|
||||
|
||||
@MockBean
|
||||
private lateinit var accessDeniedHandler: JwtAccessDeniedHandler
|
||||
|
||||
@MockBean
|
||||
private lateinit var authenticationEntryPoint: JwtAuthenticationEntryPoint
|
||||
|
||||
@Test
|
||||
@DisplayName("오디오 추천 조회는 비회원에게 200 OK와 ApiResponse.ok wrapper를 반환한다")
|
||||
fun shouldReturnRecommendationsForAnonymous() {
|
||||
Mockito.doReturn(emptyResponse()).`when`(facade).getRecommendations(null)
|
||||
|
||||
mockMvc.perform(get("/api/v2/audio/recommendations"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.banners").isArray)
|
||||
.andExpect(jsonPath("$.data.originalSeries").isArray)
|
||||
.andExpect(jsonPath("$.data.latestAudios").isArray)
|
||||
.andExpect(jsonPath("$.data.recommendedAudios").isArray)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("오디오 추천 조회는 인증 회원을 nullable member로 facade에 전달한다")
|
||||
fun shouldPassAuthenticatedMemberToFacade() {
|
||||
val member = Member(
|
||||
email = "viewer@test.com",
|
||||
password = "password",
|
||||
nickname = "viewer",
|
||||
role = MemberRole.USER
|
||||
).apply { id = 10L }
|
||||
Mockito.doReturn(emptyResponse()).`when`(facade).getRecommendations(eqValue(member))
|
||||
|
||||
mockMvc.perform(get("/api/v2/audio/recommendations").with(user(MemberAdapter(member))))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
|
||||
Mockito.verify(facade).getRecommendations(eqValue(member))
|
||||
}
|
||||
|
||||
private fun emptyResponse(): AudioRecommendationsResponse {
|
||||
return AudioRecommendationsResponse(
|
||||
banners = emptyList(),
|
||||
originalSeries = emptyList(),
|
||||
latestAudios = emptyList(),
|
||||
newAndHotAudios = emptyList(),
|
||||
freeAudios = emptyList(),
|
||||
pointAudios = emptyList(),
|
||||
mostCommentedAudios = emptyList(),
|
||||
recommendedAudios = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
private fun <T> eqValue(value: T): T {
|
||||
return Mockito.eq(value) ?: value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.`in`.web
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
|
||||
import kr.co.vividnext.sodalive.content.AudioContent
|
||||
import kr.co.vividnext.sodalive.content.comment.AudioContentComment
|
||||
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.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshot
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.test.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:audio-recommendation-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
|
||||
]
|
||||
)
|
||||
@AutoConfigureMockMvc
|
||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||
class AudioRecommendationEndToEndTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc,
|
||||
private val entityManager: EntityManager,
|
||||
private val transactionTemplate: TransactionTemplate
|
||||
) {
|
||||
@Test
|
||||
@DisplayName("오디오 추천 API는 controller-service-repository를 거쳐 추천 섹션 응답을 반환한다")
|
||||
fun shouldReturnRecommendationsThroughControllerServiceAndRepository() {
|
||||
val fixture = createFixture()
|
||||
|
||||
mockMvc.perform(get("/api/v2/audio/recommendations"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.originalSeries").isArray)
|
||||
.andExpect(jsonPath("$.data.originalSeries[0].seriesId").value(fixture.seriesId))
|
||||
.andExpect(jsonPath("$.data.latestAudios").isArray)
|
||||
.andExpect(jsonPath("$.data.latestAudios[0].audioContentId").value(fixture.audioContentId))
|
||||
.andExpect(jsonPath("$.data.latestAudios[0].isOriginalSeries").value(true))
|
||||
.andExpect(jsonPath("$.data.recommendedAudios").isArray)
|
||||
.andExpect(jsonPath("$.data.recommendedAudios[0].audioContentId").value(fixture.audioContentId))
|
||||
.andExpect(jsonPath("$.data.mostCommentedAudios[0].latestComment").value("latest e2e comment"))
|
||||
.andExpect(
|
||||
jsonPath("$.data.mostCommentedAudios[0].latestCommentWriterProfileImageUrl")
|
||||
.value("https://cdn.test/comment-writer.png")
|
||||
)
|
||||
}
|
||||
|
||||
private fun createFixture(): Fixture {
|
||||
return transactionTemplate.execute {
|
||||
val now = LocalDateTime.now().minusHours(1)
|
||||
val creator = saveMember("audio-recommendation-e2e-creator", MemberRole.CREATOR)
|
||||
val writer = saveMember("audio-recommendation-e2e-writer", MemberRole.USER, profileImage = "comment-writer.png")
|
||||
val theme = saveTheme()
|
||||
val audio = saveAudio(creator, theme, now)
|
||||
val series = saveSeries(creator)
|
||||
saveSeriesContent(series, audio)
|
||||
saveComment(audio, writer, "latest e2e comment", now.plusMinutes(10))
|
||||
saveSnapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, audio.id!!, now)
|
||||
saveSnapshot(RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, audio.id!!, now)
|
||||
saveSnapshot(RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, audio.id!!, now)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
Fixture(
|
||||
seriesId = series.id!!,
|
||||
audioContentId = audio.id!!
|
||||
)
|
||||
}!!
|
||||
}
|
||||
|
||||
private fun saveMember(nickname: String, role: MemberRole, profileImage: String? = "$nickname.png"): Member {
|
||||
val member = Member(
|
||||
email = "$nickname@test.com",
|
||||
password = "password",
|
||||
nickname = nickname,
|
||||
profileImage = profileImage,
|
||||
role = role
|
||||
)
|
||||
entityManager.persist(member)
|
||||
return member
|
||||
}
|
||||
|
||||
private fun saveTheme(): AudioContentTheme {
|
||||
val theme = AudioContentTheme(theme = "recommendation-e2e-theme", image = "theme.png", isActive = true)
|
||||
entityManager.persist(theme)
|
||||
return theme
|
||||
}
|
||||
|
||||
private fun saveAudio(creator: Member, theme: AudioContentTheme, releaseDate: LocalDateTime): AudioContent {
|
||||
val audio = AudioContent(
|
||||
title = "audio-recommendation-e2e",
|
||||
detail = "detail",
|
||||
languageCode = "ko",
|
||||
releaseDate = releaseDate,
|
||||
isAdult = false,
|
||||
price = 0,
|
||||
isPointAvailable = true
|
||||
)
|
||||
audio.member = creator
|
||||
audio.theme = theme
|
||||
audio.isActive = true
|
||||
audio.coverImage = "audio-recommendation-e2e.png"
|
||||
audio.duration = "00:10"
|
||||
entityManager.persist(audio)
|
||||
return audio
|
||||
}
|
||||
|
||||
private fun saveSeries(creator: Member): Series {
|
||||
val genre = SeriesGenre("recommendation-e2e-genre")
|
||||
entityManager.persist(genre)
|
||||
val series = Series(
|
||||
title = "recommendation-e2e-series",
|
||||
introduction = "intro",
|
||||
isOriginal = true,
|
||||
isAdult = false,
|
||||
isActive = true
|
||||
)
|
||||
series.member = creator
|
||||
series.genre = genre
|
||||
series.coverImage = "series.png"
|
||||
entityManager.persist(series)
|
||||
return series
|
||||
}
|
||||
|
||||
private fun saveSeriesContent(series: Series, audio: AudioContent) {
|
||||
val seriesContent = SeriesContent()
|
||||
seriesContent.series = series
|
||||
seriesContent.content = audio
|
||||
entityManager.persist(seriesContent)
|
||||
}
|
||||
|
||||
private fun saveComment(
|
||||
audio: AudioContent,
|
||||
writer: Member,
|
||||
commentBody: String,
|
||||
createdAt: LocalDateTime
|
||||
): AudioContentComment {
|
||||
val comment = AudioContentComment(comment = commentBody, languageCode = "ko", isActive = true)
|
||||
comment.audioContent = audio
|
||||
comment.member = writer
|
||||
comment.createdAt = createdAt
|
||||
comment.updatedAt = createdAt
|
||||
entityManager.persist(comment)
|
||||
return comment
|
||||
}
|
||||
|
||||
private fun saveSnapshot(sectionType: RecommendedSectionType, targetId: Long, snapshotAt: LocalDateTime) {
|
||||
entityManager.persist(
|
||||
RecommendationSnapshot(
|
||||
sectionType = sectionType,
|
||||
targetId = targetId,
|
||||
score = 1.0,
|
||||
snapshotAt = snapshotAt,
|
||||
randomTieBreaker = 0.0
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private data class Fixture(
|
||||
val seriesId: Long,
|
||||
val audioContentId: Long
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.content.recommendation.application
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryService
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
|
||||
class AudioRecommendationFacadeTest {
|
||||
private val objectMapper = jacksonObjectMapper()
|
||||
private val queryService = Mockito.mock(AudioRecommendationQueryService::class.java)
|
||||
private val facade = AudioRecommendationFacade(queryService)
|
||||
|
||||
@Test
|
||||
@DisplayName("facade는 도메인 추천 결과를 모든 공개 응답 필드로 변환한다")
|
||||
fun shouldConvertDomainRecommendationsToResponse() {
|
||||
Mockito.doReturn(domain()).`when`(queryService).getRecommendations(null)
|
||||
|
||||
val response = facade.getRecommendations(null)
|
||||
|
||||
assertEquals(1, response.banners.size)
|
||||
assertEquals(1, response.originalSeries.size)
|
||||
assertEquals(1, response.latestAudios.size)
|
||||
assertEquals(1, response.newAndHotAudios.size)
|
||||
assertEquals(1, response.freeAudios.size)
|
||||
assertEquals(1, response.pointAudios.size)
|
||||
assertEquals(1, response.mostCommentedAudios.size)
|
||||
assertEquals(1, response.recommendedAudios.size)
|
||||
assertEquals(false, response.latestAudios[0].isOriginalSeries)
|
||||
|
||||
val json = objectMapper.readTree(objectMapper.writeValueAsString(response))
|
||||
assertEquals(false, json["latestAudios"][0]["isAdult"].asBoolean())
|
||||
assertEquals(true, json["latestAudios"][0]["isPointAvailable"].asBoolean())
|
||||
assertEquals(true, json["latestAudios"][0]["isFirstContent"].asBoolean())
|
||||
assertEquals(false, json["latestAudios"][0]["isOriginalSeries"].asBoolean())
|
||||
assertEquals(false, json["latestAudios"][0].has("adult"))
|
||||
assertEquals(false, json["latestAudios"][0].has("pointAvailable"))
|
||||
assertEquals("latest comment", json["mostCommentedAudios"][0]["latestComment"].asText())
|
||||
}
|
||||
|
||||
private fun domain(): AudioRecommendations {
|
||||
val card = AudioCard(
|
||||
audioContentId = 1L,
|
||||
title = "audio",
|
||||
duration = "00:01",
|
||||
imageUrl = "https://cdn.test/audio.png",
|
||||
price = 0,
|
||||
isAdult = false,
|
||||
isPointAvailable = true,
|
||||
isFirstContent = true,
|
||||
isOriginalSeries = false,
|
||||
creatorNickname = "creator"
|
||||
)
|
||||
return AudioRecommendations(
|
||||
banners = listOf(RecommendationBanner("https://cdn.test/banner.png", null, null, null, "https://link.test")),
|
||||
originalSeries = listOf(OriginalSeries(2L, "https://cdn.test/series.png")),
|
||||
latestAudios = listOf(card),
|
||||
newAndHotAudios = listOf(card.copy(audioContentId = 3L)),
|
||||
freeAudios = listOf(card.copy(audioContentId = 4L)),
|
||||
pointAudios = listOf(card.copy(audioContentId = 5L)),
|
||||
mostCommentedAudios = listOf(
|
||||
CommentedAudio(
|
||||
audioContentId = 6L,
|
||||
title = "commented",
|
||||
imageUrl = "https://cdn.test/commented.png",
|
||||
latestComment = "latest comment",
|
||||
latestCommentWriterProfileImageUrl = "https://cdn.test/profile.png"
|
||||
)
|
||||
),
|
||||
recommendedAudios = listOf(card.copy(audioContentId = 7L))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberKind
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
|
||||
@@ -288,7 +287,7 @@ class HomeRecommendationControllerTest @Autowired constructor(
|
||||
val failingQueryService = Mockito.mock(HomeRecommendationQueryService::class.java)
|
||||
val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||
val facade = HomeRecommendationFacade(failingQueryService, preferenceService, "https://cdn.test")
|
||||
Mockito.`when`(preferenceService.initializeDefaultPreference(member)).thenReturn(MemberContentPreference())
|
||||
Mockito.`when`(preferenceService.canViewAdultContent(member)).thenReturn(false)
|
||||
Mockito.`when`(
|
||||
failingQueryService.findLiveRecommendations(
|
||||
offset = 0,
|
||||
@@ -315,7 +314,7 @@ class HomeRecommendationControllerTest @Autowired constructor(
|
||||
val failingQueryService = Mockito.mock(HomeRecommendationQueryService::class.java)
|
||||
val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||
val facade = HomeRecommendationFacade(failingQueryService, preferenceService, "https://cdn.test")
|
||||
Mockito.`when`(preferenceService.initializeDefaultPreference(member)).thenReturn(MemberContentPreference())
|
||||
Mockito.`when`(preferenceService.canViewAdultContent(member)).thenReturn(false)
|
||||
Mockito.`when`(
|
||||
failingQueryService.findRecentDebutCreators(
|
||||
now = Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.MIN,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.home.dto.recommendation
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -12,7 +13,15 @@ class HomeRecommendationResponseTest {
|
||||
fun shouldSerializeNewHomeRecommendationFields() {
|
||||
val response = HomeRecommendationResponse(
|
||||
lives = emptyList(),
|
||||
banners = emptyList(),
|
||||
banners = listOf(
|
||||
RecommendationBannerResponse(
|
||||
imageUrl = "https://cdn.test/banner.png",
|
||||
eventItem = null,
|
||||
creatorId = 11L,
|
||||
seriesId = 12L,
|
||||
link = "https://banner.test"
|
||||
)
|
||||
),
|
||||
recentlyActiveCreators = emptyList(),
|
||||
recentDebutCreators = emptyList(),
|
||||
firstAudioContents = listOf(
|
||||
@@ -83,6 +92,12 @@ class HomeRecommendationResponseTest {
|
||||
|
||||
val json = objectMapper.readTree(objectMapper.writeValueAsString(response))
|
||||
|
||||
assertEquals("https://cdn.test/banner.png", json["banners"][0]["imageUrl"].asText())
|
||||
assertEquals(true, json["banners"][0]["eventItem"].isNull)
|
||||
assertEquals(11L, json["banners"][0]["creatorId"].asLong())
|
||||
assertEquals(12L, json["banners"][0]["seriesId"].asLong())
|
||||
assertEquals("https://banner.test", json["banners"][0]["link"].asText())
|
||||
assertEquals(5, json["banners"][0].size())
|
||||
assertEquals(9, json["firstAudioContents"][0]["price"].asInt())
|
||||
assertEquals(true, json["firstAudioContents"][0]["isPointAvailable"].asBoolean())
|
||||
assertFalse(json["firstAudioContents"][0].has("pointAvailable"))
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
package kr.co.vividnext.sodalive.v2.content.recommendation.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.comment.AudioContentComment
|
||||
import kr.co.vividnext.sodalive.content.like.AudioContentLike
|
||||
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
|
||||
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
||||
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.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMember
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationScorePolicy
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.CreatorContentViewHistory
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
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 DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
||||
private val entityManager: EntityManager,
|
||||
queryFactory: JPAQueryFactory
|
||||
) {
|
||||
private val repository = DefaultAudioRecommendationQueryRepository(queryFactory, entityManager, "https://cdn.test")
|
||||
|
||||
@Test
|
||||
@DisplayName("배너는 홈 추천 배너와 같은 활성/탭/차단 정책과 CDN URL을 적용한다")
|
||||
fun shouldFindBannersWithHomeBannerPolicy() {
|
||||
val viewer = saveMember("viewer", MemberRole.USER)
|
||||
val visibleCreator = saveMember("visible-creator", MemberRole.CREATOR)
|
||||
val blockedCreator = saveMember("blocked-creator", MemberRole.CREATOR)
|
||||
val visibleBanner = saveBanner("visible.png", AudioContentBannerType.CREATOR, 1, creator = visibleCreator)
|
||||
val adultBanner = saveBanner("adult.png", AudioContentBannerType.LINK, 2, isAdult = true, link = "https://adult.test")
|
||||
saveBanner("inactive.png", AudioContentBannerType.LINK, 2, isActive = false, link = "https://inactive.test")
|
||||
saveBanner("blocked.png", AudioContentBannerType.CREATOR, 3, creator = blockedCreator)
|
||||
saveBlock(viewer, blockedCreator)
|
||||
flushAndClear()
|
||||
|
||||
val banners = repository.findBanners(limit = 20, memberId = viewer.id, canViewAdultContent = false)
|
||||
|
||||
assertEquals(
|
||||
listOf("https://cdn.test/${visibleBanner.thumbnailImage}", "https://cdn.test/${adultBanner.thumbnailImage}"),
|
||||
banners.map { it.imageUrl }
|
||||
)
|
||||
assertEquals(visibleCreator.id, banners.first().creatorId)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("오리지널 시리즈는 활성 원본 시리즈를 최신순으로 반환하고 성인/차단 조건을 적용한다")
|
||||
fun shouldFindOriginalSeriesWithVisibilityConditions() {
|
||||
val viewer = saveMember("series-viewer", MemberRole.USER)
|
||||
val visibleCreator = saveMember("series-visible", MemberRole.CREATOR)
|
||||
val blockedCreator = saveMember("series-blocked", MemberRole.CREATOR)
|
||||
val visibleSeries = (1..13).map { index ->
|
||||
saveSeries("visible-series-$index", visibleCreator, isOriginal = true, coverImage = "series-$index.png")
|
||||
}
|
||||
saveSeries("normal-series", visibleCreator, isOriginal = false)
|
||||
saveSeries("adult-series", visibleCreator, isOriginal = true, isAdult = true)
|
||||
saveSeries("blocked-series", blockedCreator, isOriginal = true)
|
||||
saveBlock(viewer, blockedCreator)
|
||||
flushAndClear()
|
||||
|
||||
val series = repository.findOriginalSeries(12, viewer.id, canViewAdultContent = false, now = LocalDateTime.now())
|
||||
|
||||
assertEquals(12, series.size)
|
||||
assertEquals(visibleSeries.map { it.id }.asReversed().take(12), series.map { it.seriesId })
|
||||
assertEquals("https://cdn.test/series-13.png", series.first().coverImageUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최신/무료/포인트 오디오는 공개 조건과 공통 AudioCard enrichment를 적용한다")
|
||||
fun shouldFindRealtimeAudioCardsWithCommonEnrichment() {
|
||||
val now = LocalDateTime.of(2026, 6, 23, 12, 0)
|
||||
val creator = saveMember("audio-creator", MemberRole.CREATOR)
|
||||
val theme = saveTheme()
|
||||
val first = saveAudio(
|
||||
creator = creator,
|
||||
theme = theme,
|
||||
title = "first",
|
||||
releaseDate = now.minusDays(3),
|
||||
price = 0,
|
||||
isPointAvailable = true,
|
||||
coverImage = "first.png"
|
||||
)
|
||||
val latest = saveAudio(
|
||||
creator = creator,
|
||||
theme = theme,
|
||||
title = "latest",
|
||||
releaseDate = now.minusDays(1),
|
||||
price = 10,
|
||||
isPointAvailable = false,
|
||||
coverImage = "latest.png"
|
||||
)
|
||||
saveAudio(creator, theme, "adult", now.minusHours(1), isAdult = true)
|
||||
saveAudio(creator, theme, "future", now.plusDays(1))
|
||||
saveAudio(creator, theme, "inactive", now.minusHours(2)).isActive = false
|
||||
saveAudio(creator, theme, "no-duration", now.minusHours(3)).duration = null
|
||||
saveAudio(creator, theme, "no-release-date", now.minusHours(4)).releaseDate = null
|
||||
val inactiveCreator = saveMember("inactive-audio-creator", MemberRole.CREATOR, isActive = false)
|
||||
saveAudio(inactiveCreator, theme, "inactive-creator", now.minusHours(5))
|
||||
val viewer = saveMember("blocked-audio-viewer", MemberRole.USER)
|
||||
val blockedCreator = saveMember("blocked-audio-creator", MemberRole.CREATOR)
|
||||
saveAudio(blockedCreator, theme, "blocked", now.minusHours(6))
|
||||
saveBlock(viewer, blockedCreator)
|
||||
val originalSeries = saveSeries("original", creator, isOriginal = true)
|
||||
saveSeriesContent(originalSeries, latest)
|
||||
val limitCreator = saveMember("limit-audio-creator", MemberRole.CREATOR)
|
||||
repeat(11) { index ->
|
||||
saveAudio(
|
||||
creator = limitCreator,
|
||||
theme = theme,
|
||||
title = "free-point-$index",
|
||||
releaseDate = now.minusDays(10).minusMinutes(index.toLong()),
|
||||
price = 0,
|
||||
isPointAvailable = true
|
||||
)
|
||||
}
|
||||
flushAndClear()
|
||||
|
||||
val latestAudios = repository.findLatestAudios(12, viewer.id, canViewAdultContent = false, now = now)
|
||||
val freeAudios = repository.findFreeAudios(10, viewer.id, canViewAdultContent = false, now = now)
|
||||
val pointAudios = repository.findPointAudios(10, viewer.id, canViewAdultContent = false, now = now)
|
||||
|
||||
assertEquals(12, latestAudios.size)
|
||||
assertEquals(listOf(latest.id, first.id), latestAudios.take(2).map { it.audioContentId })
|
||||
assertEquals(10, freeAudios.size)
|
||||
assertEquals(true, freeAudios.all { it.price == 0 })
|
||||
assertEquals(10, pointAudios.size)
|
||||
assertEquals(true, pointAudios.all { it.isPointAvailable })
|
||||
val latestCard = latestAudios.first()
|
||||
assertEquals("latest", latestCard.title)
|
||||
assertEquals("00:01", latestCard.duration)
|
||||
assertEquals("https://cdn.test/latest.png", latestCard.imageUrl)
|
||||
assertEquals(10, latestCard.price)
|
||||
assertEquals(false, latestCard.isAdult)
|
||||
assertEquals(false, latestCard.isPointAvailable)
|
||||
assertEquals(false, latestCard.isFirstContent)
|
||||
assertEquals(true, latestCard.isOriginalSeries)
|
||||
assertEquals(creator.nickname, latestCard.creatorNickname)
|
||||
assertEquals(true, latestAudios[1].isFirstContent)
|
||||
assertEquals(false, latestAudios[1].isOriginalSeries)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("New & Hot 후보는 조회/좋아요/댓글/최신성 점수순으로 산정하고 SAFE는 성인을 제외한다")
|
||||
fun shouldFindNewAndHotSnapshotsWithVisibility() {
|
||||
val snapshotAt = LocalDateTime.now().plusDays(1)
|
||||
val windowStart = snapshotAt.minusDays(2).toLocalDate().atStartOfDay()
|
||||
val creator = saveMember("snapshot-creator", MemberRole.CREATOR)
|
||||
val theme = saveTheme()
|
||||
val visible = saveAudio(creator, theme, "visible-hot", snapshotAt.minusDays(1))
|
||||
val adult = saveAudio(creator, theme, "adult-hot", snapshotAt.minusDays(1), isAdult = true)
|
||||
repeat(2) { saveView(visible, snapshotAt.minusHours(it.toLong())) }
|
||||
saveLike(visible, snapshotAt.minusHours(1))
|
||||
saveComment(visible, creator, "visible-comment", snapshotAt.minusHours(1))
|
||||
repeat(5) { saveView(adult, snapshotAt.minusHours(it.toLong())) }
|
||||
flushAndClear()
|
||||
|
||||
val safe = repository.findNewAndHotSnapshots(windowStart, snapshotAt, AudioRecommendationVisibility.SAFE, 12)
|
||||
val all = repository.findNewAndHotSnapshots(windowStart, snapshotAt, AudioRecommendationVisibility.ALL, 12)
|
||||
|
||||
assertEquals(listOf(visible.id), safe.map { it.targetId })
|
||||
assertEquals(RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, safe.first().sectionType)
|
||||
assertEquals(listOf(adult.id, visible.id), all.map { it.targetId })
|
||||
val expectedScore = AudioRecommendationScorePolicy().calculateNewAndHotScore(
|
||||
viewCount = 2,
|
||||
likeCount = 1,
|
||||
commentCount = 1,
|
||||
releaseDate = visible.releaseDate!!,
|
||||
now = snapshotAt
|
||||
)
|
||||
assertEquals(expectedScore, safe.first().score)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 댓글 많은 오디오는 댓글 점수 후보와 최신 댓글 상세를 반환한다")
|
||||
fun shouldFindMostCommentedSnapshotsAndCommentedAudios() {
|
||||
val snapshotAt = LocalDateTime.now().plusDays(1)
|
||||
val windowStart = snapshotAt.minusDays(6).toLocalDate().atStartOfDay()
|
||||
val viewer = saveMember("comment-viewer", MemberRole.USER)
|
||||
val creator = saveMember("comment-creator", MemberRole.CREATOR)
|
||||
val writer = saveMember("comment-writer", MemberRole.USER).apply { profileImage = "writer.png" }
|
||||
val blockedWriter = saveMember("blocked-writer", MemberRole.USER)
|
||||
val inactiveWriter = saveMember("inactive-comment-writer", MemberRole.USER, isActive = false)
|
||||
val theme = saveTheme()
|
||||
val first = saveAudio(creator, theme, "first-commented", snapshotAt.minusDays(2), coverImage = "commented.png")
|
||||
val second = saveAudio(creator, theme, "second-commented", snapshotAt.minusDays(2))
|
||||
val hiddenOnly = saveAudio(creator, theme, "hidden-only", snapshotAt.minusDays(2))
|
||||
val invisibleOnly = saveAudio(creator, theme, "invisible-only", snapshotAt.minusDays(2))
|
||||
saveComment(first, writer, "old", snapshotAt.minusDays(2))
|
||||
saveComment(first, writer, "latest", snapshotAt.minusHours(1))
|
||||
saveComment(first, blockedWriter, "blocked-latest", snapshotAt.minusMinutes(30))
|
||||
saveComment(first, writer, "inactive", snapshotAt.minusMinutes(1), isActive = false)
|
||||
saveComment(second, blockedWriter, "blocked", snapshotAt.minusHours(2))
|
||||
val parent = saveComment(hiddenOnly, writer, "parent", snapshotAt.minusDays(1), isSecret = true)
|
||||
saveComment(hiddenOnly, writer, "reply", snapshotAt.minusHours(1), parent = parent)
|
||||
saveComment(invisibleOnly, inactiveWriter, "inactive-writer", snapshotAt.minusHours(2))
|
||||
saveComment(invisibleOnly, blockedWriter, "blocked-writer", snapshotAt.minusHours(1))
|
||||
saveBlock(creator, blockedWriter)
|
||||
saveBlock(viewer, blockedWriter)
|
||||
flushAndClear()
|
||||
|
||||
val snapshots = repository.findMostCommentedSnapshots(windowStart, snapshotAt, AudioRecommendationVisibility.SAFE, 5)
|
||||
val commented = repository.findCommentedAudiosByIds(
|
||||
contentIds = listOf(first.id!!, second.id!!),
|
||||
memberId = viewer.id,
|
||||
canViewAdultContent = false
|
||||
)
|
||||
|
||||
assertEquals(listOf(first.id), snapshots.map { it.targetId })
|
||||
assertEquals(RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, snapshots.first().sectionType)
|
||||
assertEquals(listOf(first.id), commented.map { it.audioContentId })
|
||||
assertEquals("latest", commented.first().latestComment)
|
||||
assertEquals("https://cdn.test/writer.png", commented.first().latestCommentWriterProfileImageUrl)
|
||||
assertEquals("https://cdn.test/commented.png", commented.first().imageUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("댓글 상세는 공개 최상위 댓글만 노출하고 동일 시각이면 id가 큰 댓글 하나를 선택한다")
|
||||
fun shouldFindLatestVisibleTopLevelCommentWithIdTieBreaker() {
|
||||
val now = LocalDateTime.now().plusDays(1)
|
||||
val viewer = saveMember("tie-viewer", MemberRole.USER)
|
||||
val creator = saveMember("tie-creator", MemberRole.CREATOR)
|
||||
val writer = saveMember("tie-writer", MemberRole.USER)
|
||||
val theme = saveTheme()
|
||||
val audio = saveAudio(creator, theme, "tie-commented", now.minusDays(1))
|
||||
val sameCreatedAt = now.minusHours(1)
|
||||
saveComment(audio, writer, "same-time-first", sameCreatedAt)
|
||||
saveComment(audio, writer, "same-time-second", sameCreatedAt)
|
||||
saveComment(audio, writer, "secret-latest", now.minusMinutes(20), isSecret = true)
|
||||
val parent = saveComment(audio, writer, "public-parent", now.minusHours(2))
|
||||
saveComment(audio, writer, "reply-latest", now.minusMinutes(10), parent = parent)
|
||||
flushAndClear()
|
||||
|
||||
val commented = repository.findCommentedAudiosByIds(
|
||||
contentIds = listOf(audio.id!!),
|
||||
memberId = viewer.id,
|
||||
canViewAdultContent = false
|
||||
)
|
||||
|
||||
assertEquals(1, commented.size)
|
||||
assertEquals("same-time-second", commented.single().latestComment)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("댓글 상세는 크리에이터와 댓글 작성자 간 차단 댓글을 최신 댓글에서 제외한다")
|
||||
fun shouldExcludeCreatorBlockedWriterFromLatestCommentDetail() {
|
||||
val now = LocalDateTime.now().plusDays(1)
|
||||
val viewer = saveMember("creator-block-comment-viewer", MemberRole.USER)
|
||||
val creator = saveMember("creator-block-comment-creator", MemberRole.CREATOR)
|
||||
val writer = saveMember("creator-block-comment-writer", MemberRole.USER)
|
||||
val blockedWriter = saveMember("creator-block-comment-blocked-writer", MemberRole.USER)
|
||||
val theme = saveTheme()
|
||||
val audio = saveAudio(creator, theme, "creator-block-commented", now.minusDays(1))
|
||||
saveComment(audio, writer, "visible-comment", now.minusHours(2))
|
||||
saveComment(audio, blockedWriter, "creator-blocked-latest", now.minusHours(1))
|
||||
saveBlock(creator, blockedWriter)
|
||||
flushAndClear()
|
||||
|
||||
val commented = repository.findCommentedAudiosByIds(
|
||||
contentIds = listOf(audio.id!!),
|
||||
memberId = viewer.id,
|
||||
canViewAdultContent = false
|
||||
)
|
||||
|
||||
assertEquals(1, commented.size)
|
||||
assertEquals("visible-comment", commented.single().latestComment)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("추천 오디오는 playCount가 아니라 조회 이력 기반 점수로 산정한다")
|
||||
fun shouldFindRecommendedAudioSnapshotsWithoutPlayCount() {
|
||||
val snapshotAt = LocalDateTime.now().plusDays(1)
|
||||
val windowStart = snapshotAt.minusDays(6).toLocalDate().atStartOfDay()
|
||||
val creator = saveMember("recommended-creator", MemberRole.CREATOR)
|
||||
val theme = saveTheme()
|
||||
val viewed = saveAudio(creator, theme, "viewed", snapshotAt.minusDays(1))
|
||||
val playCountOnly = saveAudio(creator, theme, "play-count-only", snapshotAt.minusDays(1)).apply { playCount = 999 }
|
||||
repeat(3) { saveView(viewed, snapshotAt.minusHours(it.toLong())) }
|
||||
flushAndClear()
|
||||
|
||||
val snapshots = repository.findRecommendedAudioSnapshots(windowStart, snapshotAt, AudioRecommendationVisibility.SAFE, 10)
|
||||
|
||||
assertEquals(viewed.id, snapshots.first().targetId)
|
||||
assertEquals(RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, snapshots.first().sectionType)
|
||||
assertEquals(
|
||||
true,
|
||||
snapshots.indexOfFirst { it.targetId == viewed.id } <
|
||||
snapshots.indexOfFirst { it.targetId == playCountOnly.id }
|
||||
)
|
||||
}
|
||||
|
||||
private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member {
|
||||
val member = Member(
|
||||
email = "$nickname@test.com",
|
||||
password = "password",
|
||||
nickname = nickname,
|
||||
role = role,
|
||||
isActive = isActive
|
||||
)
|
||||
entityManager.persist(member)
|
||||
return member
|
||||
}
|
||||
|
||||
private fun saveTheme(): AudioContentTheme {
|
||||
val theme = AudioContentTheme(theme = "theme", image = "theme.png", isActive = true)
|
||||
entityManager.persist(theme)
|
||||
return theme
|
||||
}
|
||||
|
||||
private fun saveBanner(
|
||||
thumbnailImage: String,
|
||||
type: AudioContentBannerType,
|
||||
orders: Int,
|
||||
isActive: Boolean = true,
|
||||
isAdult: Boolean = false,
|
||||
creator: Member? = null,
|
||||
link: String? = null
|
||||
): AudioContentBanner {
|
||||
val banner = AudioContentBanner(
|
||||
thumbnailImage = thumbnailImage,
|
||||
type = type,
|
||||
isAdult = isAdult,
|
||||
isActive = isActive,
|
||||
orders = orders
|
||||
)
|
||||
banner.creator = creator
|
||||
banner.link = link
|
||||
entityManager.persist(banner)
|
||||
return banner
|
||||
}
|
||||
|
||||
private fun saveSeries(
|
||||
title: String,
|
||||
creator: Member,
|
||||
isOriginal: Boolean,
|
||||
isAdult: Boolean = false,
|
||||
coverImage: String? = null
|
||||
): Series {
|
||||
val genre = SeriesGenre("genre-$title")
|
||||
entityManager.persist(genre)
|
||||
val series = Series(title = title, introduction = "intro", isOriginal = isOriginal, isAdult = isAdult, isActive = true)
|
||||
series.genre = genre
|
||||
series.member = creator
|
||||
series.coverImage = coverImage
|
||||
entityManager.persist(series)
|
||||
return series
|
||||
}
|
||||
|
||||
private fun saveAudio(
|
||||
creator: Member,
|
||||
theme: AudioContentTheme,
|
||||
title: String,
|
||||
releaseDate: LocalDateTime,
|
||||
price: Int = 0,
|
||||
isAdult: Boolean = false,
|
||||
isPointAvailable: Boolean = false,
|
||||
coverImage: String? = null
|
||||
): AudioContent {
|
||||
val audio = AudioContent(
|
||||
title = title,
|
||||
detail = "detail",
|
||||
languageCode = "ko",
|
||||
price = price,
|
||||
releaseDate = releaseDate,
|
||||
isAdult = isAdult,
|
||||
isPointAvailable = isPointAvailable
|
||||
)
|
||||
audio.isActive = true
|
||||
audio.duration = "00:01"
|
||||
audio.coverImage = coverImage
|
||||
audio.member = creator
|
||||
audio.theme = theme
|
||||
entityManager.persist(audio)
|
||||
return audio
|
||||
}
|
||||
|
||||
private fun saveView(audio: AudioContent, viewedAt: LocalDateTime) {
|
||||
entityManager.persist(
|
||||
CreatorContentViewHistory(memberId = 1L, contentId = audio.id!!, genreId = 1L, viewedAt = viewedAt)
|
||||
)
|
||||
}
|
||||
|
||||
private fun saveLike(audio: AudioContent, createdAt: LocalDateTime) {
|
||||
val like = AudioContentLike(memberId = 1L)
|
||||
like.audioContent = audio
|
||||
like.createdAt = createdAt
|
||||
like.updatedAt = createdAt
|
||||
entityManager.persist(like)
|
||||
}
|
||||
|
||||
private fun saveComment(
|
||||
audio: AudioContent,
|
||||
writer: Member,
|
||||
commentBody: String,
|
||||
createdAt: LocalDateTime,
|
||||
isActive: Boolean = true,
|
||||
isSecret: Boolean = false,
|
||||
parent: AudioContentComment? = null
|
||||
): AudioContentComment {
|
||||
val comment = AudioContentComment(
|
||||
comment = commentBody,
|
||||
languageCode = "ko",
|
||||
isSecret = isSecret,
|
||||
isActive = isActive
|
||||
)
|
||||
comment.audioContent = audio
|
||||
comment.member = writer
|
||||
comment.parent = parent
|
||||
comment.createdAt = createdAt
|
||||
comment.updatedAt = createdAt
|
||||
entityManager.persist(comment)
|
||||
entityManager.flush()
|
||||
comment.createdAt = createdAt
|
||||
comment.updatedAt = createdAt
|
||||
return comment
|
||||
}
|
||||
|
||||
private fun saveSeriesContent(series: Series, audio: AudioContent) {
|
||||
val seriesContent = SeriesContent()
|
||||
seriesContent.series = series
|
||||
seriesContent.content = audio
|
||||
entityManager.persist(seriesContent)
|
||||
}
|
||||
|
||||
private fun saveBlock(member: Member, blockedMember: Member) {
|
||||
val block = BlockMember(isActive = true)
|
||||
block.member = member
|
||||
block.blockedMember = blockedMember
|
||||
entityManager.persist(block)
|
||||
}
|
||||
|
||||
private fun flushAndClear() {
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.scheduler
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.redisson.api.RLock
|
||||
import org.redisson.api.RedissonClient
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class AudioRecommendationSnapshotSchedulerTest {
|
||||
private val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java)
|
||||
private val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||
private val lock = Mockito.mock(RLock::class.java)
|
||||
private val scheduler = AudioRecommendationSnapshotScheduler(refreshService, redissonClient)
|
||||
|
||||
@Test
|
||||
@DisplayName("스케줄러는 매일 00:00 KST cron을 사용한다")
|
||||
fun shouldUseMidnightKstCron() {
|
||||
val annotation = AudioRecommendationSnapshotScheduler::class.java
|
||||
.getDeclaredMethod("refreshDailySnapshots")
|
||||
.getAnnotation(Scheduled::class.java)
|
||||
|
||||
assertEquals("0 0 0 * * *", annotation.cron)
|
||||
assertEquals("Asia/Seoul", annotation.zone)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("락 획득 성공 시에만 refresh를 호출하고 보유 중이면 unlock한다")
|
||||
fun shouldRefreshOnlyWhenLockAcquired() {
|
||||
Mockito.doReturn(lock).`when`(redissonClient).getLock(AudioRecommendationSnapshotScheduler.LOCK_KEY)
|
||||
Mockito.doReturn(true).`when`(lock).tryLock(0, -1, TimeUnit.SECONDS)
|
||||
Mockito.doReturn(true).`when`(lock).isHeldByCurrentThread
|
||||
|
||||
scheduler.refreshDailySnapshots()
|
||||
|
||||
Mockito.verify(refreshService).refreshDailySnapshots()
|
||||
Mockito.verify(lock).unlock()
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("락 획득 실패 시 refresh와 unlock을 호출하지 않는다")
|
||||
fun shouldSkipWhenLockNotAcquired() {
|
||||
Mockito.doReturn(lock).`when`(redissonClient).getLock(AudioRecommendationSnapshotScheduler.LOCK_KEY)
|
||||
Mockito.doReturn(false).`when`(lock).tryLock(0, -1, TimeUnit.SECONDS)
|
||||
Mockito.doReturn(false).`when`(lock).isHeldByCurrentThread
|
||||
|
||||
scheduler.refreshDailySnapshots()
|
||||
|
||||
Mockito.verify(refreshService, Mockito.never()).refreshDailySnapshots()
|
||||
Mockito.verify(lock, Mockito.never()).unlock()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package kr.co.vividnext.sodalive.v2.content.recommendation.application
|
||||
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.redisson.api.RBucket
|
||||
import org.redisson.api.RedissonClient
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class AudioRecommendationQueryServiceTest {
|
||||
private val queryPort = Mockito.mock(AudioRecommendationQueryPort::class.java)
|
||||
private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||
private val snapshotPort = Mockito.mock(RecommendationSnapshotPort::class.java)
|
||||
private val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java)
|
||||
private val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||
private val lazyRefreshMarker = Mockito.mock(RBucket::class.java) as RBucket<String>
|
||||
private val service = AudioRecommendationQueryService(
|
||||
queryPort,
|
||||
preferenceService,
|
||||
snapshotPort,
|
||||
refreshService,
|
||||
redissonClient
|
||||
)
|
||||
|
||||
@Test
|
||||
@DisplayName("비회원은 SAFE visibility를 사용한다")
|
||||
fun shouldResolveSafeVisibilityForAnonymous() {
|
||||
assertEquals(AudioRecommendationVisibility.SAFE, service.resolveVisibility(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("조회 서비스는 SAFE 스냅샷을 lazy refresh 후 상세 섹션으로 조립한다")
|
||||
fun shouldBuildRecommendationsFromSafeSnapshotsWithLazyRefresh() {
|
||||
val snapshot = RecommendationSnapshotRecord(
|
||||
sectionType = RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE,
|
||||
targetId = 1L,
|
||||
score = 10.0,
|
||||
snapshotAt = LocalDateTime.now(),
|
||||
randomTieBreaker = 1.0
|
||||
)
|
||||
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>(), listOf(snapshot))
|
||||
.`when`(snapshotPort)
|
||||
.findLatestSnapshots(
|
||||
RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE,
|
||||
0,
|
||||
AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT
|
||||
)
|
||||
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>())
|
||||
.`when`(snapshotPort)
|
||||
.findLatestSnapshots(
|
||||
RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE,
|
||||
0,
|
||||
AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT
|
||||
)
|
||||
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>())
|
||||
.`when`(snapshotPort)
|
||||
.findLatestSnapshots(
|
||||
RecommendedSectionType.RECOMMENDED_AUDIO_SAFE,
|
||||
0,
|
||||
AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT
|
||||
)
|
||||
allowLazyRefreshOnce()
|
||||
|
||||
val recommendations = service.getRecommendations(null)
|
||||
|
||||
assertEquals(0, recommendations.mostCommentedAudios.size)
|
||||
Mockito.verify(refreshService).refreshDailySnapshots()
|
||||
Mockito.verify(snapshotPort, Mockito.times(1)).findLatestSnapshots(
|
||||
RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE,
|
||||
0,
|
||||
AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT
|
||||
)
|
||||
Mockito.verify(snapshotPort, Mockito.times(1)).findLatestSnapshots(
|
||||
RecommendedSectionType.RECOMMENDED_AUDIO_SAFE,
|
||||
0,
|
||||
AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT
|
||||
)
|
||||
Mockito.verify(queryPort).findBanners(AudioRecommendationQueryService.BANNER_LIMIT, null, false)
|
||||
Mockito.verify(queryPort).findAudioCardsByIds(
|
||||
eqValue(listOf(1L)),
|
||||
Mockito.isNull(),
|
||||
eqValue(false),
|
||||
anyLocalDateTime()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("New & Hot lazy refresh는 보강 후에도 비어 있으면 같은 KST 날짜에 다시 실행하지 않는다")
|
||||
fun shouldAttemptEmptyNewAndHotLazyRefreshOncePerKstDate() {
|
||||
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>())
|
||||
.`when`(snapshotPort)
|
||||
.findLatestSnapshots(
|
||||
RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE,
|
||||
0,
|
||||
AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT
|
||||
)
|
||||
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>())
|
||||
.`when`(snapshotPort)
|
||||
.findLatestSnapshots(
|
||||
RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE,
|
||||
0,
|
||||
AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT
|
||||
)
|
||||
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>())
|
||||
.`when`(snapshotPort)
|
||||
.findLatestSnapshots(
|
||||
RecommendedSectionType.RECOMMENDED_AUDIO_SAFE,
|
||||
0,
|
||||
AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT
|
||||
)
|
||||
allowLazyRefreshOnce()
|
||||
|
||||
service.getRecommendations(null)
|
||||
service.getRecommendations(null)
|
||||
|
||||
Mockito.verify(refreshService, Mockito.times(1)).refreshDailySnapshots()
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("인증 회원 성인 정책은 조회용 저장 preference를 사용한다")
|
||||
fun shouldUseStoredPreferenceForMemberAdultVisibility() {
|
||||
val member = kr.co.vividnext.sodalive.member.Member(
|
||||
email = "adult@test.com",
|
||||
password = "password",
|
||||
nickname = "adult",
|
||||
role = kr.co.vividnext.sodalive.member.MemberRole.USER
|
||||
)
|
||||
Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member)
|
||||
Mockito.doReturn(listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 10L)))
|
||||
.`when`(snapshotPort)
|
||||
.findLatestSnapshots(
|
||||
RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL,
|
||||
0,
|
||||
AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT
|
||||
)
|
||||
|
||||
service.getRecommendations(member)
|
||||
|
||||
Mockito.verify(preferenceService).canViewAdultContent(member)
|
||||
Mockito.verify(preferenceService, Mockito.never()).initializeDefaultPreference(member)
|
||||
Mockito.verify(snapshotPort).findLatestSnapshots(
|
||||
RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL,
|
||||
0,
|
||||
AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("visibility와 섹션 조합은 신규 RecommendedSectionType으로 매핑된다")
|
||||
fun shouldMapVisibilityToAudioSectionTypes() {
|
||||
assertEquals(
|
||||
RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE,
|
||||
service.newAndHotSectionType(AudioRecommendationVisibility.SAFE)
|
||||
)
|
||||
assertEquals(
|
||||
RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL,
|
||||
service.newAndHotSectionType(AudioRecommendationVisibility.ALL)
|
||||
)
|
||||
assertEquals(
|
||||
RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE,
|
||||
service.mostCommentedSectionType(AudioRecommendationVisibility.SAFE)
|
||||
)
|
||||
assertEquals(
|
||||
RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL,
|
||||
service.mostCommentedSectionType(AudioRecommendationVisibility.ALL)
|
||||
)
|
||||
assertEquals(
|
||||
RecommendedSectionType.RECOMMENDED_AUDIO_SAFE,
|
||||
service.recommendedAudioSectionType(AudioRecommendationVisibility.SAFE)
|
||||
)
|
||||
assertEquals(
|
||||
RecommendedSectionType.RECOMMENDED_AUDIO_ALL,
|
||||
service.recommendedAudioSectionType(AudioRecommendationVisibility.ALL)
|
||||
)
|
||||
}
|
||||
|
||||
private fun anyLocalDateTime(): LocalDateTime {
|
||||
return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
|
||||
}
|
||||
|
||||
private fun snapshot(sectionType: RecommendedSectionType, targetId: Long): RecommendationSnapshotRecord {
|
||||
return RecommendationSnapshotRecord(
|
||||
sectionType = sectionType,
|
||||
targetId = targetId,
|
||||
score = 10.0,
|
||||
snapshotAt = LocalDateTime.now(),
|
||||
randomTieBreaker = 1.0
|
||||
)
|
||||
}
|
||||
|
||||
private fun <T> eqValue(value: T): T {
|
||||
return Mockito.eq(value) ?: value
|
||||
}
|
||||
|
||||
private fun allowLazyRefreshOnce() {
|
||||
Mockito.doReturn(lazyRefreshMarker).`when`(redissonClient).getBucket<String>(Mockito.anyString())
|
||||
Mockito.doReturn(true, false).`when`(lazyRefreshMarker).setIfAbsent(
|
||||
eqValue("1"),
|
||||
eqValue(Duration.ofDays(2))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package kr.co.vividnext.sodalive.v2.content.recommendation.application
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
class AudioRecommendationSnapshotRefreshServiceTest {
|
||||
private val snapshotPort = Mockito.mock(RecommendationSnapshotPort::class.java)
|
||||
private val queryPort = Mockito.mock(AudioRecommendationQueryPort::class.java)
|
||||
private val service = AudioRecommendationSnapshotRefreshService(snapshotPort, queryPort)
|
||||
|
||||
@Test
|
||||
@DisplayName("일 배치는 KST 전날 23:59:59 기준으로 여섯 오디오 스냅샷을 교체한다")
|
||||
fun shouldRefreshAllAudioSnapshotsWithKstPreviousDaySnapshotAt() {
|
||||
val now = LocalDateTime.of(2026, 6, 24, 0, 0)
|
||||
val snapshotAt = LocalDateTime.of(2026, 6, 23, 23, 59, 59)
|
||||
val newAndHotWindowStart = LocalDateTime.of(2026, 6, 21, 0, 0)
|
||||
val mostCommentedWindowStart = LocalDateTime.of(2026, 6, 17, 0, 0)
|
||||
|
||||
service.refreshDailySnapshots(now)
|
||||
|
||||
Mockito.verify(queryPort).findNewAndHotSnapshots(
|
||||
newAndHotWindowStart,
|
||||
snapshotAt,
|
||||
AudioRecommendationVisibility.SAFE,
|
||||
AudioRecommendationSnapshotRefreshService.NEW_AND_HOT_LIMIT
|
||||
)
|
||||
Mockito.verify(queryPort).findMostCommentedSnapshots(
|
||||
mostCommentedWindowStart,
|
||||
snapshotAt,
|
||||
AudioRecommendationVisibility.ALL,
|
||||
AudioRecommendationSnapshotRefreshService.MOST_COMMENTED_LIMIT
|
||||
)
|
||||
Mockito.verify(queryPort).findRecommendedAudioSnapshots(
|
||||
mostCommentedWindowStart,
|
||||
snapshotAt,
|
||||
AudioRecommendationVisibility.SAFE,
|
||||
AudioRecommendationSnapshotRefreshService.RECOMMENDED_AUDIO_LIMIT
|
||||
)
|
||||
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, snapshotAt, emptyList())
|
||||
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, snapshotAt, emptyList())
|
||||
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, snapshotAt, emptyList())
|
||||
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL, snapshotAt, emptyList())
|
||||
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, snapshotAt, emptyList())
|
||||
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.RECOMMENDED_AUDIO_ALL, snapshotAt, emptyList())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("일 배치는 ZonedDateTime 입력의 zone과 무관하게 KST 날짜 경계 기준으로 스냅샷 시각을 계산한다")
|
||||
fun shouldRefreshSnapshotsByKstBoundaryFromZonedDateTime() {
|
||||
val now = ZonedDateTime.of(2026, 6, 24, 0, 0, 0, 0, ZoneId.of("Asia/Seoul"))
|
||||
val snapshotAt = LocalDateTime.of(2026, 6, 23, 23, 59, 59)
|
||||
val newAndHotWindowStart = LocalDateTime.of(2026, 6, 21, 0, 0)
|
||||
val mostCommentedWindowStart = LocalDateTime.of(2026, 6, 17, 0, 0)
|
||||
|
||||
service.refreshDailySnapshots(now)
|
||||
|
||||
Mockito.verify(queryPort).findNewAndHotSnapshots(
|
||||
newAndHotWindowStart,
|
||||
snapshotAt,
|
||||
AudioRecommendationVisibility.SAFE,
|
||||
AudioRecommendationSnapshotRefreshService.NEW_AND_HOT_LIMIT
|
||||
)
|
||||
Mockito.verify(queryPort).findMostCommentedSnapshots(
|
||||
mostCommentedWindowStart,
|
||||
snapshotAt,
|
||||
AudioRecommendationVisibility.ALL,
|
||||
AudioRecommendationSnapshotRefreshService.MOST_COMMENTED_LIMIT
|
||||
)
|
||||
Mockito.verify(queryPort).findRecommendedAudioSnapshots(
|
||||
mostCommentedWindowStart,
|
||||
snapshotAt,
|
||||
AudioRecommendationVisibility.SAFE,
|
||||
AudioRecommendationSnapshotRefreshService.RECOMMENDED_AUDIO_LIMIT
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package kr.co.vividnext.sodalive.v2.content.recommendation.domain
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class AudioRecommendationScorePolicyTest {
|
||||
private val policy = AudioRecommendationScorePolicy()
|
||||
private val now = LocalDateTime.of(2026, 6, 23, 12, 0)
|
||||
|
||||
@Test
|
||||
@DisplayName("New & Hot 점수는 원본 count를 정규화하지 않고 가중합으로 계산한다")
|
||||
fun shouldCalculateNewAndHotScoreWithoutNormalization() {
|
||||
val score = policy.calculateNewAndHotScore(10, 4, 2, now.minusDays(2), now)
|
||||
|
||||
assertEquals(485.5, score)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("추천 오디오 점수는 원본 count와 최신성 배수를 가중합으로 계산한다")
|
||||
fun shouldCalculateRecommendedAudioScoreWithoutNormalization() {
|
||||
val score = policy.calculateRecommendedAudioScore(10, 4, 2, now.minusDays(10), now)
|
||||
|
||||
assertEquals(601.0, score)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최신성 배수는 정책별 일수 경계를 적용한다")
|
||||
fun shouldReturnRecencyMultipliersByPolicyBoundaries() {
|
||||
assertEquals(1.3, policy.newAndHotRecencyMultiplier(now.minusDays(3), now))
|
||||
assertEquals(1.15, policy.newAndHotRecencyMultiplier(now.minusDays(7), now))
|
||||
assertEquals(1.0, policy.newAndHotRecencyMultiplier(now.minusDays(14), now))
|
||||
assertEquals(0.8, policy.newAndHotRecencyMultiplier(now.minusDays(15), now))
|
||||
assertEquals(1.3, policy.recommendedAudioRecencyMultiplier(now.minusDays(3), now))
|
||||
assertEquals(1.15, policy.recommendedAudioRecencyMultiplier(now.minusDays(7), now))
|
||||
assertEquals(1.1, policy.recommendedAudioRecencyMultiplier(now.minusDays(30), now))
|
||||
assertEquals(1.0, policy.recommendedAudioRecencyMultiplier(now.minusDays(31), now))
|
||||
assertEquals(1.3, policy.commentRecencyMultiplier(now.minusDays(3), now))
|
||||
assertEquals(1.15, policy.commentRecencyMultiplier(now.minusDays(7), now))
|
||||
assertEquals(1.0, policy.commentRecencyMultiplier(now.minusDays(14), now))
|
||||
assertEquals(0.0, policy.commentRecencyMultiplier(now.minusDays(15), now))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최신성 일수는 날짜 경계가 아니라 24시간 경과 기준으로 계산한다")
|
||||
fun shouldCalculateRecencyDaysByElapsedTwentyFourHours() {
|
||||
val releaseDate = LocalDateTime.of(2026, 6, 19, 23, 59, 59)
|
||||
val snapshotAt = LocalDateTime.of(2026, 6, 23, 0, 0)
|
||||
|
||||
assertEquals(1.3, policy.newAndHotRecencyMultiplier(releaseDate, snapshotAt))
|
||||
assertEquals(1.3, policy.recommendedAudioRecencyMultiplier(releaseDate, snapshotAt))
|
||||
assertEquals(1.3, policy.commentRecencyMultiplier(releaseDate, snapshotAt))
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.audio.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
|
||||
@@ -9,7 +8,6 @@ import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberProvider
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
|
||||
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicy
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioContentRecord
|
||||
@@ -134,15 +132,8 @@ class CreatorChannelAudioQueryServiceTest {
|
||||
): CreatorChannelAudioQueryService {
|
||||
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
|
||||
)
|
||||
)
|
||||
preferenceService.canViewAdultContent(Mockito.any(Member::class.java) ?: createMember(id = 0L))
|
||||
).thenReturn(canViewAdultContent)
|
||||
val langContext = LangContext()
|
||||
langContext.setLang(Lang.EN)
|
||||
return CreatorChannelAudioQueryService(
|
||||
|
||||
@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.v2.creator.channel.community.application
|
||||
|
||||
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
|
||||
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
|
||||
@@ -10,7 +9,6 @@ import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberProvider
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicy
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
|
||||
@@ -180,15 +178,8 @@ class CreatorChannelCommunityQueryServiceTest {
|
||||
): CreatorChannelCommunityQueryService {
|
||||
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
|
||||
)
|
||||
)
|
||||
preferenceService.canViewAdultContent(Mockito.any(Member::class.java) ?: createMember(id = 0L))
|
||||
).thenReturn(canViewAdultContent)
|
||||
val langContext = LangContext()
|
||||
langContext.setLang(Lang.EN)
|
||||
return CreatorChannelCommunityQueryService(
|
||||
|
||||
@@ -421,6 +421,9 @@ class CreatorChannelHomeQueryServiceTest {
|
||||
isAdult = canViewAdultContent
|
||||
)
|
||||
)
|
||||
Mockito.`when`(
|
||||
preferenceService.canViewAdultContent(Mockito.any(Member::class.java) ?: createMember(id = 0L))
|
||||
).thenReturn(canViewAdultContent)
|
||||
val messageSource = SodaMessageSource()
|
||||
val langContext = LangContext()
|
||||
langContext.setLang(Lang.KO)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -11,7 +10,6 @@ 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
|
||||
@@ -220,15 +218,8 @@ class CreatorChannelLiveQueryServiceTest {
|
||||
): 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
|
||||
)
|
||||
)
|
||||
preferenceService.canViewAdultContent(Mockito.any(Member::class.java) ?: createMember(id = 0L))
|
||||
).thenReturn(canViewAdultContent)
|
||||
val messageSource = SodaMessageSource()
|
||||
val langContext = LangContext()
|
||||
langContext.setLang(Lang.KO)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.series.application
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.content.ContentType
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
|
||||
import kr.co.vividnext.sodalive.i18n.Lang
|
||||
@@ -11,7 +10,6 @@ import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberProvider
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
|
||||
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicy
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesCreatorRecord
|
||||
@@ -143,15 +141,8 @@ class CreatorChannelSeriesQueryServiceTest {
|
||||
): CreatorChannelSeriesQueryService {
|
||||
val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||
Mockito.`when`(
|
||||
preferenceService.getStoredPreference(Mockito.any(Member::class.java) ?: createMember(id = 0L))
|
||||
).thenReturn(
|
||||
ViewerContentPreference(
|
||||
countryCode = "US",
|
||||
isAdultContentVisible = canViewAdultContent,
|
||||
contentType = ContentType.ALL,
|
||||
isAdult = canViewAdultContent
|
||||
)
|
||||
)
|
||||
preferenceService.canViewAdultContent(Mockito.any(Member::class.java) ?: createMember(id = 0L))
|
||||
).thenReturn(canViewAdultContent)
|
||||
val langContext = LangContext()
|
||||
langContext.setLang(Lang.EN)
|
||||
return CreatorChannelSeriesQueryService(
|
||||
|
||||
Reference in New Issue
Block a user