Compare commits

...

19 Commits

Author SHA1 Message Date
9c458d0ae1 fix(admin-member): 회원 목록 lazy 초기화를 방지한다 2026-06-27 07:53:58 +09:00
342c39890e docs(admin-member): 회원 목록 lazy 초기화 수정 계획을 추가한다 2026-06-27 07:53:42 +09:00
55abbd2a6d test(content): 콘텐츠 전체보기 E2E 검증을 추가한다 2026-06-27 07:32:50 +09:00
0686dd6eb3 docs(content): 콘텐츠 전체보기 Phase 4 검증 기록을 갱신한다 2026-06-27 07:09:58 +09:00
9c7b956fdc fix(home): 미배포 first-audio 하위 endpoint를 제거한다 2026-06-27 07:09:48 +09:00
b5f0cfee4b docs(content): 콘텐츠 전체보기 Phase 3 기록을 갱신한다 2026-06-27 06:42:41 +09:00
686bd2c987 feat(content): 콘텐츠 전체보기 endpoint를 추가한다 2026-06-27 06:41:47 +09:00
4e2b63acf4 feat(content): 콘텐츠 전체보기 facade를 추가한다 2026-06-27 06:41:06 +09:00
ef9ddae94b feat(recommendation): 첫 오디오 콘텐츠 플래그를 확장한다 2026-06-27 06:40:55 +09:00
151593a524 docs(content): 콘텐츠 전체보기 Phase 2 기록을 갱신한다 2026-06-27 05:50:31 +09:00
581c5fd441 feat(recommendation): New & Hot 전체보기 조회를 추가한다 2026-06-27 05:49:27 +09:00
6ab8d65207 fix(recommendation): New & Hot 스냅샷 저장 수를 확장한다 2026-06-27 05:49:16 +09:00
f99ed002b2 fix(home): 홈 추천 offset 계산 overflow를 방지한다 2026-06-27 05:12:21 +09:00
c028aa4002 fix(recommendation): 홈 추천 query offset 범위를 확장한다 2026-06-27 05:11:51 +09:00
24e217e8ee fix(recommendation): 추천 snapshot offset 범위를 확장한다 2026-06-27 05:11:22 +09:00
63df1b5777 feat(content): 콘텐츠 전체보기 조회 정책을 추가한다 2026-06-27 05:10:49 +09:00
3c4f852ddb feat(content): 콘텐츠 전체보기 응답 모델을 추가한다 2026-06-27 05:10:37 +09:00
8b24e89465 docs(content): 콘텐츠 전체보기 Phase 1 기록을 갱신한다 2026-06-27 05:10:27 +09:00
c42230e568 docs(content): 콘텐츠 전체보기 API 계획을 추가한다 2026-06-27 03:47:22 +09:00
33 changed files with 2311 additions and 128 deletions

View File

@@ -0,0 +1,94 @@
# 관리자 회원 목록 LazyInitializationException 수정 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` 또는 동등한 TDD 절차로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** `spring.jpa.open-in-view=false` 환경에서 관리자 회원 리스트와 크리에이터 리스트 조회가 `Member.signOutReasons` lazy collection 접근 때문에 실패하지 않게 한다.
**Architecture:** 기존 `AdminMemberService`의 응답 매핑 구조는 유지한다. 서비스 클래스에 read-only 트랜잭션을 기본 적용해 목록 조회와 응답 매핑 전체를 열린 영속성 컨텍스트 안에서 처리한다. 쓰기 메서드는 기존 메서드 레벨 `@Transactional`로 read-only 기본값을 override한다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring Data JPA, QueryDSL, JUnit 5, Gradle Wrapper
---
## 0. 구현 전 확정 사항
- API 응답 스키마는 변경하지 않는다.
- `Member.signOutReasons`를 eager로 바꾸지 않는다.
- OSIV 설정을 켜지 않는다.
- 리포지토리 fetch join이나 projection 전면 개편은 이번 범위에서 제외한다.
- lazy 접근 문제가 확인된 대상 메서드:
- `AdminMemberService.getMemberList(...)`
- `AdminMemberService.searchMember(...)`
- `AdminMemberService.getCreatorList(...)`
- `AdminMemberService.searchCreator(...)`
---
## 1. 파일 구조 계획
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt`
- 클래스 레벨에 `@Transactional(readOnly = true)`를 추가하고, 기존 쓰기 메서드의 `@Transactional`은 유지한다.
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberServiceTest.kt`
- OSIV off 환경에서 탈퇴 이력이 있는 회원/크리에이터 목록 조회가 예외 없이 응답되는지 검증한다.
- Verify: `src/test/resources/application.yml`
- `spring.jpa.open-in-view: false` 테스트 설정을 그대로 사용한다.
---
### Phase 1: LazyInitializationException 재현 테스트
- [x] **Task 1.1: 관리자 회원/크리에이터 목록 실패 테스트 작성**
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberServiceTest.kt`
- RED: `@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://cdn.test"])` 통합 테스트를 추가한다.
- RED: 테스트 클래스에는 `@Transactional`을 붙이지 않아 서비스 호출이 테스트 트랜잭션에 의해 가려지지 않게 한다.
- RED: `MemberRole.USER` 회원과 `MemberRole.CREATOR` 회원을 저장하고, 각각 `SignOut`을 저장한다.
- RED: `service.getMemberList(PageRequest.of(0, 20))`, `service.getCreatorList(PageRequest.of(0, 20))`를 호출해 `signOutDate`가 비어 있지 않고 예외가 발생하지 않기를 기대한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberServiceTest`
- 기대 결과: production code 수정 전에는 `LazyInitializationException`으로 테스트가 실패한다.
- 구현 기록(2026-06-27): `AdminMemberServiceTest`를 추가해 `@Transactional` 없는 테스트 클래스에서 서비스 목록 조회를 호출하도록 했다.
- 1차 RED: `./gradlew test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberServiceTest` 실행 시 Redis 연결 실패로 Spring context 생성이 실패해 의도한 실패가 아니었다.
- 보정: 기존 통합 테스트 패턴에 맞춰 `EmbeddedRedisInitializer`를 추가했다.
- 2차 RED: 같은 명령 재실행 결과 `getMemberList`, `getCreatorList` 모두 `LazyInitializationException`으로 실패해 OSIV off lazy collection 접근 문제를 재현했다.
---
### Phase 2: 서비스 read-only 트랜잭션 보강
- [x] **Task 2.1: 서비스 클래스에 read-only 트랜잭션 기본값 추가**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt`
- GREEN: `AdminMemberService` 클래스에 `@Transactional(readOnly = true)`를 추가한다.
- GREEN: `updateMember`, `resetPassword`의 기존 메서드 레벨 `@Transactional`은 유지해 쓰기 트랜잭션으로 동작하게 한다.
- GREEN: 응답 매핑 로직과 리포지토리 쿼리는 변경하지 않는다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberServiceTest`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: 불필요한 import/format 변경이 생기지 않았는지 확인한다.
- 구현 기록(2026-06-27): 최초 구현에서는 `getMemberList`, `searchMember`, `getCreatorList`, `searchCreator``@Transactional(readOnly = true)`를 추가했다.
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberServiceTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 검증 이유: OSIV off 환경에서 서비스 메서드의 read-only 트랜잭션 안에서 `signOutReasons``auth` lazy 접근이 완료되는지 확인했다.
- 후속 수정(2026-06-27): 리뷰 피드백에 따라 개별 조회 메서드 annotation을 제거하고 `AdminMemberService` 클래스 레벨 `@Transactional(readOnly = true)`로 정리했다. 쓰기 메서드 `updateMember`, `resetPassword`는 기존 메서드 레벨 `@Transactional`을 유지했다.
---
### Phase 3: 회귀 검증과 문서 기록
- [x] **Task 3.1: 관련 검증 실행 및 문서 기록**
- Verify: `./gradlew test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberServiceTest`
- Verify: `./gradlew :app:ktlintCheck`는 단일 루트 프로젝트에 `:app` 모듈이 없으면 실행하지 않고 `./gradlew ktlintCheck`로 대체한다.
- Verify: `./gradlew ktlintCheck`
- Verify: `./gradlew tasks --all`
- 문서 기록: 각 task 아래에 실행 명령, 결과, 검증 이유를 한국어로 누적한다.
- 구현 기록(2026-06-27): 관련 단일 테스트, ktlint, Gradle task 목록 검증을 실행했다.
- 단일 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberServiceTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- ktlint 1차: `./gradlew ktlintCheck``./gradlew tasks --all`과 동시에 실행했을 때 `~/.gradle` wrapper lock 파일 접근 sandbox 오류로 실패했다.
- ktlint 재실행: `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 명령 유효성: `./gradlew --no-daemon tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
---
## 검증 기록
- 2026-06-27: `./gradlew test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberServiceTest`로 OSIV off lazy collection 재현 테스트가 수정 후 통과함을 확인했다.
- 2026-06-27: `./gradlew --no-daemon ktlintCheck`로 Kotlin formatting 검증이 통과함을 확인했다.
- 2026-06-27: `./gradlew --no-daemon tasks --all`로 문서에 안내된 Gradle 명령 목록이 유효함을 확인했다.
- 2026-06-27: 최종 확인으로 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberServiceTest``./gradlew --no-daemon ktlintCheck`를 재실행했고 둘 다 `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-27: 클래스 레벨 `@Transactional(readOnly = true)` 후속 변경 후 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberServiceTest``./gradlew --no-daemon ktlintCheck`를 재실행했고 둘 다 `BUILD SUCCESSFUL`을 확인했다.

View File

@@ -0,0 +1,77 @@
# PRD: 관리자 회원 목록 LazyInitializationException 수정
## 1. Overview
`spring.jpa.open-in-view=false` 환경에서 관리자 회원 리스트와 크리에이터 리스트 조회 시 `Member.signOutReasons` lazy collection 접근으로 발생하는 `LazyInitializationException`을 방지한다.
---
## 2. Problem
- 관리자 회원 목록 응답 생성 중 `Member.signOutReasons`를 읽어 탈퇴일을 계산한다.
- 현재 `AdminMemberService`의 목록 조회 메서드는 트랜잭션 경계가 없어 QueryDSL 조회 후 영속성 컨텍스트가 닫힌 상태에서 lazy collection을 접근할 수 있다.
- `spring.jpa.open-in-view=false` 환경에서는 이 접근이 `org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: kr.co.vividnext.sodalive.member.Member.signOutReasons`로 이어진다.
- 같은 응답 매핑 흐름을 사용하는 관리자 회원 리스트, 회원 검색, 크리에이터 리스트, 크리에이터 검색 모두 같은 위험이 있다.
---
## 3. Goals
- `osiv=false` 환경에서도 관리자 회원 리스트와 크리에이터 리스트가 예외 없이 응답된다.
- 기존 API 응답 스키마와 정렬/필터 조건을 변경하지 않는다.
- 탈퇴 이력이 있는 회원의 `signOutDate` 계산 동작을 유지한다.
- 서비스 계층에 명확한 read-only 트랜잭션 기본 경계를 둔다.
- 실패 재현 테스트를 먼저 작성하고, 최소 수정으로 통과시킨다.
---
## 4. Non-Goals
- 관리자 회원 목록 API의 응답 필드를 변경하지 않는다.
- `Member.signOutReasons` fetch 전략을 전역 eager로 바꾸지 않는다.
- 목록 조회를 projection 전용 쿼리로 전면 개편하지 않는다.
- pagination/count 쿼리 구조를 리팩터링하지 않는다.
- OSIV 설정을 다시 켜지 않는다.
---
## 5. Target Users
- 관리자: 관리자 화면에서 회원 목록과 크리에이터 목록을 조회하는 사용자
- 운영자: 탈퇴 또는 차단 이력이 있는 회원을 포함한 목록을 안정적으로 확인해야 하는 사용자
---
## 6. User Stories
- 관리자는 탈퇴 이력이 있는 일반 회원이 포함된 회원 리스트를 조회해도 서버 오류를 만나지 않아야 한다.
- 관리자는 탈퇴 이력이 있는 크리에이터가 포함된 크리에이터 리스트를 조회해도 서버 오류를 만나지 않아야 한다.
- 관리자는 검색 결과에서도 동일하게 탈퇴일과 활성 상태를 확인할 수 있어야 한다.
---
## 7. Core Features
### Feature A. 관리자 회원 목록 조회 트랜잭션 보강
#### Requirements
- `AdminMemberService`는 클래스 레벨 `@Transactional(readOnly = true)`로 조회 기본 트랜잭션을 제공한다.
- `AdminMemberService.getMemberList(...)`는 read-only 트랜잭션 안에서 조회와 응답 매핑을 완료한다.
- `AdminMemberService.searchMember(...)`는 read-only 트랜잭션 안에서 조회와 응답 매핑을 완료한다.
- `AdminMemberService.getCreatorList(...)`는 read-only 트랜잭션 안에서 조회와 응답 매핑을 완료한다.
- `AdminMemberService.searchCreator(...)`는 read-only 트랜잭션 안에서 조회와 응답 매핑을 완료한다.
- `AdminMemberService.updateMember(...)`, `AdminMemberService.resetPassword(...)`는 메서드 레벨 `@Transactional`로 쓰기 트랜잭션을 유지한다.
- 기존 `processMemberListToGetAdminMemberListResponseItemList(...)`의 응답 필드 계산 방식은 유지한다.
#### Edge Cases
- `signOutReasons`가 비어 있으면 기존처럼 `signOutDate`는 빈 문자열이다.
- `signOutReasons`가 있으면 기존처럼 마지막 탈퇴 이력의 `createdAt`을 KST `yyyy-MM-dd HH:mm` 형식으로 내려준다.
- `auth` lazy one-to-one 접근도 같은 read-only 트랜잭션 안에서 처리되어야 한다.
---
## 8. Technical Constraints
- Kotlin + Spring Boot 2.7.14 + Spring Data JPA 기준으로 구현한다.
- 테스트 환경의 `spring.jpa.open-in-view=false` 설정을 유지한다.
- 서비스 클래스에는 `@Transactional(readOnly = true)`를 사용하고, 쓰기 메서드는 기존 메서드 레벨 `@Transactional`을 유지한다.
- 변경 범위는 `AdminMemberService`와 해당 테스트로 제한한다.
---
## 9. Metrics
- `AdminMemberServiceTest`에서 OSIV off 조건의 회원/크리에이터 목록 조회 테스트가 통과한다.
- 관련 단일 테스트와 `ktlintCheck`가 통과한다.

View File

@@ -0,0 +1,804 @@
# 콘텐츠 전체보기 API Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** `GET /api/v2/contents`로 인증 회원이 `NEW_AND_HOT_AUDIO`, `FIRST_AUDIO_CONTENT` 콘텐츠 전체보기 목록을 동일한 페이징 계약으로 조회할 수 있게 한다.
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.content.overview` 조립 계층에 둔다. New & Hot 조회는 기존 `v2.content.recommendation` 도메인 조회 계층을 확장해 재사용하고, 첫 번째 오디오 콘텐츠 조회는 기존 `v2.recommendation.application.HomeRecommendationQueryService.findFirstAudioContents(...)`를 재사용한다. 기존 홈 하위 전체보기 endpoint는 배포 전 기능이므로 제거하고, 새 콘텐츠 전체보기 API로 책임을 이동한다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Security, Spring Data JPA, QueryDSL/native SQL, Redisson, JUnit 5, MockMvc, Gradle Wrapper
---
## 0. 구현 전 확정 사항
- API endpoint: `GET /api/v2/contents`
- 인증 정책: 비회원 조회 불가. 인증 회원만 호출할 수 있다.
- 응답 wrapper: `ApiResponse.ok(...)`
- 요청 query parameter:
- `type`: `NEW_AND_HOT_AUDIO`, `FIRST_AUDIO_CONTENT`; 기본값 `NEW_AND_HOT_AUDIO`
- `page`: 0부터 시작. 기본값 `0`
- `size`: 기본값 `20`, 최소값보다 작으면 `20`, 최대 `50`
- invalid `type`은 400 오류 대신 `NEW_AND_HOT_AUDIO`로 fallback한다.
- `hasNext``size + 1`개 조회 후 응답 item은 최대 `size`개만 내려주는 방식으로 계산한다.
- `NEW_AND_HOT_AUDIO``AudioRecommendationQueryService`에 페이징 조회 메서드를 추가해 조회한다.
- New & Hot 첫 화면 노출 수는 `12`로 유지한다.
- New & Hot 스냅샷 저장 수는 `SAFE`, `ALL` 각각 `100`으로 확장한다.
- `FIRST_AUDIO_CONTENT``HomeRecommendationQueryService.findFirstAudioContents(...)`를 새 콘텐츠 전체보기 Facade에서 직접 호출한다.
- `GET /api/v2/home/recommendations/first-audio-contents`는 제거한다.
- 신규 DB 테이블과 DDL은 작성하지 않는다. New & Hot 전체보기용 스냅샷은 기존 `recommendation_snapshot` 테이블을 재사용하고, 저장 개수만 visibility별 100개로 확장한다.
---
## 1. 파일 구조 계획
### 신규 API 조립 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacade.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewQueryPolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacadeTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewQueryPolicyTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt`
### 기존 도메인 조회 계층 확장
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.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/recommendation/application/HomeRecommendationQueryServiceTest.kt`
### 미배포 홈 하위 endpoint 제거
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
### 통합 검증
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/RecommendationSnapshotPort.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
---
## 2. 공개 응답 및 정책 초안
`src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt`
```kotlin
package kr.co.vividnext.sodalive.v2.api.content.overview.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeFirstAudioContentRecord
data class ContentOverviewPageResponse(
val type: ContentOverviewType,
val items: List<ContentOverviewItemResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
)
enum class ContentOverviewType {
NEW_AND_HOT_AUDIO,
FIRST_AUDIO_CONTENT;
companion object {
fun from(value: String?): ContentOverviewType {
return values().firstOrNull { it.name == value } ?: NEW_AND_HOT_AUDIO
}
}
}
data class ContentOverviewItemResponse(
val contentId: Long,
val title: String,
val coverImage: String?,
val price: Int,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
val creatorNickname: String,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean
) {
companion object {
fun fromNewAndHot(audio: AudioCard): ContentOverviewItemResponse {
return ContentOverviewItemResponse(
contentId = audio.audioContentId,
title = audio.title,
coverImage = audio.imageUrl,
price = audio.price,
isPointAvailable = audio.isPointAvailable,
creatorNickname = audio.creatorNickname,
isAdult = audio.isAdult,
isFirstContent = audio.isFirstContent,
isOriginalSeries = audio.isOriginalSeries
)
}
fun fromFirstAudioContent(
audio: HomeFirstAudioContentRecord,
coverImage: String?
): ContentOverviewItemResponse {
return ContentOverviewItemResponse(
contentId = audio.contentId,
title = audio.title,
coverImage = coverImage,
price = audio.price,
isPointAvailable = audio.isPointAvailable,
creatorNickname = audio.creatorNickname,
isAdult = audio.isAdult,
isFirstContent = true,
isOriginalSeries = audio.isOriginalSeries
)
}
}
}
```
`src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewQueryPolicy.kt`
```kotlin
package kr.co.vividnext.sodalive.v2.api.content.overview.application
import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewType
data class ContentOverviewPage(
val page: Int,
val size: Int
) {
val offset: Long = page.toLong() * size
}
class ContentOverviewQueryPolicy {
fun resolveType(type: String?): ContentOverviewType {
return ContentOverviewType.from(type)
}
fun createPage(page: Int?, size: Int?): ContentOverviewPage {
val resolvedPage = (page ?: DEFAULT_PAGE).coerceAtLeast(DEFAULT_PAGE)
val requestedSize = size ?: DEFAULT_SIZE
val resolvedSize = if (requestedSize < 1) DEFAULT_SIZE else minOf(requestedSize, MAX_SIZE)
return ContentOverviewPage(page = resolvedPage, size = resolvedSize)
}
fun <T> pageItems(items: List<T>, page: ContentOverviewPage): List<T> {
return items.take(page.size)
}
fun <T> hasNext(items: List<T>, page: ContentOverviewPage): Boolean {
return items.size > page.size
}
companion object {
const val DEFAULT_PAGE = 0
const val DEFAULT_SIZE = 20
const val MAX_SIZE = 50
}
}
```
---
## 3. 테스트 helper 기준
아래 helper는 각 테스트 파일에서 필요한 범위만 복사해 사용한다. Kotlin 1.6.21을 사용하므로 enum 변환 구현에는 `entries`가 아니라 `values()`를 사용한다.
```kotlin
private fun member(id: Long): Member {
return Member(
email = "viewer$id@test.com",
password = "password",
nickname = "viewer$id",
role = MemberRole.USER
).apply {
this.id = id
}
}
private fun audioCard(id: Long): AudioCard {
return AudioCard(
audioContentId = id,
title = "audio$id",
duration = "00:01",
imageUrl = "https://cdn.test/audio$id.png",
price = id.toInt(),
isAdult = false,
isPointAvailable = true,
isFirstContent = true,
isOriginalSeries = false,
creatorNickname = "creator$id"
)
}
private fun firstAudio(id: Long): HomeFirstAudioContentRecord {
return HomeFirstAudioContentRecord(
contentId = id,
creatorId = id + 100,
creatorNickname = "creator$id",
creatorProfileImage = null,
title = "first audio$id",
price = id.toInt(),
coverImage = "cover/audio$id.png",
isPointAvailable = true,
isAdult = false,
isOriginalSeries = false
)
}
private fun snapshot(
sectionType: RecommendedSectionType,
targetId: Long,
score: Double = 100.0 - targetId,
snapshotAt: LocalDateTime = LocalDateTime.of(2026, 6, 26, 23, 59, 59)
): RecommendationSnapshotRecord {
return RecommendationSnapshotRecord(
sectionType = sectionType,
targetId = targetId,
score = score,
snapshotAt = snapshotAt,
randomTieBreaker = targetId.toDouble() / 1000
)
}
private fun anyLocalDateTime(): LocalDateTime {
return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.of(2026, 6, 27, 0, 0)
}
private fun emptyResponse(type: ContentOverviewType): ContentOverviewPageResponse {
return ContentOverviewPageResponse(
type = type,
items = emptyList(),
page = 0,
size = 20,
hasNext = false
)
}
```
---
### Phase 1: 콘텐츠 전체보기 응답/요청 정책 작성
- [x] **Task 1.1: ContentOverview DTO 직렬화 테스트 작성**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt`
- RED: `ContentOverviewPageResponse``ContentOverviewItemResponse``JsonProperty` 필드명을 검증하는 실패 테스트를 작성한다.
- 테스트 코드 기준:
```kotlin
class ContentOverviewPageResponseTest {
private val objectMapper = jacksonObjectMapper()
@Test
fun shouldSerializeContentOverviewPageResponse() {
val response = ContentOverviewPageResponse(
type = ContentOverviewType.NEW_AND_HOT_AUDIO,
items = listOf(
ContentOverviewItemResponse(
contentId = 1L,
title = "audio",
coverImage = "https://cdn.test/audio.png",
price = 10,
isPointAvailable = true,
creatorNickname = "creator",
isAdult = false,
isFirstContent = true,
isOriginalSeries = false
)
),
page = 0,
size = 20,
hasNext = true
)
val json = objectMapper.readTree(objectMapper.writeValueAsString(response))
assertEquals("NEW_AND_HOT_AUDIO", json["type"].asText())
assertEquals(true, json["hasNext"].asBoolean())
assertEquals(1L, json["items"][0]["contentId"].asLong())
assertEquals("https://cdn.test/audio.png", json["items"][0]["coverImage"].asText())
assertEquals(true, json["items"][0]["isPointAvailable"].asBoolean())
assertEquals(false, json["items"][0]["isAdult"].asBoolean())
assertEquals(true, json["items"][0]["isFirstContent"].asBoolean())
assertEquals(false, json["items"][0]["isOriginalSeries"].asBoolean())
assertEquals(false, json["items"][0].has("audioContentId"))
assertEquals(false, json["items"][0].has("imageUrl"))
assertEquals(false, json["items"][0].has("duration"))
assertEquals(false, json["items"][0].has("creatorId"))
assertEquals(false, json["items"][0].has("creatorProfileImage"))
assertEquals(false, json["items"][0].has("pointAvailable"))
assertEquals(false, json["items"][0].has("adult"))
assertEquals(false, json["items"][0].has("firstContent"))
assertEquals(false, json["items"][0].has("originalSeries"))
}
}
```
- 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponseTest`
- 기대 결과: DTO 파일이 없어서 `compileTestKotlin` 실패.
- GREEN: 위 DTO 초안을 추가하고 테스트를 통과시킨다.
- 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponseTest`
- REFACTOR: import 정리 후 같은 테스트를 재실행한다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponseTest` 실행 시 `ContentOverviewPageResponse`, `ContentOverviewType`, `ContentOverviewItemResponse` 미구현으로 `compileTestKotlin` 실패.
- GREEN: DTO 구현 후 같은 명령 재실행, `BUILD SUCCESSFUL`.
- REVIEW 보완: `fromFirstAudioContent(...)`가 성인/오리지널 플래그를 전달하는 테스트를 추가했다. 보완 RED는 `isAdult`, `isOriginalSeries` 파라미터 미존재로 `compileTestKotlin` 실패했고, 시그니처 보강 후 같은 DTO 테스트가 `BUILD SUCCESSFUL`.
- [x] **Task 1.2: ContentOverviewQueryPolicy 테스트와 구현 작성**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewQueryPolicyTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewQueryPolicy.kt`
- RED: type/page/size 보정 정책 실패 테스트를 작성한다.
- 테스트 코드 기준:
```kotlin
class ContentOverviewQueryPolicyTest {
private val policy = ContentOverviewQueryPolicy()
@Test
fun shouldResolveTypeWithDefaultFallback() {
assertEquals(ContentOverviewType.NEW_AND_HOT_AUDIO, policy.resolveType(null))
assertEquals(ContentOverviewType.NEW_AND_HOT_AUDIO, policy.resolveType("UNKNOWN"))
assertEquals(ContentOverviewType.FIRST_AUDIO_CONTENT, policy.resolveType("FIRST_AUDIO_CONTENT"))
}
@Test
fun shouldNormalizePageAndSize() {
assertEquals(ContentOverviewPage(page = 0, size = 20), policy.createPage(null, null))
assertEquals(ContentOverviewPage(page = 0, size = 20), policy.createPage(-1, 0))
assertEquals(ContentOverviewPage(page = 2, size = 50), policy.createPage(2, 100))
}
@Test
fun shouldCalculatePageItemsAndHasNext() {
val page = ContentOverviewPage(page = 0, size = 2)
val items = listOf(1, 2, 3)
assertEquals(listOf(1, 2), policy.pageItems(items, page))
assertEquals(true, policy.hasNext(items, page))
}
}
```
- 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewQueryPolicyTest`
- 기대 결과: policy 파일이 없어서 `compileTestKotlin` 실패.
- GREEN: 위 policy 초안을 추가하고 테스트를 통과시킨다.
- 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewQueryPolicyTest`
- REFACTOR: `ContentOverviewType.from(...)`와 page 보정 로직이 DTO/Facade에 중복되지 않게 유지하고 같은 테스트를 재실행한다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewQueryPolicyTest` 실행 시 `ContentOverviewQueryPolicy`, `ContentOverviewPage` 미구현으로 `compileTestKotlin` 실패.
- GREEN: policy 구현 후 같은 명령 재실행, `BUILD SUCCESSFUL`.
- REVIEW 보완: `size = 19`가 기본 size `20`으로 보정되는 테스트를 추가하고, `MIN_SIZE = 20` 정책을 반영했다. 보완 후 같은 policy 테스트가 `BUILD SUCCESSFUL`.
- REVIEW 보완: 큰 `page` 입력에서 `offset`이 Int overflow 되지 않도록 `offset: Long = page.toLong() * size`로 변경했다. 보완 RED는 `Int.MAX_VALUE, size = 50` offset assertion 실패였고, 수정 후 같은 policy 테스트가 `BUILD SUCCESSFUL`.
- REVIEW 보완: 후속 Phase에서 `ContentOverviewPage.offset`을 그대로 넘길 수 있도록 `RecommendationSnapshotPort`, `HomeRecommendationQueryPort`, 관련 service/adapter/repository offset 계약과 문서 예시를 `Long`으로 정렬했다.
- Phase 1 묶음: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.*'` 실행, `BUILD SUCCESSFUL`.
- Lint: `./gradlew ktlintCheck` 실행, `BUILD SUCCESSFUL`.
- 참고: `./gradlew test` 전체 실행은 다수 테스트의 XML 결과 파일 write 실패로 중단되어 Phase 1 로직 실패로 보지 않는다.
---
### Phase 2: New & Hot 스냅샷 저장 수와 페이징 조회 분리
- [x] **Task 2.1: New & Hot 스냅샷 저장 limit 100 테스트 작성**
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt`
- RED: `refreshDailySnapshots(now)`가 New & Hot 후보 조회 시 `limit = 100`을 전달하는 실패 테스트를 추가한다.
- 테스트 코드 기준:
```kotlin
@Test
@DisplayName("New & Hot 스냅샷은 visibility별 100개 후보를 저장한다")
fun shouldRequestOneHundredNewAndHotSnapshotsPerVisibility() {
val snapshotPort = FakeRecommendationSnapshotPort()
val queryPort = Mockito.mock(AudioRecommendationQueryPort::class.java)
val service = AudioRecommendationSnapshotRefreshService(snapshotPort, queryPort)
val now = LocalDateTime.of(2026, 6, 27, 0, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 6, 26, 23, 59, 59)
val windowStart = LocalDateTime.of(2026, 6, 24, 0, 0, 0)
service.refreshDailySnapshots(now)
Mockito.verify(queryPort).findNewAndHotSnapshots(windowStart, snapshotAt, AudioRecommendationVisibility.SAFE, 100)
Mockito.verify(queryPort).findNewAndHotSnapshots(windowStart, snapshotAt, AudioRecommendationVisibility.ALL, 100)
}
```
- 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest`
- 기대 결과: 현재 구현이 `NEW_AND_HOT_LIMIT = 12`를 사용하므로 verify가 실패.
- GREEN: `AudioRecommendationSnapshotRefreshService`에서 `NEW_AND_HOT_SNAPSHOT_LIMIT = 100`을 추가하고 New & Hot 저장 조회에 사용한다.
- 구현 기준:
```kotlin
companion object {
const val NEW_AND_HOT_SNAPSHOT_LIMIT = 100
const val MOST_COMMENTED_LIMIT = 5
const val RECOMMENDED_AUDIO_LIMIT = 10
private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul")
}
```
- 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest`
- REFACTOR: 기존 `NEW_AND_HOT_LIMIT` 이름이 남아 있으면 저장 limit 의미가 드러나는 `NEW_AND_HOT_SNAPSHOT_LIMIT`으로 정리하고 같은 테스트를 재실행한다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest` 실행 시 `shouldRequestOneHundredNewAndHotSnapshotsPerVisibility`가 기존 `limit = 12` 호출과 기대 `100` 차이로 `ArgumentsAreDifferent` 실패.
- GREEN: `NEW_AND_HOT_SNAPSHOT_LIMIT = 100`으로 저장 조회 limit을 분리한 뒤 같은 명령 재실행, `BUILD SUCCESSFUL`.
- [x] **Task 2.2: AudioRecommendationQueryService의 첫 화면 12개 조회 회귀 테스트 작성**
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
- RED: `getRecommendations(member)`는 New & Hot 첫 화면 조회 시 여전히 12개만 요청하는 회귀 테스트를 추가한다.
- 테스트 코드 기준:
```kotlin
@Test
@DisplayName("추천 탭 첫 화면은 New & Hot 스냅샷을 12개만 조회한다")
fun shouldKeepNewAndHotHomeLimitAtTwelve() {
val member = member(id = 10L)
Mockito.doReturn(true).`when`(memberContentPreferenceService).canViewAdultContent(member)
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>()).`when`(snapshotPort)
.findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, limit = 12)
queryService.getRecommendations(member)
Mockito.verify(snapshotPort).findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, limit = 12)
}
```
- 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest`
- 기대 결과: 상수명을 아직 분리하지 않았거나 test helper가 없으면 컴파일 또는 verify 실패.
- GREEN: `AudioRecommendationQueryService`에 `NEW_AND_HOT_HOME_LIMIT = 12`를 추가하고 첫 화면 조회와 lazy refresh 재조회에 사용한다.
- 구현 기준:
```kotlin
companion object {
const val NEW_AND_HOT_HOME_LIMIT = 12
// 기존 NEW_AND_HOT_AUDIO_LIMIT 사용처는 첫 화면 의미이면 NEW_AND_HOT_HOME_LIMIT로 교체한다.
}
```
- 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest`
- REFACTOR: 첫 화면 limit과 스냅샷 저장 limit 이름이 섞이지 않게 import/상수명을 정리하고 같은 테스트를 재실행한다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest` 실행 시 `NEW_AND_HOT_HOME_LIMIT` 미구현으로 `compileTestKotlin` 실패.
- GREEN: `NEW_AND_HOT_HOME_LIMIT = 12`를 추가하고 홈 첫 화면 조회와 lazy refresh 재조회에서 사용하도록 정리한 뒤 같은 명령 재실행, `BUILD SUCCESSFUL`.
- [x] **Task 2.3: New & Hot 전체보기 페이징 조회 테스트 작성**
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
- RED: `findNewAndHotAudios(member, offset, limit)`가 visibility, offset, limit을 반영하고 상세 조회 순서를 유지하는 실패 테스트를 작성한다.
- 테스트 코드 기준:
```kotlin
@Test
@DisplayName("New & Hot 전체보기는 스냅샷 offset과 limit으로 오디오 카드를 조회한다")
fun shouldFindNewAndHotAudiosWithOffsetAndLimit() {
val member = member(id = 10L)
val nowSnapshots = listOf(
snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, targetId = 3L),
snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, targetId = 4L),
snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, targetId = 5L)
)
Mockito.doReturn(true).`when`(memberContentPreferenceService).canViewAdultContent(member)
Mockito.doReturn(nowSnapshots).`when`(snapshotPort)
.findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, offset = 20L, limit = 21)
Mockito.doReturn(listOf(audioCard(3L), audioCard(4L), audioCard(5L))).`when`(queryPort)
.findAudioCardsByIds(listOf(3L, 4L, 5L), member.id, true, anyLocalDateTime())
val result = queryService.findNewAndHotAudios(member, offset = 20L, limit = 21)
assertEquals(listOf(3L, 4L, 5L), result.map { it.audioContentId })
Mockito.verify(snapshotPort).findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, offset = 20L, limit = 21)
}
```
- 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest`
- 기대 결과: `findNewAndHotAudios` 메서드가 없어 `compileTestKotlin` 실패.
- GREEN: `AudioRecommendationQueryService.findNewAndHotAudios(member, offset, limit)`를 추가한다.
- 구현 기준:
```kotlin
fun findNewAndHotAudios(member: Member, offset: Long, limit: Int): List<AudioCard> {
val now = LocalDateTime.now()
val canViewAdultContent = canViewAdultContent(member)
val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE
val sectionType = newAndHotSectionType(visibility)
val snapshots = findNewAndHotSnapshotsWithLazyRefresh(sectionType, offset, limit)
return queryPort.findAudioCardsByIds(
snapshots.map { it.targetId },
member.id,
canViewAdultContent,
now
)
}
```
- 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest`
- REFACTOR: 기존 `refreshMissingNewAndHotSnapshots(...)`는 첫 화면과 전체보기에서 공통 사용 가능한 private 함수로 정리하고 같은 테스트를 재실행한다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest` 실행 시 `findNewAndHotAudios` 미구현으로 `compileTestKotlin` 실패.
- GREEN: `findNewAndHotAudios(member, offset, limit)`를 추가하고 기존 lazy refresh 재조회가 동일 `offset`, `limit`을 사용하도록 보강한 뒤 같은 명령 재실행, `BUILD SUCCESSFUL`.
---
### Phase 3: 콘텐츠 전체보기 API 조립 계층 작성
- [x] **Task 3.1: ContentOverviewFacade 테스트 작성**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacadeTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacade.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- RED: `NEW_AND_HOT_AUDIO`와 `FIRST_AUDIO_CONTENT`를 각각 조회해 `ContentOverviewPageResponse`로 변환하는 실패 테스트를 작성한다.
- 테스트 코드 기준:
```kotlin
class ContentOverviewFacadeTest {
private val audioRecommendationQueryService = Mockito.mock(AudioRecommendationQueryService::class.java)
private val homeRecommendationQueryService = Mockito.mock(HomeRecommendationQueryService::class.java)
private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
private val facade = ContentOverviewFacade(
audioRecommendationQueryService = audioRecommendationQueryService,
homeRecommendationQueryService = homeRecommendationQueryService,
memberContentPreferenceService = memberContentPreferenceService,
cloudFrontHost = "https://cdn.test",
queryPolicy = ContentOverviewQueryPolicy()
)
@Test
fun shouldReturnNewAndHotPage() {
val member = member(id = 10L)
Mockito.doReturn(listOf(audioCard(1L), audioCard(2L), audioCard(3L))).`when`(audioRecommendationQueryService)
.findNewAndHotAudios(member, offset = 0L, limit = 3)
val response = facade.getContents("NEW_AND_HOT_AUDIO", page = 0, size = 2, member = member)
assertEquals(ContentOverviewType.NEW_AND_HOT_AUDIO, response.type)
assertEquals(listOf(1L, 2L), response.items.map { it.contentId })
assertEquals(listOf("https://cdn.test/audio1.png", "https://cdn.test/audio2.png"), response.items.map { it.coverImage })
assertEquals(true, response.hasNext)
}
@Test
fun shouldReturnFirstAudioContentPage() {
val member = member(id = 10L)
Mockito.doReturn(true).`when`(memberContentPreferenceService).canViewAdultContent(member)
Mockito.doReturn(listOf(firstAudio(1L), firstAudio(2L))).`when`(homeRecommendationQueryService)
.findFirstAudioContents(anyLocalDateTime(), offset = 20L, limit = 21, memberId = member.id, includeAdultContents = true)
val response = facade.getContents("FIRST_AUDIO_CONTENT", page = 1, size = 20, member = member)
assertEquals(ContentOverviewType.FIRST_AUDIO_CONTENT, response.type)
assertEquals(listOf(1L, 2L), response.items.map { it.contentId })
assertEquals("https://cdn.test/cover/audio1.png", response.items[0].coverImage)
assertEquals(true, response.items[0].isFirstContent)
}
}
```
- 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewFacadeTest`
- 기대 결과: Facade 파일이 없어 `compileTestKotlin` 실패.
- GREEN: `ContentOverviewFacade`를 추가하고 `size + 1` 조회, item `take(size)`, `hasNext` 계산을 구현한다.
- GREEN: `HomeFirstAudioContentRecord`에 `isAdult: Boolean`, `isOriginalSeries: Boolean` 필드를 추가하고, `DefaultHomeRecommendationQueryRepository.findFirstAudioContents(...)`가 해당 값을 조회해 채우도록 보강한다.
- 구현 기준:
```kotlin
fun getContents(type: String?, page: Int?, size: Int?, member: Member): ContentOverviewPageResponse {
val resolvedType = queryPolicy.resolveType(type)
val resolvedPage = queryPolicy.createPage(page, size)
return when (resolvedType) {
ContentOverviewType.NEW_AND_HOT_AUDIO -> getNewAndHotContents(member, resolvedPage)
ContentOverviewType.FIRST_AUDIO_CONTENT -> getFirstAudioContents(member, resolvedPage)
}
}
```
- 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewFacadeTest`
- REFACTOR: `coverImage` CDN URL 변환은 `String?.toCdnUrl(cloudFrontHost)`를 사용하고, 타입별 전용 필드 없이 `ContentOverviewItemResponse`의 동일 필드만 채우는지 확인한 뒤 같은 테스트를 재실행한다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewFacadeTest` 실행 시 `ContentOverviewFacade` 미구현 및 `HomeFirstAudioContentRecord`의 `isAdult`, `isOriginalSeries` 필드 미구현으로 `compileTestKotlin` 실패.
- GREEN: `ContentOverviewFacade` 추가, `HomeFirstAudioContentRecord` 플래그 확장, `DefaultHomeRecommendationQueryRepository.findFirstAudioContents(...)` 플래그 조회 보강 후 같은 명령 재실행, `BUILD SUCCESSFUL`.
- REFACTOR: Kotlin Mockito matcher 보정과 Phase 1의 `MIN_SIZE = 20` 정책에 맞춰 테스트 기대값을 정렬했고, `String?.toCdnUrl(cloudFrontHost)`로 coverImage CDN 변환을 유지했다.
- REVIEW 보완: `findFirstAudioContents(...)` native SQL의 오리지널 시리즈 subquery가 실제 `SeriesContent`/`Series` 테이블(`series_content`, `series`)과 FK(`series_id`)를 참조하는지 검증하는 repository 테스트를 추가했다. 보완 RED는 존재하지 않는 `content_series_content` 테이블 참조로 `SQLGrammarException` 실패였고, 테이블/FK명을 실제 스키마에 맞춘 뒤 `DefaultHomeRecommendationQueryRepositoryTest`가 `BUILD SUCCESSFUL`.
- [x] **Task 3.2: ContentOverviewController 인증/파라미터 테스트 작성**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewControllerTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewController.kt`
- RED: 비회원 요청은 401, 인증 회원 요청은 facade에 type/page/size/member를 전달하는 실패 테스트를 작성한다.
- 테스트 코드 기준:
```kotlin
@WebMvcTest(ContentOverviewController::class)
@Import(SecurityConfig::class)
class ContentOverviewControllerTest @Autowired constructor(
private val mockMvc: MockMvc
) {
@MockBean
private lateinit var facade: ContentOverviewFacade
@Test
fun shouldRejectAnonymousRequest() {
mockMvc.perform(get("/api/v2/contents"))
.andExpect(status().isUnauthorized)
}
@Test
fun shouldPassAuthenticatedMemberAndQueryParameters() {
val member = member(id = 10L)
Mockito.doReturn(emptyResponse(ContentOverviewType.FIRST_AUDIO_CONTENT)).`when`(facade)
.getContents("FIRST_AUDIO_CONTENT", 1, 30, member)
mockMvc.perform(
get("/api/v2/contents")
.param("type", "FIRST_AUDIO_CONTENT")
.param("page", "1")
.param("size", "30")
.with(user(MemberAdapter(member)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.type").value("FIRST_AUDIO_CONTENT"))
Mockito.verify(facade).getContents("FIRST_AUDIO_CONTENT", 1, 30, member)
}
}
```
- 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewControllerTest`
- 기대 결과: Controller 파일이 없어 `compileTestKotlin` 실패.
- GREEN: `ContentOverviewController`를 추가한다.
- 구현 기준:
```kotlin
@RestController
@RequestMapping("/api/v2/contents")
class ContentOverviewController(
private val facade: ContentOverviewFacade
) {
@GetMapping
fun getContents(
@RequestParam(required = false) type: String?,
@RequestParam(required = false) page: Int?,
@RequestParam(required = false) size: Int?,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(facade.getContents(type, page, size, requireMember(member)))
}
private fun requireMember(member: Member?): Member {
return member ?: throw SodaException(messageKey = "common.error.bad_credentials")
}
}
```
- 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewControllerTest`
- REFACTOR: `SecurityConfig`에 `/api/v2/contents` permitAll을 추가하지 않았는지 확인하고 같은 테스트를 재실행한다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewControllerTest` 실행 시 `ContentOverviewController` 미구현으로 `compileTestKotlin` 실패.
- GREEN: `ContentOverviewController` 추가 후 인증 회원 query parameter 전달 테스트 통과. 비회원 401 검증은 slice test에서 실제 `JwtAuthenticationEntryPoint`, `JwtAccessDeniedHandler`를 import하고 `.with(anonymous())`를 명시하도록 보정한 뒤 같은 명령 재실행, `BUILD SUCCESSFUL`.
- REFACTOR: `SecurityConfig`에 `/api/v2/contents` `permitAll`을 추가하지 않았음을 확인했다. `/api/v2/contents`는 기존 `anyRequest().authenticated()` 정책으로 인증 필수다.
- Phase 3 묶음: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.*'` 실행, `BUILD SUCCESSFUL`.
- 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest` 실행, `BUILD SUCCESSFUL`.
- 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` 실행, `BUILD SUCCESSFUL`.
- Lint: `./gradlew ktlintCheck` 실행, `BUILD SUCCESSFUL`.
- 코드 리뷰: `ContentOverviewFacade`, `ContentOverviewController`, `HomeFirstAudioContentRecord` 확장, `DefaultHomeRecommendationQueryRepository.findFirstAudioContents(...)` 변경을 Phase 3 요구사항과 대조했고 차단 이슈는 발견하지 않았다.
- 리뷰 검증: `git diff --check` 실행, 공백 오류 없음.
- 리뷰 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.*'` 재실행, `BUILD SUCCESSFUL`.
- 리뷰 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest` 재실행, `BUILD SUCCESSFUL`.
- 리뷰 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` 재실행, `BUILD SUCCESSFUL`.
- 리뷰 wiring: `./gradlew test --tests kr.co.vividnext.sodalive.support.SpringBootIntegrationSampleTest` 실행, `BUILD SUCCESSFUL`.
- 리뷰 Lint: `./gradlew ktlintCheck` 재실행, `BUILD SUCCESSFUL`. 최초 sandbox 실행은 Gradle wrapper lock 파일 접근 권한 문제로 중단되어 sandbox 밖에서 재실행했다.
---
### Phase 4: 미배포 홈 하위 전체보기 endpoint 제거
- [x] **Task 4.1: 홈 하위 first-audio-contents 제거 테스트 갱신**
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
- RED: `/api/v2/home/recommendations/first-audio-contents`가 더 이상 성공 endpoint가 아님을 확인하는 테스트로 갱신한다.
- 테스트 코드 기준:
```kotlin
@Test
@DisplayName("미배포 first-audio-contents 홈 하위 endpoint는 제거된다")
fun shouldNotExposeDeprecatedFirstAudioContentsEndpoint() {
val member = saveMember("home-viewer", MemberRole.USER)
entityManager.flush()
entityManager.clear()
mockMvc.perform(
get("/api/v2/home/recommendations/first-audio-contents")
.with(user(MemberAdapter(member)))
)
.andExpect(status().isNotFound)
}
```
- 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
- 기대 결과: 기존 endpoint가 남아 있으면 200 OK로 응답해 테스트 실패.
- GREEN: `HomeRecommendationController.getFirstAudioContents(...)`를 제거하고, `HomeRecommendationFacade.getFirstAudioContents(...)`와 관련 로그 section 처리만 제거한다.
- 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
- REFACTOR: `HomeRecommendationQueryService.findFirstAudioContents(...)`는 새 API에서 재사용하므로 제거하지 않았는지 확인하고 같은 테스트를 재실행한다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest` 실행 시 `shouldNotExposeDeprecatedFirstAudioContentsEndpoint`가 기존 endpoint 200 응답으로 실패.
- GREEN: `HomeRecommendationController.getFirstAudioContents(...)`와 `HomeRecommendationFacade.getFirstAudioContents(...)` 제거 후 같은 명령 재실행, `BUILD SUCCESSFUL`.
- REFACTOR: `rg -n "findFirstAudioContents|firstAudioContents|HOME_FIRST_AUDIO_CONTENT_LIMIT" ...`로 홈 메인 `firstAudioContents`, `HOME_FIRST_AUDIO_CONTENT_LIMIT`, `HomeRecommendationQueryService.findFirstAudioContents(...)`, 새 `ContentOverviewFacade` 재사용 경로가 유지됨을 확인했다.
- [x] **Task 4.2: 홈 전체보기 인증 경로 목록 회귀 테스트 정리**
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
- RED: 기존 테스트의 경로 목록에서 `/first-audio-contents`를 제거하고 `/lives`, `/debut-creators`, `/ai-characters`만 홈 하위 전체보기 endpoint로 검증하도록 갱신한다.
- 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
- 기대 결과: controller 제거와 테스트 기대값이 어긋나면 실패.
- GREEN: 홈 추천 controller 테스트에서 first-audio-contents 성공 응답, facade 실패 로그 검증, 경로 반복 목록을 모두 새 정책에 맞춰 정리한다.
- 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
- REFACTOR: 홈 추천 첫 화면의 `firstAudioContents` 필드와 `HOME_FIRST_AUDIO_CONTENT_LIMIT`는 유지되어야 하므로 삭제하지 않았는지 확인한다.
- 검증 기록:
- 테스트 정리: `HomeRecommendationControllerTest`의 성공 응답 반복 경로와 비회원 거부 반복 경로에서 `/first-audio-contents`를 제거하고, facade page failure 로그 검증에서 `FIRST_AUDIO_CONTENT` section 검증을 제거했다.
- 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewFacadeTest` 실행, `BUILD SUCCESSFUL`.
- 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest` 실행, `BUILD SUCCESSFUL`.
- Lint: `./gradlew ktlintCheck` 실행, `BUILD SUCCESSFUL`.
- 코드 리뷰: `HomeRecommendationController`, `HomeRecommendationFacade`, `HomeRecommendationControllerTest` 변경을 Phase 4 요구사항과 대조했고 차단 이슈는 발견하지 않았다.
- 리뷰 확인: `rg -n "first-audio-contents|getFirstAudioContents|FIRST_AUDIO_CONTENT|findFirstAudioContents|HOME_FIRST_AUDIO_CONTENT_LIMIT|firstAudioContents" ...` 실행으로 제거 endpoint는 문서와 404 테스트에만 남고, 홈 메인 `firstAudioContents`와 새 콘텐츠 전체보기의 `findFirstAudioContents(...)` 재사용 경로가 유지됨을 확인했다.
- 리뷰 검증: `git diff --check` 실행, 공백 오류 없음.
- 리뷰 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest` 재실행, `BUILD SUCCESSFUL`.
- 리뷰 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewFacadeTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest` 실행, `BUILD SUCCESSFUL`.
- 리뷰 Lint: `./gradlew ktlintCheck` 재실행, `BUILD SUCCESSFUL`. 최초 sandbox 실행은 Gradle wrapper lock 파일 접근 권한 문제로 중단되어 sandbox 밖에서 재실행했다.
---
### Phase 5: End-to-End 검증
- [x] **Task 5.1: 콘텐츠 전체보기 E2E 테스트 작성**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt`
- RED: 인증 회원 기준 `NEW_AND_HOT_AUDIO`, `FIRST_AUDIO_CONTENT`가 `ApiResponse.ok`와 `items/page/size/hasNext`를 반환하는 E2E 실패 테스트를 작성한다.
- 테스트 범위:
- 비회원 `GET /api/v2/contents`는 401
- 인증 회원 `GET /api/v2/contents?type=NEW_AND_HOT_AUDIO`는 200, `data.type = NEW_AND_HOT_AUDIO`
- 인증 회원 `GET /api/v2/contents?type=FIRST_AUDIO_CONTENT`는 200, `data.type = FIRST_AUDIO_CONTENT`
- invalid type은 `NEW_AND_HOT_AUDIO`로 fallback
- 실패 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewEndToEndTest`
- 기대 결과: API 구현 전에는 endpoint 미존재 또는 bean 미구성으로 실패.
- GREEN: Phase 1~4 구현을 통합해 E2E 테스트를 통과시킨다.
- 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewEndToEndTest`
- REFACTOR: 테스트 데이터가 다른 추천 테스트와 충돌하지 않도록 각 테스트에서 저장한 데이터만 사용하고, 같은 테스트를 재실행한다.
- 검증 기록:
- E2E 테스트 작성: `ContentOverviewEndToEndTest`를 추가해 비회원 401, 인증 회원 `NEW_AND_HOT_AUDIO` 200, 인증 회원 `FIRST_AUDIO_CONTENT` 200, invalid type의 `NEW_AND_HOT_AUDIO` fallback을 검증했다.
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewEndToEndTest` 실행, `BUILD SUCCESSFUL`.
- [x] **Task 5.2: 전체 관련 테스트와 ktlint 검증**
- Files:
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/**`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/**`
- RED: 신규/수정 테스트를 한 번에 실행해 남은 컴파일 오류나 회귀를 확인한다.
- 실행 명령:
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.*'`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
- 기대 결과: 모든 명령 `BUILD SUCCESSFUL`.
- GREEN: 실패가 있으면 해당 task 문서 체크박스를 되돌리고 RED/GREEN 단계로 돌아가 수정한다.
- REFACTOR: `./gradlew ktlintCheck`를 실행하고 `BUILD SUCCESSFUL`을 확인한다.
- 검증 기록:
- 관련 테스트 묶음: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.*'` 실행, `BUILD SUCCESSFUL`.
- 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest` 실행, `BUILD SUCCESSFUL`.
- 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest` 실행, `BUILD SUCCESSFUL`.
- Lint: `./gradlew ktlintCheck` 실행, `BUILD SUCCESSFUL`.
---
## 검증 기록
- 구현 전 문서 생성 단계에서는 코드 변경이 없으므로 단위 테스트를 실행하지 않는다.
- 문서 변경 후 명령 유효성은 `./gradlew tasks --all`로 확인한다.
- 구현 중 각 task 완료 즉시 해당 task 아래에 실행 명령, 결과, 실패 원인과 수정 내용을 한국어로 누적 기록한다.
- Phase 3 코드 리뷰 및 검증 기록 추가 후 `git diff --check` 실행, 공백 오류 없음.
- Phase 3 코드 리뷰 및 검증 기록 추가 후 `./gradlew tasks --all` 실행, `BUILD SUCCESSFUL`. 최초 sandbox 실행은 Gradle wrapper lock 파일 접근 권한 문제로 중단되어 sandbox 밖에서 재실행했다.
---
## Self-Review Checklist
- PRD의 endpoint `GET /api/v2/contents`는 Phase 3과 Phase 5에서 구현/검증한다.
- 비회원 조회 불가는 Phase 3 controller 테스트와 Phase 5 E2E 테스트에서 검증한다.
- `NEW_AND_HOT_AUDIO` 스냅샷 저장 수 100개는 Phase 2에서 검증한다.
- New & Hot 첫 화면 12개 유지 회귀는 Phase 2에서 검증한다.
- `FIRST_AUDIO_CONTENT` 조회 재사용은 Phase 3 Facade 테스트와 Phase 5 E2E 테스트에서 검증한다.
- 미배포 홈 하위 endpoint 제거는 Phase 4에서 검증한다.
- 신규 DB 테이블이 없다는 제약은 파일 구조 계획과 Phase 5 검증 범위에 반영했다.

View File

@@ -0,0 +1,242 @@
# PRD: 콘텐츠 전체보기 API
## 1. Overview
콘텐츠 섹션에서 노출되는 오디오 목록의 전체보기 화면을 위해 `NEW_AND_HOT_AUDIO`, `FIRST_AUDIO_CONTENT` 두 타입을 페이징으로 조회하는 v2 API를 제공한다.
---
## 2. Problem
- 기존 `GET /api/v2/audio/recommendations`는 추천 탭 첫 화면의 섹션별 기본 개수만 내려주며, New & Hot 섹션 전체보기/페이징 API가 없다.
- 기존 `GET /api/v2/home/recommendations/first-audio-contents`는 아직 배포되지 않은 홈 추천 하위 개별 endpoint이며, 콘텐츠 전체보기 API가 추가되면 별도 유지할 이유가 없다.
- `GET /api/v2/audio/recommendations/contents`는 추천 API 하위 리소스처럼 보이므로, 콘텐츠 전체보기 API라는 의미와 맞지 않는다.
- `GET /api/v2/audio/contents`는 이미 메인 콘텐츠 전체 탭 API가 사용 중이므로, 새 섹션 전체보기 API 경로로 재사용하지 않는다.
- 클라이언트는 전체보기 화면에서 동일한 페이징 응답 형태로 섹션 타입만 바꿔 조회할 수 있어야 한다.
- V2 패키지에는 `AudioRecommendationQueryService`, `HomeRecommendationQueryService`, `AudioCardResponse`, `HomeFirstAudioContentItem`, `HomeRecommendationPageResponse` 등 재사용 가능한 조회/응답 패턴이 있으므로, 새 API는 기존 패턴을 우선 재사용해야 한다.
---
## 3. Goals
- 콘텐츠 전체보기 API를 `kr.co.vividnext.sodalive.v2` 하위 코드로 제공한다.
- 기존 패턴과 동일하게 API 조립 계층과 도메인 조회 계층을 분리한다.
- 조회 타입은 `NEW_AND_HOT_AUDIO`, `FIRST_AUDIO_CONTENT`를 지원한다.
- `NEW_AND_HOT_AUDIO``AudioRecommendationQueryService`의 New & Hot 스냅샷 조회 흐름을 재사용한다.
- `FIRST_AUDIO_CONTENT``HomeRecommendationQueryService.findFirstAudioContents` 조회 흐름을 재사용한다.
- 하나의 endpoint에서 `type` query parameter로 두 타입을 분리한다.
- 비회원 조회를 허용하지 않는다.
- 인증 회원의 차단/성인 콘텐츠 노출 가능 여부 등 기존 사용자 조건을 반영한다.
- 아직 배포되지 않은 `GET /api/v2/home/recommendations/first-audio-contents`는 제거한다.
- PRD에 API endpoint와 Response data class 초안을 포함한다.
---
## 4. Non-Goals
- 기존 `GET /api/v2/audio/recommendations` 공개 응답 스키마를 변경하지 않는다.
- 기존 `GET /api/v2/home/recommendations` 공개 응답 스키마를 변경하지 않는다.
- 기존 `GET /api/v2/home/recommendations/first-audio-contents` endpoint는 배포 전 기능이므로 하위 호환 대상으로 보지 않는다.
- New & Hot 점수 산식, 스냅샷 생성 주기, lazy refresh 정책을 변경하지 않는다.
- 첫 번째 오디오 콘텐츠 판정 기준과 정렬 정책을 변경하지 않는다.
- `RECENT_DEBUT_CREATOR`, `AI_CHARACTER` 등 다른 홈 추천 전체보기 타입은 이번 범위에 포함하지 않는다.
- 새로운 DB 테이블, 배치 작업, 관리자 기능은 이번 범위에 포함하지 않는다.
---
## 5. Target Users
- 회원: 콘텐츠 섹션의 전체보기에서 더 많은 오디오 콘텐츠를 탐색하는 사용자
- 앱 클라이언트: 동일한 전체보기 화면에서 타입, page, size, hasNext를 기반으로 목록을 구성하려는 클라이언트
---
## 6. User Stories
- 사용자는 New & Hot 섹션에서 첫 화면에 보이는 개수보다 더 많은 오디오를 보고 싶다.
- 사용자는 처음부터 함께 성장 섹션의 첫 번째 오디오 콘텐츠를 전체보기로 더 탐색하고 싶다.
- 앱 클라이언트는 전체보기 화면에서 `type`만 바꿔 동일한 페이징 응답을 처리하고 싶다.
- 앱 클라이언트는 인증 회원 기준으로 서버가 기존 성인 콘텐츠/차단 정책을 반영한 결과를 받길 원한다.
---
## 7. Core Features
### Feature A. 콘텐츠 전체보기 통합 조회 API
#### Requirements
- 신규 API endpoint는 `GET /api/v2/contents`로 정의한다.
- 응답 wrapper는 기존 패턴과 동일하게 `ApiResponse.ok(...)`를 사용한다.
- 비회원 조회를 허용하지 않는다.
- Security 설정은 `GET /api/v2/contents`를 인증 필요 endpoint로 둔다.
- 회원 조회 시 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴과 `requireMember(...)` 가드절을 사용한다.
- 요청 query parameter는 `type`, `page`, `size`를 사용한다.
- `type` 값은 아래 enum으로 정의한다.
- `NEW_AND_HOT_AUDIO`: 콘텐츠 추천 탭 New & Hot 오디오 전체보기
- `FIRST_AUDIO_CONTENT`: 메인 홈 처음부터 함께 성장 오디오 전체보기
- `type`을 보내지 않으면 `NEW_AND_HOT_AUDIO`를 기본값으로 사용한다.
- 지원하지 않는 `type` 값이 들어오면 400 오류 대신 `NEW_AND_HOT_AUDIO`로 fallback한다.
- `page`는 0부터 시작하는 page index로 처리한다.
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
- `page`가 0보다 작으면 `0`으로 fallback한다.
- `size`가 1보다 작으면 기본값 `20`으로 fallback한다.
- `size`가 50보다 크면 `50`으로 fallback한다.
- 다음 page 존재 여부는 `size + 1`개 조회 또는 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
#### Edge Cases
- 조회 결과가 없으면 `items`는 빈 배열, `hasNext``false`로 내려준다.
- 요청한 page 범위에 콘텐츠가 없으면 `items`는 빈 배열, `hasNext``false`로 내려준다.
- 특정 타입 조회 중 필터링으로 스냅샷 대상 상세 데이터가 제거될 수 있으며, 이 경우 가능한 항목만 내려준다.
### Feature B. NEW_AND_HOT_AUDIO 전체보기
#### Requirements
- `type=NEW_AND_HOT_AUDIO``AudioRecommendationQueryService`의 New & Hot 조회 정책을 재사용한다.
- 인증 회원의 성인 콘텐츠 노출 가능 여부에 따라 `AudioRecommendationVisibility.SAFE` 또는 `AudioRecommendationVisibility.ALL`을 결정한다.
- `SAFE``RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE`, `ALL``RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL` 스냅샷을 조회한다.
- New & Hot 첫 화면 노출 수는 기존과 동일하게 12개로 유지한다.
- New & Hot 스냅샷 저장 수는 전체보기 페이징을 위해 visibility별 100개로 확장한다.
- 스냅샷 저장 수 100개는 `SAFE``ALL` 각각에 적용한다.
- `RecommendationSnapshotPort.findLatestSnapshots(sectionType, offset, limit)`로 page offset과 `size + 1` limit을 적용한다.
- 스냅샷이 없으면 기존 `AudioRecommendationQueryService`의 New & Hot lazy refresh 정책을 재사용한다.
- 스냅샷 target id 목록을 `AudioRecommendationQueryPort.findAudioCardsByIds(...)`로 상세 조회한다.
- 응답 item은 기존 `AudioCardResponse` 필드 의미를 유지한다.
#### Edge Cases
- lazy refresh 후에도 스냅샷이 없으면 빈 배열로 내려준다.
- 스냅샷에는 있지만 비활성/예약 공개/차단/성인 콘텐츠 정책으로 상세 조회에서 제외된 항목은 응답하지 않는다.
### Feature C. FIRST_AUDIO_CONTENT 전체보기
#### Requirements
- `type=FIRST_AUDIO_CONTENT``HomeRecommendationQueryService.findFirstAudioContents(...)`를 재사용한다.
- `offset = page * size`, `limit = size + 1`로 조회한다.
- `member.id``MemberContentPreferenceService.canViewAdultContent(member)` 결과를 전달한다.
- 응답 item은 `NEW_AND_HOT_AUDIO`와 동일한 `ContentOverviewItemResponse` 필드를 모두 채운다.
- 기존 `HomeFirstAudioContentRecord`에 공통 응답 구성을 위해 필요한 `isAdult`, `isOriginalSeries` 값을 보강한다.
- `FIRST_AUDIO_CONTENT` 응답의 `isFirstContent`는 첫 번째 콘텐츠 섹션 특성상 `true`로 내려준다.
#### Edge Cases
- 첫 번째 오디오 콘텐츠 판정은 기존 홈 추천 PRD와 현재 `HomeRecommendationQueryService.findFirstAudioContents` 구현을 따른다.
- 예약 공개 콘텐츠는 기존 조회 서비스 정책에 따라 공개 전에는 노출하지 않는다.
### Feature D. 공통 콘텐츠 정책
#### Requirements
- 모든 타입은 공개 가능한 콘텐츠만 조회한다.
- 회원이 차단했거나 회원을 차단한 크리에이터의 콘텐츠는 노출하지 않는다.
- 인증 회원은 기존 콘텐츠 조회 설정에 따라 19금 콘텐츠 노출 가능 여부를 반영한다.
- 이미지 경로와 기본 프로필 이미지는 기존 각 조회 서비스/Facade 변환 정책을 따른다.
### Feature E. 미배포 홈 하위 전체보기 API 제거
#### Requirements
- `HomeRecommendationController``GET /api/v2/home/recommendations/first-audio-contents` endpoint를 제거한다.
- 해당 endpoint만을 위한 `HomeRecommendationFacade.getFirstAudioContents(...)` 조립 메서드는 새 콘텐츠 전체보기 Facade로 책임을 옮긴 뒤 제거한다.
- 관련 Controller/Facade 테스트는 새 `GET /api/v2/contents?type=FIRST_AUDIO_CONTENT` 테스트로 대체한다.
- `SecurityConfig`에 홈 하위 전체보기 endpoint를 위한 별도 설정이 있다면 제거하거나 더 이상 영향이 없게 정리한다.
#### Edge Cases
- `HomeRecommendationQueryService.findFirstAudioContents(...)`는 새 API에서 재사용하므로 제거하지 않는다.
---
## 8. API Endpoint
```http
GET /api/v2/contents?type=NEW_AND_HOT_AUDIO&page=0&size=20
Authorization: Bearer {accessToken}
```
- 비회원 조회를 허용하지 않는다.
- `SecurityConfig`에서 `GET /api/v2/contents`는 인증 필요 endpoint로 둔다.
- `type` 미지정 또는 invalid 값은 `NEW_AND_HOT_AUDIO`로 fallback한다.
- `FIRST_AUDIO_CONTENT` 조회 예시는 아래와 같다.
```http
GET /api/v2/contents?type=FIRST_AUDIO_CONTENT&page=0&size=20
Authorization: Bearer {accessToken}
```
---
## 9. Response Data Class
```kotlin
data class ContentOverviewPageResponse(
val type: ContentOverviewType,
val items: List<ContentOverviewItemResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
)
enum class ContentOverviewType {
NEW_AND_HOT_AUDIO,
FIRST_AUDIO_CONTENT
}
data class ContentOverviewItemResponse(
val contentId: Long,
val title: String,
val coverImage: String?,
val price: Int,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
val creatorNickname: String,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean
)
```
- `NEW_AND_HOT_AUDIO`, `FIRST_AUDIO_CONTENT` 모두 동일한 item 필드를 채우며 타입별 nullable 전용 필드를 두지 않는다.
- 기존 `audioContentId`, `imageUrl` 공개 필드명은 각각 `contentId`, `coverImage`로 사용한다.
- `duration`, `creatorId`, `creatorProfileImage`는 콘텐츠 전체보기 응답에 포함하지 않는다.
---
## 10. Technical Constraints
### 패키지 구조
- 공개 API 조립 계층은 콘텐츠 전체보기 API 의미가 드러나도록 `kr.co.vividnext.sodalive.v2.api.content.overview` 하위에 둔다.
- Controller: `...adapter.in.web.ContentOverviewController`
- Facade: `...application.ContentOverviewFacade`
- Response DTO: `...dto.ContentOverviewPageResponse`
- 도메인 조회 계층은 기존 서비스 재사용을 우선한다.
- New & Hot: `kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryService`
- 첫 번째 오디오 콘텐츠: `kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService`
- 신규 도메인 모델/정책이 필요하면 `kr.co.vividnext.sodalive.v2.content.recommendation.domain`에 최소 범위로 추가한다.
- 의존 방향은 `v2.api.content.overview -> v2.content.recommendation`, `v2.api.content.overview -> v2.recommendation`만 허용한다.
### V2 공통화/재사용 대상
- `AudioRecommendationQueryService.resolveVisibility(...)`
- `AudioRecommendationQueryService.newAndHotSectionType(...)`
- `RecommendationSnapshotPort.findLatestSnapshots(...)`
- `AudioRecommendationQueryPort.findAudioCardsByIds(...)`
- `HomeRecommendationQueryService.findFirstAudioContents(...)`
- `AudioCardResponse`의 응답 필드 의미와 `JsonProperty` 네이밍 패턴
- `HomeFirstAudioContentItem`의 응답 필드 의미와 이미지 URL 변환 패턴
- `HomeRecommendationFacade`의 page/size 보정, `size + 1` 기반 `hasNext` 계산 패턴
### 스냅샷 저장 정책
- New & Hot은 첫 화면 조회 limit과 스냅샷 저장 limit을 분리한다.
- 첫 화면 조회 limit은 `NEW_AND_HOT_HOME_LIMIT = 12`로 유지한다.
- 스냅샷 저장 limit은 `NEW_AND_HOT_SNAPSHOT_LIMIT = 100`으로 정의한다.
- `AudioRecommendationSnapshotRefreshService``findNewAndHotSnapshots(..., limit = NEW_AND_HOT_SNAPSHOT_LIMIT)``SAFE`, `ALL` 각각 최대 100개를 저장한다.
- `AudioRecommendationQueryService.getRecommendations(...)`는 첫 화면 응답 조립 시 최신 스냅샷에서 12개만 조회한다.
- 콘텐츠 전체보기 API는 저장된 100개 스냅샷 범위 안에서 `offset`, `size + 1`로 페이징한다.
### 구현 판단
- 별도 endpoint 2개보다 typed endpoint 1개를 기본안으로 한다.
- 이유는 두 요구가 모두 “오디오 콘텐츠 목록 전체보기”이고, page/size/hasNext 응답 계약이 동일하며, `MainContentAllController``type` 기반 단일 endpoint 패턴을 이미 사용하기 때문이다.
- endpoint는 `GET /api/v2/contents`를 사용한다.
- 이유는 `GET /api/v2/audio/recommendations/contents`가 추천 하위 리소스처럼 읽혀 콘텐츠 전체보기 API 의미와 맞지 않고, `GET /api/v2/audio/contents`는 이미 메인 콘텐츠 전체 탭 API가 사용 중이기 때문이다.
- 기존 `GET /api/v2/home/recommendations/first-audio-contents`는 배포 전 endpoint이므로 제거하고, 새 API의 `type=FIRST_AUDIO_CONTENT`로 대체한다.
---
## 11. Decisions
- `GET /api/v2/contents`는 인증 회원만 호출할 수 있다.
- 기존 홈 하위 전체보기 endpoint는 배포 전 기능이므로 제거한다.
- New & Hot 스냅샷은 전체보기 지원을 위해 visibility별 100개 저장한다.

View File

@@ -16,6 +16,7 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@Service @Service
@Transactional(readOnly = true)
class AdminMemberService( class AdminMemberService(
private val repository: AdminMemberRepository, private val repository: AdminMemberRepository,
private val passwordEncoder: PasswordEncoder, private val passwordEncoder: PasswordEncoder,

View File

@@ -0,0 +1,31 @@
package kr.co.vividnext.sodalive.v2.api.content.overview.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewFacade
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.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/contents")
class ContentOverviewController(
private val facade: ContentOverviewFacade
) {
@GetMapping
fun getContents(
@RequestParam(required = false) type: String?,
@RequestParam(required = false) page: Int?,
@RequestParam(required = false) size: Int?,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(facade.getContents(type, page, size, requireMember(member)))
}
private fun requireMember(member: Member?): Member {
return member ?: throw SodaException(messageKey = "common.error.bad_credentials")
}
}

View File

@@ -0,0 +1,72 @@
package kr.co.vividnext.sodalive.v2.api.content.overview.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewItemResponse
import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponse
import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewType
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryService
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.time.LocalDateTime
@Component
class ContentOverviewFacade(
private val audioRecommendationQueryService: AudioRecommendationQueryService,
private val homeRecommendationQueryService: HomeRecommendationQueryService,
private val memberContentPreferenceService: MemberContentPreferenceService,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String,
private val queryPolicy: ContentOverviewQueryPolicy = ContentOverviewQueryPolicy()
) {
fun getContents(type: String?, page: Int?, size: Int?, member: Member): ContentOverviewPageResponse {
val resolvedType = queryPolicy.resolveType(type)
val resolvedPage = queryPolicy.createPage(page, size)
return when (resolvedType) {
ContentOverviewType.NEW_AND_HOT_AUDIO -> getNewAndHotContents(member, resolvedPage)
ContentOverviewType.FIRST_AUDIO_CONTENT -> getFirstAudioContents(member, resolvedPage)
}
}
private fun getNewAndHotContents(member: Member, page: ContentOverviewPage): ContentOverviewPageResponse {
val fetched = audioRecommendationQueryService.findNewAndHotAudios(
member = member,
offset = page.offset,
limit = page.size + 1
)
return ContentOverviewPageResponse(
type = ContentOverviewType.NEW_AND_HOT_AUDIO,
items = queryPolicy.pageItems(fetched, page).map { ContentOverviewItemResponse.fromNewAndHot(it) },
page = page.page,
size = page.size,
hasNext = queryPolicy.hasNext(fetched, page)
)
}
private fun getFirstAudioContents(member: Member, page: ContentOverviewPage): ContentOverviewPageResponse {
val fetched = homeRecommendationQueryService.findFirstAudioContents(
now = LocalDateTime.now(),
offset = page.offset,
limit = page.size + 1,
memberId = member.id,
includeAdultContents = memberContentPreferenceService.canViewAdultContent(member)
)
return ContentOverviewPageResponse(
type = ContentOverviewType.FIRST_AUDIO_CONTENT,
items = queryPolicy.pageItems(fetched, page).map {
ContentOverviewItemResponse.fromFirstAudioContent(
audio = it,
coverImage = it.coverImage.toCdnUrl(cloudFrontHost),
isAdult = it.isAdult,
isOriginalSeries = it.isOriginalSeries
)
},
page = page.page,
size = page.size,
hasNext = queryPolicy.hasNext(fetched, page)
)
}
}

View File

@@ -0,0 +1,38 @@
package kr.co.vividnext.sodalive.v2.api.content.overview.application
import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewType
data class ContentOverviewPage(
val page: Int,
val size: Int
) {
val offset: Long = page.toLong() * size
}
class ContentOverviewQueryPolicy {
fun resolveType(type: String?): ContentOverviewType {
return ContentOverviewType.from(type)
}
fun createPage(page: Int?, size: Int?): ContentOverviewPage {
val resolvedPage = (page ?: DEFAULT_PAGE).coerceAtLeast(DEFAULT_PAGE)
val requestedSize = size ?: DEFAULT_SIZE
val resolvedSize = if (requestedSize < MIN_SIZE) DEFAULT_SIZE else minOf(requestedSize, MAX_SIZE)
return ContentOverviewPage(page = resolvedPage, size = resolvedSize)
}
fun <T> pageItems(items: List<T>, page: ContentOverviewPage): List<T> {
return items.take(page.size)
}
fun <T> hasNext(items: List<T>, page: ContentOverviewPage): Boolean {
return items.size > page.size
}
companion object {
const val DEFAULT_PAGE = 0
const val DEFAULT_SIZE = 20
const val MIN_SIZE = 20
const val MAX_SIZE = 50
}
}

View File

@@ -0,0 +1,76 @@
package kr.co.vividnext.sodalive.v2.api.content.overview.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeFirstAudioContentRecord
data class ContentOverviewPageResponse(
val type: ContentOverviewType,
val items: List<ContentOverviewItemResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
)
enum class ContentOverviewType {
NEW_AND_HOT_AUDIO,
FIRST_AUDIO_CONTENT;
companion object {
fun from(value: String?): ContentOverviewType {
return values().firstOrNull { it.name == value } ?: NEW_AND_HOT_AUDIO
}
}
}
data class ContentOverviewItemResponse(
val contentId: Long,
val title: String,
val coverImage: String?,
val price: Int,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
val creatorNickname: String,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean
) {
companion object {
fun fromNewAndHot(audio: AudioCard): ContentOverviewItemResponse {
return ContentOverviewItemResponse(
contentId = audio.audioContentId,
title = audio.title,
coverImage = audio.imageUrl,
price = audio.price,
isPointAvailable = audio.isPointAvailable,
creatorNickname = audio.creatorNickname,
isAdult = audio.isAdult,
isFirstContent = audio.isFirstContent,
isOriginalSeries = audio.isOriginalSeries
)
}
fun fromFirstAudioContent(
audio: HomeFirstAudioContentRecord,
coverImage: String?,
isAdult: Boolean,
isOriginalSeries: Boolean
): ContentOverviewItemResponse {
return ContentOverviewItemResponse(
contentId = audio.contentId,
title = audio.title,
coverImage = coverImage,
price = audio.price,
isPointAvailable = audio.isPointAvailable,
creatorNickname = audio.creatorNickname,
isAdult = isAdult,
isFirstContent = true,
isOriginalSeries = isOriginalSeries
)
}
}
}

View File

@@ -57,21 +57,6 @@ class HomeRecommendationController(
) )
} }
@GetMapping("/first-audio-contents")
fun getFirstAudioContents(
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "$DEFAULT_PAGE_SIZE") size: Int,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
homeRecommendationFacade.getFirstAudioContents(
requireMember(member),
normalizePage(page),
normalizeSize(size)
)
)
}
@GetMapping("/ai-characters") @GetMapping("/ai-characters")
fun getAiCharacters( fun getAiCharacters(
@RequestParam(defaultValue = "0") page: Int, @RequestParam(defaultValue = "0") page: Int,

View File

@@ -143,24 +143,6 @@ class HomeRecommendationFacade(
}.getOrThrow() }.getOrThrow()
} }
fun getFirstAudioContents(member: Member, page: Int, size: Int): HomeRecommendationPageResponse<HomeFirstAudioContentItem> {
val startedAt = System.currentTimeMillis()
return runCatching {
val fetched = queryService.findFirstAudioContents(
now = LocalDateTime.now(),
offset = page.toOffset(size),
limit = size + 1,
memberId = member.id,
includeAdultContents = resolveAdultVisibility(member)
)
fetched.toPage(page, size) { it.toItem() }
}.onSuccess {
logPageSuccess("FIRST_AUDIO_CONTENT", member, page, size, it.items.size, System.currentTimeMillis() - startedAt)
}.onFailure { ex ->
logPageFailure("FIRST_AUDIO_CONTENT", member, page, size, startedAt, ex)
}.getOrThrow()
}
fun getAiCharacters(member: Member, page: Int, size: Int): HomeRecommendationPageResponse<HomeAiCharacterItem> { fun getAiCharacters(member: Member, page: Int, size: Int): HomeRecommendationPageResponse<HomeAiCharacterItem> {
val startedAt = System.currentTimeMillis() val startedAt = System.currentTimeMillis()
return runCatching { return runCatching {
@@ -217,7 +199,7 @@ class HomeRecommendationFacade(
return memberContentPreferenceService.canViewAdultContent(member) return memberContentPreferenceService.canViewAdultContent(member)
} }
private fun Int.toOffset(size: Int): Int = this * size private fun Int.toOffset(size: Int): Long = this.toLong() * size
private fun <S, T> List<S>.toPage( private fun <S, T> List<S>.toPage(
page: Int, page: Int,

View File

@@ -21,7 +21,7 @@ class HomeOnAirLiveFacade(
fun getOnAirLives(member: Member, page: Int): HomeOnAirLivePageResponse { fun getOnAirLives(member: Member, page: Int): HomeOnAirLivePageResponse {
val normalizedPage = page.coerceIn(0, MAX_PAGE) val normalizedPage = page.coerceIn(0, MAX_PAGE)
val fetched = queryService.findLiveRecommendations( val fetched = queryService.findLiveRecommendations(
offset = normalizedPage * PAGE_SIZE, offset = normalizedPage.toLong() * PAGE_SIZE,
limit = PAGE_SIZE + 1, limit = PAGE_SIZE + 1,
memberId = member.id, memberId = member.id,
includeAdultLives = memberContentPreferenceService.canViewAdultContent(member) includeAdultLives = memberContentPreferenceService.canViewAdultContent(member)

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.content.recommendation.application
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
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.AudioRecommendationVisibility
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations 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.content.recommendation.port.out.AudioRecommendationQueryPort
@@ -29,7 +30,7 @@ class AudioRecommendationQueryService(
val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE
val memberId = member?.id val memberId = member?.id
val newAndHotSectionType = newAndHotSectionType(visibility) val newAndHotSectionType = newAndHotSectionType(visibility)
val newAndHotSnapshots = snapshotPort.findLatestSnapshots(newAndHotSectionType, limit = NEW_AND_HOT_AUDIO_LIMIT) val newAndHotSnapshots = snapshotPort.findLatestSnapshots(newAndHotSectionType, limit = NEW_AND_HOT_HOME_LIMIT)
val mostCommentedSnapshots = snapshotPort.findLatestSnapshots( val mostCommentedSnapshots = snapshotPort.findLatestSnapshots(
mostCommentedSectionType(visibility), mostCommentedSectionType(visibility),
limit = MOST_COMMENTED_AUDIO_LIMIT limit = MOST_COMMENTED_AUDIO_LIMIT
@@ -38,7 +39,12 @@ class AudioRecommendationQueryService(
recommendedAudioSectionType(visibility), recommendedAudioSectionType(visibility),
limit = RECOMMENDED_AUDIO_LIMIT limit = RECOMMENDED_AUDIO_LIMIT
) )
val refreshedNewAndHotSnapshots = refreshMissingNewAndHotSnapshots(newAndHotSectionType, newAndHotSnapshots) val refreshedNewAndHotSnapshots = refreshMissingNewAndHotSnapshots(
newAndHotSectionType,
newAndHotSnapshots,
offset = 0,
limit = NEW_AND_HOT_HOME_LIMIT
)
return AudioRecommendations( return AudioRecommendations(
banners = queryPort.findBanners(BANNER_LIMIT, memberId, canViewAdultContent), banners = queryPort.findBanners(BANNER_LIMIT, memberId, canViewAdultContent),
@@ -66,6 +72,22 @@ class AudioRecommendationQueryService(
) )
} }
fun findNewAndHotAudios(member: Member, offset: Long, limit: Int): List<AudioCard> {
val now = LocalDateTime.now()
val canViewAdultContent = canViewAdultContent(member)
val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE
val sectionType = newAndHotSectionType(visibility)
val snapshots = snapshotPort.findLatestSnapshots(sectionType, offset, limit)
val refreshedSnapshots = refreshMissingNewAndHotSnapshots(sectionType, snapshots, offset, limit)
return queryPort.findAudioCardsByIds(
refreshedSnapshots.map { it.targetId },
member.id,
canViewAdultContent,
now
)
}
fun resolveVisibility(member: Member?): AudioRecommendationVisibility { fun resolveVisibility(member: Member?): AudioRecommendationVisibility {
return if (canViewAdultContent(member)) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE return if (canViewAdultContent(member)) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE
} }
@@ -93,7 +115,9 @@ class AudioRecommendationQueryService(
private fun refreshMissingNewAndHotSnapshots( private fun refreshMissingNewAndHotSnapshots(
sectionType: RecommendedSectionType, sectionType: RecommendedSectionType,
snapshots: List<RecommendationSnapshotRecord> snapshots: List<RecommendationSnapshotRecord>,
offset: Long,
limit: Int
): List<RecommendationSnapshotRecord> { ): List<RecommendationSnapshotRecord> {
if (snapshots.isNotEmpty()) return snapshots if (snapshots.isNotEmpty()) return snapshots
val today = LocalDate.now(KST_ZONE) val today = LocalDate.now(KST_ZONE)
@@ -107,7 +131,7 @@ class AudioRecommendationQueryService(
marker.delete() marker.delete()
throw ex throw ex
} }
return snapshotPort.findLatestSnapshots(sectionType, limit = NEW_AND_HOT_AUDIO_LIMIT) return snapshotPort.findLatestSnapshots(sectionType, offset, limit)
} }
private fun newAndHotLazyRefreshMarkerKey(date: LocalDate): String { private fun newAndHotLazyRefreshMarkerKey(date: LocalDate): String {
@@ -125,7 +149,7 @@ class AudioRecommendationQueryService(
const val LATEST_AUDIO_LIMIT = 12 const val LATEST_AUDIO_LIMIT = 12
const val FREE_AUDIO_LIMIT = 10 const val FREE_AUDIO_LIMIT = 10
const val POINT_AUDIO_LIMIT = 10 const val POINT_AUDIO_LIMIT = 10
const val NEW_AND_HOT_AUDIO_LIMIT = 12 const val NEW_AND_HOT_HOME_LIMIT = 12
const val MOST_COMMENTED_AUDIO_LIMIT = 5 const val MOST_COMMENTED_AUDIO_LIMIT = 5
const val RECOMMENDED_AUDIO_LIMIT = 10 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_MARKER_KEY_PREFIX = "audio-recommendation:new-and-hot:lazy-refresh-attempted"

View File

@@ -68,7 +68,7 @@ class AudioRecommendationSnapshotRefreshService(
visibility: AudioRecommendationVisibility visibility: AudioRecommendationVisibility
) { ) {
val sectionType = visibility.newAndHotSectionType() val sectionType = visibility.newAndHotSectionType()
val snapshots = queryPort.findNewAndHotSnapshots(windowStart, snapshotAt, visibility, NEW_AND_HOT_LIMIT) val snapshots = queryPort.findNewAndHotSnapshots(windowStart, snapshotAt, visibility, NEW_AND_HOT_SNAPSHOT_LIMIT)
snapshotPort.replaceSnapshots(sectionType, snapshotAt, snapshots) snapshotPort.replaceSnapshots(sectionType, snapshotAt, snapshots)
} }
@@ -128,7 +128,7 @@ class AudioRecommendationSnapshotRefreshService(
} }
companion object { companion object {
const val NEW_AND_HOT_LIMIT = 12 const val NEW_AND_HOT_SNAPSHOT_LIMIT = 100
const val MOST_COMMENTED_LIMIT = 5 const val MOST_COMMENTED_LIMIT = 5
const val RECOMMENDED_AUDIO_LIMIT = 10 const val RECOMMENDED_AUDIO_LIMIT = 10
private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul") private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul")

View File

@@ -51,7 +51,7 @@ class DefaultHomeRecommendationQueryRepository(
private val entityManager: EntityManager private val entityManager: EntityManager
) : HomeRecommendationQueryRepository { ) : HomeRecommendationQueryRepository {
override fun findLiveRecommendations( override fun findLiveRecommendations(
offset: Int, offset: Long,
limit: Int, limit: Int,
memberId: Long?, memberId: Long?,
includeAdultLives: Boolean includeAdultLives: Boolean
@@ -79,7 +79,7 @@ class DefaultHomeRecommendationQueryRepository(
member.isActive.isTrue member.isActive.isTrue
) )
.orderBy(liveRoom.beginDateTime.desc(), liveRoom.id.desc()) .orderBy(liveRoom.beginDateTime.desc(), liveRoom.id.desc())
.offset(offset.toLong()) .offset(offset)
.limit(limit.toLong()) .limit(limit.toLong())
.fetch() .fetch()
} }
@@ -211,7 +211,7 @@ class DefaultHomeRecommendationQueryRepository(
override fun findRecentDebutCreators( override fun findRecentDebutCreators(
now: LocalDateTime, now: LocalDateTime,
offset: Int, offset: Long,
limit: Int, limit: Int,
memberId: Long?, memberId: Long?,
includeAdultContents: Boolean includeAdultContents: Boolean
@@ -355,7 +355,7 @@ class DefaultHomeRecommendationQueryRepository(
override fun findFirstAudioContents( override fun findFirstAudioContents(
now: LocalDateTime, now: LocalDateTime,
offset: Int, offset: Long,
limit: Int, limit: Int,
memberId: Long?, memberId: Long?,
includeAdultContents: Boolean includeAdultContents: Boolean
@@ -390,6 +390,14 @@ class DefaultHomeRecommendationQueryRepository(
ac.release_date as release_date, ac.release_date as release_date,
ac.is_active as is_active, ac.is_active as is_active,
ac.is_point_available as is_point_available, ac.is_point_available as is_point_available,
ac.is_adult as is_adult,
exists (
select 1
from series_content csc
join series cs on cs.id = csc.series_id
where csc.content_id = ac.id
and cs.is_original = true
) as is_original_series,
row_number() over ( row_number() over (
partition by ac.member_id partition by ac.member_id
order by ac.created_at asc, ac.release_date asc, ac.id asc order by ac.created_at asc, ac.release_date asc, ac.id asc
@@ -416,7 +424,9 @@ class DefaultHomeRecommendationQueryRepository(
ec.title as title, ec.title as title,
ec.price as price, ec.price as price,
ec.cover_image as cover_image, ec.cover_image as cover_image,
ec.is_point_available as is_point_available ec.is_point_available as is_point_available,
ec.is_adult as is_adult,
ec.is_original_series as is_original_series
from eligible_contents ec from eligible_contents ec
join member m on m.id = ec.creator_id join member m on m.id = ec.creator_id
join creator_debut cd on cd.creator_id = ec.creator_id join creator_debut cd on cd.creator_id = ec.creator_id
@@ -465,7 +475,9 @@ class DefaultHomeRecommendationQueryRepository(
title = row[4] as String, title = row[4] as String,
price = (row[5] as Number).toInt(), price = (row[5] as Number).toInt(),
coverImage = row[6] as String?, coverImage = row[6] as String?,
isPointAvailable = row[7] as Boolean isPointAvailable = row[7] as Boolean,
isAdult = row[8] as Boolean,
isOriginalSeries = row[9] as Boolean
) )
} }
} }

View File

@@ -12,7 +12,7 @@ class RecommendationSnapshotPersistenceAdapter(
) : RecommendationSnapshotPort { ) : RecommendationSnapshotPort {
override fun findLatestSnapshots( override fun findLatestSnapshots(
sectionType: RecommendedSectionType, sectionType: RecommendedSectionType,
offset: Int, offset: Long,
limit: Int limit: Int
): List<RecommendationSnapshotRecord> { ): List<RecommendationSnapshotRecord> {
return repository.findLatestSnapshots(sectionType.name, offset, limit).map { it.toRecord() } return repository.findLatestSnapshots(sectionType.name, offset, limit).map { it.toRecord() }

View File

@@ -24,7 +24,7 @@ interface RecommendationSnapshotRepository : JpaRepository<RecommendationSnapsho
) )
fun findLatestSnapshots( fun findLatestSnapshots(
@Param("sectionType") sectionType: String, @Param("sectionType") sectionType: String,
@Param("offset") offset: Int, @Param("offset") offset: Long,
@Param("limit") limit: Int @Param("limit") limit: Int
): List<RecommendationSnapshot> ): List<RecommendationSnapshot>

View File

@@ -24,7 +24,7 @@ class HomeRecommendationQueryService(
private val snapshotPort: RecommendationSnapshotPort private val snapshotPort: RecommendationSnapshotPort
) { ) {
fun findLiveRecommendations( fun findLiveRecommendations(
offset: Int = 0, offset: Long = 0,
limit: Int = DEFAULT_LIVE_LIMIT, limit: Int = DEFAULT_LIVE_LIMIT,
memberId: Long? = null, memberId: Long? = null,
includeAdultLives: Boolean = false includeAdultLives: Boolean = false
@@ -49,7 +49,7 @@ class HomeRecommendationQueryService(
fun findRecentDebutCreators( fun findRecentDebutCreators(
now: LocalDateTime, now: LocalDateTime,
offset: Int = 0, offset: Long = 0,
limit: Int = DEFAULT_RECENT_DEBUT_CREATOR_LIMIT, limit: Int = DEFAULT_RECENT_DEBUT_CREATOR_LIMIT,
memberId: Long? = null, memberId: Long? = null,
includeAdultContents: Boolean = false includeAdultContents: Boolean = false
@@ -59,7 +59,7 @@ class HomeRecommendationQueryService(
fun findFirstAudioContents( fun findFirstAudioContents(
now: LocalDateTime, now: LocalDateTime,
offset: Int = 0, offset: Long = 0,
limit: Int = DEFAULT_FIRST_AUDIO_CONTENT_LIMIT, limit: Int = DEFAULT_FIRST_AUDIO_CONTENT_LIMIT,
memberId: Long? = null, memberId: Long? = null,
includeAdultContents: Boolean = false includeAdultContents: Boolean = false
@@ -68,7 +68,7 @@ class HomeRecommendationQueryService(
} }
fun findAiCharacterRecommendations( fun findAiCharacterRecommendations(
offset: Int = 0, offset: Long = 0,
limit: Int = DEFAULT_AI_CHARACTER_LIMIT limit: Int = DEFAULT_AI_CHARACTER_LIMIT
): List<HomeAiCharacterRecommendationRecord> { ): List<HomeAiCharacterRecommendationRecord> {
val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER, offset, limit) val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER, offset, limit)

View File

@@ -5,7 +5,7 @@ import java.time.LocalDateTime
interface HomeRecommendationQueryPort { interface HomeRecommendationQueryPort {
fun findLiveRecommendations( fun findLiveRecommendations(
offset: Int = 0, offset: Long = 0,
limit: Int, limit: Int,
memberId: Long? = null, memberId: Long? = null,
includeAdultLives: Boolean = false includeAdultLives: Boolean = false
@@ -24,7 +24,7 @@ interface HomeRecommendationQueryPort {
fun findRecentDebutCreators( fun findRecentDebutCreators(
now: LocalDateTime, now: LocalDateTime,
offset: Int = 0, offset: Long = 0,
limit: Int, limit: Int,
memberId: Long? = null, memberId: Long? = null,
includeAdultContents: Boolean = false includeAdultContents: Boolean = false
@@ -32,7 +32,7 @@ interface HomeRecommendationQueryPort {
fun findFirstAudioContents( fun findFirstAudioContents(
now: LocalDateTime, now: LocalDateTime,
offset: Int = 0, offset: Long = 0,
limit: Int, limit: Int,
memberId: Long? = null, memberId: Long? = null,
includeAdultContents: Boolean = false includeAdultContents: Boolean = false
@@ -119,7 +119,9 @@ data class HomeFirstAudioContentRecord(
val title: String, val title: String,
val price: Int, val price: Int,
val coverImage: String?, val coverImage: String?,
val isPointAvailable: Boolean val isPointAvailable: Boolean,
val isAdult: Boolean,
val isOriginalSeries: Boolean
) )
data class HomeAiCharacterRecommendationRecord( data class HomeAiCharacterRecommendationRecord(

View File

@@ -6,7 +6,7 @@ import java.time.LocalDateTime
interface RecommendationSnapshotPort { interface RecommendationSnapshotPort {
fun findLatestSnapshots( fun findLatestSnapshots(
sectionType: RecommendedSectionType, sectionType: RecommendedSectionType,
offset: Int = 0, offset: Long = 0,
limit: Int = Int.MAX_VALUE limit: Int = Int.MAX_VALUE
): List<RecommendationSnapshotRecord> ): List<RecommendationSnapshotRecord>

View File

@@ -0,0 +1,81 @@
package kr.co.vividnext.sodalive.admin.member
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.SignOut
import kr.co.vividnext.sodalive.member.SignOutRepository
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.data.domain.PageRequest
import org.springframework.test.context.ContextConfiguration
@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://cdn.test"])
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class AdminMemberServiceTest @Autowired constructor(
private val service: AdminMemberService,
private val adminMemberRepository: AdminMemberRepository,
private val signOutRepository: SignOutRepository
) {
@AfterEach
fun tearDown() {
signOutRepository.deleteAll()
adminMemberRepository.deleteAll()
}
@Test
@DisplayName("OSIV off 환경에서 탈퇴 이력이 있는 회원 리스트를 조회해도 lazy 초기화 예외가 발생하지 않는다")
fun shouldGetMemberListWithSignOutReasonsWhenOpenInViewIsDisabled() {
val member = saveMemberWithSignOutReason(
email = "admin-member-list-user@test.com",
nickname = "회원 목록 사용자",
role = MemberRole.USER
)
val response = service.getMemberList(PageRequest.of(0, 20))
val item = response.items.single { it.id == member.id }
assertEquals(1, response.totalCount)
assertTrue(item.signOutDate.isNotBlank())
}
@Test
@DisplayName("OSIV off 환경에서 탈퇴 이력이 있는 크리에이터 리스트를 조회해도 lazy 초기화 예외가 발생하지 않는다")
fun shouldGetCreatorListWithSignOutReasonsWhenOpenInViewIsDisabled() {
val creator = saveMemberWithSignOutReason(
email = "admin-member-list-creator@test.com",
nickname = "크리에이터 목록 사용자",
role = MemberRole.CREATOR
)
val response = service.getCreatorList(PageRequest.of(0, 20))
val item = response.items.single { it.id == creator.id }
assertEquals(1, response.totalCount)
assertTrue(item.signOutDate.isNotBlank())
}
private fun saveMemberWithSignOutReason(
email: String,
nickname: String,
role: MemberRole
): Member {
val member = adminMemberRepository.save(
Member(
email = email,
password = "password",
nickname = nickname,
role = role
)
)
val signOut = SignOut(reason = "운영 정책 위반")
signOut.member = member
signOutRepository.save(signOut)
return member
}
}

View File

@@ -0,0 +1,105 @@
package kr.co.vividnext.sodalive.v2.api.content.overview.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.overview.application.ContentOverviewFacade
import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponse
import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewType
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.anonymous
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(ContentOverviewController::class)
@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class)
class ContentOverviewControllerTest @Autowired constructor(
private val mockMvc: MockMvc
) {
@MockBean
private lateinit var facade: ContentOverviewFacade
@MockBean
private lateinit var countryContext: CountryContext
@MockBean
private lateinit var langContext: LangContext
@MockBean
private lateinit var sodaMessageSource: SodaMessageSource
@MockBean
private lateinit var tokenProvider: TokenProvider
@Test
@DisplayName("콘텐츠 전체보기는 비회원 요청을 거부한다")
fun shouldRejectAnonymousRequest() {
mockMvc.perform(
get("/api/v2/contents")
.with(anonymous())
)
.andExpect(status().isUnauthorized)
}
@Test
@DisplayName("콘텐츠 전체보기는 인증 회원과 query parameter를 facade에 전달한다")
fun shouldPassAuthenticatedMemberAndQueryParameters() {
val member = member(id = 10L)
Mockito.doReturn(emptyResponse(ContentOverviewType.FIRST_AUDIO_CONTENT)).`when`(facade)
.getContents(eqValue("FIRST_AUDIO_CONTENT"), eqValue(1), eqValue(30), eqValue(member))
mockMvc.perform(
get("/api/v2/contents")
.param("type", "FIRST_AUDIO_CONTENT")
.param("page", "1")
.param("size", "30")
.with(user(MemberAdapter(member)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.type").value("FIRST_AUDIO_CONTENT"))
Mockito.verify(facade).getContents(eqValue("FIRST_AUDIO_CONTENT"), eqValue(1), eqValue(30), eqValue(member))
}
private fun <T> eqValue(value: T): T {
return Mockito.eq(value) ?: value
}
private fun member(id: Long): Member {
return Member(
email = "viewer$id@test.com",
password = "password",
nickname = "viewer$id",
role = MemberRole.USER
).apply {
this.id = id
}
}
private fun emptyResponse(type: ContentOverviewType): ContentOverviewPageResponse {
return ContentOverviewPageResponse(
type = type,
items = emptyList(),
page = 0,
size = 20,
hasNext = false
)
}
}

View File

@@ -0,0 +1,204 @@
package kr.co.vividnext.sodalive.v2.api.content.overview.adapter.`in`.web
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import 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.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.annotation.DirtiesContext
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:content-overview-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
]
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class ContentOverviewEndToEndTest @Autowired constructor(
private val mockMvc: MockMvc,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
) {
@Test
@DisplayName("콘텐츠 전체보기 API는 비회원 요청을 거부한다")
fun shouldRejectAnonymousContentOverviewRequest() {
mockMvc.perform(get("/api/v2/contents"))
.andExpect(status().isUnauthorized)
}
@Test
@DisplayName("콘텐츠 전체보기 API는 인증 회원에게 New & Hot 오디오 페이지를 반환한다")
fun shouldReturnNewAndHotAudioOverviewForMember() {
val fixture = createNewAndHotFixture("content-overview-new-hot")
mockMvc.perform(
get("/api/v2/contents")
.param("type", "NEW_AND_HOT_AUDIO")
.param("page", "0")
.param("size", "20")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.type").value("NEW_AND_HOT_AUDIO"))
.andExpect(jsonPath("$.data.items[0].contentId").value(fixture.audioContentId))
.andExpect(jsonPath("$.data.items[0].title").value("content-overview-new-hot-audio"))
.andExpect(jsonPath("$.data.items[0].coverImage").value("https://cdn.test/content-overview-new-hot.png"))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
}
@Test
@DisplayName("콘텐츠 전체보기 API는 인증 회원에게 첫 번째 오디오 콘텐츠 페이지를 반환한다")
fun shouldReturnFirstAudioContentOverviewForMember() {
val fixture = createFirstAudioFixture("content-overview-first")
mockMvc.perform(
get("/api/v2/contents")
.param("type", "FIRST_AUDIO_CONTENT")
.param("page", "0")
.param("size", "20")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.type").value("FIRST_AUDIO_CONTENT"))
.andExpect(jsonPath("$.data.items[0].contentId").value(fixture.audioContentId))
.andExpect(jsonPath("$.data.items[0].title").value("content-overview-first-audio"))
.andExpect(jsonPath("$.data.items[0].coverImage").value("https://cdn.test/content-overview-first.png"))
.andExpect(jsonPath("$.data.items[0].isFirstContent").value(true))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
}
@Test
@DisplayName("콘텐츠 전체보기 API는 유효하지 않은 type을 New & Hot으로 대체한다")
fun shouldFallbackInvalidTypeToNewAndHotAudio() {
val fixture = createNewAndHotFixture("content-overview-invalid-type")
mockMvc.perform(
get("/api/v2/contents")
.param("type", "UNKNOWN")
.param("page", "0")
.param("size", "20")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.type").value("NEW_AND_HOT_AUDIO"))
.andExpect(jsonPath("$.data.items[0].contentId").value(fixture.audioContentId))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
}
private fun createNewAndHotFixture(prefix: String): Fixture {
return transactionTemplate.execute {
val now = LocalDateTime.now().minusHours(1)
val viewer = saveMember("$prefix-viewer", MemberRole.USER)
val creator = saveMember("$prefix-creator", MemberRole.CREATOR)
val theme = saveTheme(prefix)
val audio = saveAudio(creator, theme, "$prefix-audio", "$prefix.png", now)
saveSnapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, audio.id!!, now)
entityManager.flush()
entityManager.clear()
Fixture(viewer = viewer, audioContentId = audio.id!!)
}!!
}
private fun createFirstAudioFixture(prefix: String): Fixture {
return transactionTemplate.execute {
val now = LocalDateTime.now().minusHours(1)
val viewer = saveMember("$prefix-viewer", MemberRole.USER)
val creator = saveMember("$prefix-creator", MemberRole.CREATOR)
val theme = saveTheme(prefix)
val audio = saveAudio(creator, theme, "$prefix-audio", "$prefix.png", now)
entityManager.flush()
entityManager.clear()
Fixture(viewer = viewer, audioContentId = audio.id!!)
}!!
}
private fun saveMember(nickname: String, role: MemberRole): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
role = role
)
entityManager.persist(member)
return member
}
private fun saveTheme(prefix: String): AudioContentTheme {
val theme = AudioContentTheme(theme = "$prefix-theme", image = "$prefix-theme.png", isActive = true)
entityManager.persist(theme)
return theme
}
private fun saveAudio(
creator: Member,
theme: AudioContentTheme,
title: String,
coverImage: String,
releaseDate: LocalDateTime
): AudioContent {
val audio = AudioContent(
title = title,
detail = "detail",
languageCode = "ko",
releaseDate = releaseDate,
isAdult = false,
price = 100,
isPointAvailable = true
)
audio.member = creator
audio.theme = theme
audio.isActive = true
audio.coverImage = coverImage
audio.duration = "00:10"
entityManager.persist(audio)
return audio
}
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 viewer: Member,
val audioContentId: Long
)
}

View File

@@ -0,0 +1,117 @@
package kr.co.vividnext.sodalive.v2.api.content.overview.application
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.v2.api.content.overview.dto.ContentOverviewType
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.recommendation.application.HomeRecommendationQueryService
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeFirstAudioContentRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import java.time.LocalDateTime
class ContentOverviewFacadeTest {
private val audioRecommendationQueryService = Mockito.mock(AudioRecommendationQueryService::class.java)
private val homeRecommendationQueryService = Mockito.mock(HomeRecommendationQueryService::class.java)
private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
private val facade = ContentOverviewFacade(
audioRecommendationQueryService = audioRecommendationQueryService,
homeRecommendationQueryService = homeRecommendationQueryService,
memberContentPreferenceService = memberContentPreferenceService,
cloudFrontHost = "https://cdn.test",
queryPolicy = ContentOverviewQueryPolicy()
)
@Test
@DisplayName("New & Hot 전체보기는 size + 1 조회 결과를 공통 페이지 응답으로 변환한다")
fun shouldReturnNewAndHotPage() {
val member = member(id = 10L)
Mockito.doReturn((1L..21L).map { audioCard(it) }).`when`(audioRecommendationQueryService)
.findNewAndHotAudios(member, offset = 0L, limit = 21)
val response = facade.getContents("NEW_AND_HOT_AUDIO", page = 0, size = 20, member = member)
assertEquals(ContentOverviewType.NEW_AND_HOT_AUDIO, response.type)
assertEquals((1L..20L).toList(), response.items.map { it.contentId })
assertEquals("https://cdn.test/audio1.png", response.items[0].coverImage)
assertEquals(0, response.page)
assertEquals(20, response.size)
assertEquals(true, response.hasNext)
}
@Test
@DisplayName("첫 번째 오디오 콘텐츠 전체보기는 adult visibility와 offset을 반영해 조회한다")
fun shouldReturnFirstAudioContentPage() {
val member = member(id = 10L)
Mockito.doReturn(true).`when`(memberContentPreferenceService).canViewAdultContent(member)
Mockito.doReturn(listOf(firstAudio(1L), firstAudio(2L))).`when`(homeRecommendationQueryService)
.findFirstAudioContents(
anyLocalDateTime(),
eqValue(20L),
eqValue(21),
eqValue(member.id),
eqValue(true)
)
val response = facade.getContents("FIRST_AUDIO_CONTENT", page = 1, size = 20, member = member)
assertEquals(ContentOverviewType.FIRST_AUDIO_CONTENT, response.type)
assertEquals(listOf(1L, 2L), response.items.map { it.contentId })
assertEquals("https://cdn.test/cover/audio1.png", response.items[0].coverImage)
assertEquals(true, response.items[0].isFirstContent)
assertEquals(false, response.hasNext)
}
private fun member(id: Long): Member {
return Member(
email = "viewer$id@test.com",
password = "password",
nickname = "viewer$id",
role = MemberRole.USER
).apply {
this.id = id
}
}
private fun audioCard(id: Long): AudioCard {
return AudioCard(
audioContentId = id,
title = "audio$id",
duration = "00:01",
imageUrl = "https://cdn.test/audio$id.png",
price = id.toInt(),
isAdult = false,
isPointAvailable = true,
isFirstContent = true,
isOriginalSeries = false,
creatorNickname = "creator$id"
)
}
private fun anyLocalDateTime(): LocalDateTime {
return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.of(2026, 6, 27, 0, 0)
}
private fun <T> eqValue(value: T): T {
return Mockito.eq(value) ?: value
}
private fun firstAudio(id: Long): HomeFirstAudioContentRecord {
return HomeFirstAudioContentRecord(
contentId = id,
creatorId = id + 100,
creatorNickname = "creator$id",
creatorProfileImage = null,
title = "first audio$id",
price = id.toInt(),
coverImage = "cover/audio$id.png",
isPointAvailable = true,
isAdult = false,
isOriginalSeries = false
)
}
}

View File

@@ -0,0 +1,45 @@
package kr.co.vividnext.sodalive.v2.api.content.overview.application
import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewType
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
class ContentOverviewQueryPolicyTest {
private val policy = ContentOverviewQueryPolicy()
@Test
@DisplayName("콘텐츠 전체보기 type은 null 또는 invalid 값을 기본 타입으로 보정한다")
fun shouldResolveTypeWithDefaultFallback() {
assertEquals(ContentOverviewType.NEW_AND_HOT_AUDIO, policy.resolveType(null))
assertEquals(ContentOverviewType.NEW_AND_HOT_AUDIO, policy.resolveType("UNKNOWN"))
assertEquals(ContentOverviewType.FIRST_AUDIO_CONTENT, policy.resolveType("FIRST_AUDIO_CONTENT"))
}
@Test
@DisplayName("콘텐츠 전체보기 page와 size를 기본값과 최대값으로 보정한다")
fun shouldNormalizePageAndSize() {
assertEquals(ContentOverviewPage(page = 0, size = 20), policy.createPage(null, null))
assertEquals(ContentOverviewPage(page = 0, size = 20), policy.createPage(-1, 0))
assertEquals(ContentOverviewPage(page = 0, size = 20), policy.createPage(0, 19))
assertEquals(ContentOverviewPage(page = 2, size = 50), policy.createPage(2, 100))
}
@Test
@DisplayName("콘텐츠 전체보기 offset은 큰 page 입력에서도 Int overflow 없이 계산한다")
fun shouldCalculateOffsetWithoutIntOverflow() {
val page = policy.createPage(Int.MAX_VALUE, 50)
assertEquals(Int.MAX_VALUE.toLong() * 50, page.offset)
}
@Test
@DisplayName("콘텐츠 전체보기 응답 목록과 hasNext는 size + 1 조회 결과로 계산한다")
fun shouldCalculatePageItemsAndHasNext() {
val page = ContentOverviewPage(page = 0, size = 2)
val items = listOf(1, 2, 3)
assertEquals(listOf(1, 2), policy.pageItems(items, page))
assertEquals(true, policy.hasNext(items, page))
}
}

View File

@@ -0,0 +1,80 @@
package kr.co.vividnext.sodalive.v2.api.content.overview.dto
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeFirstAudioContentRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
class ContentOverviewPageResponseTest {
private val objectMapper = jacksonObjectMapper()
@Test
@DisplayName("콘텐츠 전체보기 응답은 공개 JSON 필드명만 직렬화한다")
fun shouldSerializeContentOverviewPageResponse() {
val response = ContentOverviewPageResponse(
type = ContentOverviewType.NEW_AND_HOT_AUDIO,
items = listOf(
ContentOverviewItemResponse(
contentId = 1L,
title = "audio",
coverImage = "https://cdn.test/audio.png",
price = 10,
isPointAvailable = true,
creatorNickname = "creator",
isAdult = false,
isFirstContent = true,
isOriginalSeries = false
)
),
page = 0,
size = 20,
hasNext = true
)
val json = objectMapper.readTree(objectMapper.writeValueAsString(response))
assertEquals("NEW_AND_HOT_AUDIO", json["type"].asText())
assertEquals(true, json["hasNext"].asBoolean())
assertEquals(1L, json["items"][0]["contentId"].asLong())
assertEquals("https://cdn.test/audio.png", json["items"][0]["coverImage"].asText())
assertEquals(true, json["items"][0]["isPointAvailable"].asBoolean())
assertEquals(false, json["items"][0]["isAdult"].asBoolean())
assertEquals(true, json["items"][0]["isFirstContent"].asBoolean())
assertEquals(false, json["items"][0]["isOriginalSeries"].asBoolean())
assertEquals(false, json["items"][0].has("audioContentId"))
assertEquals(false, json["items"][0].has("imageUrl"))
assertEquals(false, json["items"][0].has("duration"))
assertEquals(false, json["items"][0].has("creatorId"))
assertEquals(false, json["items"][0].has("creatorProfileImage"))
assertEquals(false, json["items"][0].has("pointAvailable"))
assertEquals(false, json["items"][0].has("adult"))
assertEquals(false, json["items"][0].has("firstContent"))
assertEquals(false, json["items"][0].has("originalSeries"))
}
@Test
@DisplayName("첫 번째 오디오 콘텐츠 변환은 성인/오리지널 플래그를 전달한다")
fun shouldMapFirstAudioContentFlags() {
val response = ContentOverviewItemResponse.fromFirstAudioContent(
audio = HomeFirstAudioContentRecord(
contentId = 1L,
creatorId = 10L,
creatorNickname = "creator",
creatorProfileImage = null,
title = "first audio",
price = 100,
coverImage = "cover/audio.png",
isPointAvailable = true,
isAdult = true,
isOriginalSeries = true
),
coverImage = "https://cdn.test/cover/audio.png",
isAdult = true,
isOriginalSeries = true
)
assertEquals(true, response.isAdult)
assertEquals(true, response.isOriginalSeries)
}
}

View File

@@ -318,30 +318,19 @@ class HomeRecommendationControllerTest @Autowired constructor(
Mockito.`when`( Mockito.`when`(
failingQueryService.findRecentDebutCreators( failingQueryService.findRecentDebutCreators(
now = Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.MIN, now = Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.MIN,
offset = Mockito.eq(0), offset = Mockito.eq(0L),
limit = Mockito.eq(21), limit = Mockito.eq(21),
memberId = Mockito.eq(member.id), memberId = Mockito.eq(member.id),
includeAdultContents = Mockito.eq(false) includeAdultContents = Mockito.eq(false)
) )
).thenThrow(IllegalStateException("debut page failed")) ).thenThrow(IllegalStateException("debut page failed"))
Mockito.`when`( Mockito.`when`(failingQueryService.findAiCharacterRecommendations(offset = 0L, limit = 21))
failingQueryService.findFirstAudioContents(
now = Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.MIN,
offset = Mockito.eq(0),
limit = Mockito.eq(21),
memberId = Mockito.eq(member.id),
includeAdultContents = Mockito.eq(false)
)
).thenThrow(IllegalStateException("first audio page failed"))
Mockito.`when`(failingQueryService.findAiCharacterRecommendations(offset = 0, limit = 21))
.thenThrow(IllegalStateException("ai page failed")) .thenThrow(IllegalStateException("ai page failed"))
assertThrows(IllegalStateException::class.java) { facade.getRecentDebutCreators(member, page = 0, size = 20) } assertThrows(IllegalStateException::class.java) { facade.getRecentDebutCreators(member, page = 0, size = 20) }
assertThrows(IllegalStateException::class.java) { facade.getFirstAudioContents(member, page = 0, size = 20) }
assertThrows(IllegalStateException::class.java) { facade.getAiCharacters(member, page = 0, size = 20) } assertThrows(IllegalStateException::class.java) { facade.getAiCharacters(member, page = 0, size = 20) }
assertTrue(output.out.contains("section=DEBUT_CREATOR")) assertTrue(output.out.contains("section=DEBUT_CREATOR"))
assertTrue(output.out.contains("section=FIRST_AUDIO_CONTENT"))
assertTrue(output.out.contains("section=AI_CHARACTER")) assertTrue(output.out.contains("section=AI_CHARACTER"))
} }
@@ -362,31 +351,43 @@ class HomeRecommendationControllerTest @Autowired constructor(
} }
@Test @Test
@DisplayName("첫 오디오/AI 캐릭터 전체보기도 같은 페이징 응답 형식을 사용한다") @DisplayName("AI 캐릭터 전체보기도 같은 페이징 응답 형식을 사용한다")
fun shouldReturnPagedSectionsWithSameFormat() { fun shouldReturnPagedSectionsWithSameFormat() {
val member = saveMember("paged-section-viewer", MemberRole.USER) val member = saveMember("paged-section-viewer", MemberRole.USER)
entityManager.flush() entityManager.flush()
entityManager.clear() entityManager.clear()
for (path in listOf("/first-audio-contents", "/ai-characters")) { mockMvc.perform(
mockMvc.perform( get("/api/v2/home/recommendations/ai-characters")
get("/api/v2/home/recommendations$path") .with(user(MemberAdapter(member)))
.with(user(MemberAdapter(member))) .param("page", "1")
.param("page", "1") .param("size", "10")
.param("size", "10") )
) .andExpect(status().isOk)
.andExpect(status().isOk) .andExpect(jsonPath("$.data.items").isArray)
.andExpect(jsonPath("$.data.items").isArray) .andExpect(jsonPath("$.data.page").value(1))
.andExpect(jsonPath("$.data.page").value(1)) .andExpect(jsonPath("$.data.size").value(10))
.andExpect(jsonPath("$.data.size").value(10)) .andExpect(jsonPath("$.data.hasNext").isBoolean)
.andExpect(jsonPath("$.data.hasNext").isBoolean) }
}
@Test
@DisplayName("미배포 first-audio-contents 홈 하위 endpoint는 제거된다")
fun shouldNotExposeDeprecatedFirstAudioContentsEndpoint() {
val member = saveMember("home-viewer", MemberRole.USER)
entityManager.flush()
entityManager.clear()
mockMvc.perform(
get("/api/v2/home/recommendations/first-audio-contents")
.with(user(MemberAdapter(member)))
)
.andExpect(status().isNotFound)
} }
@Test @Test
@DisplayName("세부 전체보기 API는 비회원 요청을 거부한다") @DisplayName("세부 전체보기 API는 비회원 요청을 거부한다")
fun shouldRejectAnonymousSectionPages() { fun shouldRejectAnonymousSectionPages() {
for (path in listOf("/lives", "/debut-creators", "/first-audio-contents", "/ai-characters")) { for (path in listOf("/lives", "/debut-creators", "/ai-characters")) {
mockMvc.perform(get("/api/v2/home/recommendations$path")) mockMvc.perform(get("/api/v2/home/recommendations$path"))
.andExpect(status().isUnauthorized) .andExpect(status().isUnauthorized)
} }

View File

@@ -22,7 +22,7 @@ class HomeOnAirLiveFacadeTest {
val member = createMember(100L) val member = createMember(100L)
Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member) Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member)
Mockito.doReturn((1L..21L).map { record(it) }).`when`(queryService).findLiveRecommendations( Mockito.doReturn((1L..21L).map { record(it) }).`when`(queryService).findLiveRecommendations(
eqValue(0), eqValue(0L),
eqValue(21), eqValue(21),
eqValue(member.id), eqValue(member.id),
eqValue(true) eqValue(true)
@@ -34,7 +34,7 @@ class HomeOnAirLiveFacadeTest {
assertEquals(20, response.size) assertEquals(20, response.size)
assertEquals(true, response.hasNext) assertEquals(true, response.hasNext)
assertEquals(20, response.items.size) assertEquals(20, response.items.size)
Mockito.verify(queryService).findLiveRecommendations(eqValue(0), eqValue(21), eqValue(member.id), eqValue(true)) Mockito.verify(queryService).findLiveRecommendations(eqValue(0L), eqValue(21), eqValue(member.id), eqValue(true))
} }
@Test @Test
@@ -43,7 +43,7 @@ class HomeOnAirLiveFacadeTest {
val member = createMember(100L) val member = createMember(100L)
Mockito.doReturn(false).`when`(preferenceService).canViewAdultContent(member) Mockito.doReturn(false).`when`(preferenceService).canViewAdultContent(member)
Mockito.doReturn(listOf(record(1L, creatorProfileImage = null))).`when`(queryService).findLiveRecommendations( Mockito.doReturn(listOf(record(1L, creatorProfileImage = null))).`when`(queryService).findLiveRecommendations(
eqValue(0), eqValue(0L),
eqValue(21), eqValue(21),
eqValue(member.id), eqValue(member.id),
eqValue(false) eqValue(false)
@@ -60,7 +60,7 @@ class HomeOnAirLiveFacadeTest {
val member = createMember(100L) val member = createMember(100L)
Mockito.doReturn(false).`when`(preferenceService).canViewAdultContent(member) Mockito.doReturn(false).`when`(preferenceService).canViewAdultContent(member)
Mockito.doReturn(listOf(record(1L, beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30)))).`when`(queryService) Mockito.doReturn(listOf(record(1L, beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30)))).`when`(queryService)
.findLiveRecommendations(eqValue(0), eqValue(21), eqValue(member.id), eqValue(false)) .findLiveRecommendations(eqValue(0L), eqValue(21), eqValue(member.id), eqValue(false))
val response = facade.getOnAirLives(member, page = 0) val response = facade.getOnAirLives(member, page = 0)

View File

@@ -1,6 +1,9 @@
package kr.co.vividnext.sodalive.v2.content.recommendation.application package kr.co.vividnext.sodalive.v2.content.recommendation.application
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.MemberContentPreferenceService
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.AudioRecommendationVisibility
import kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort 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.domain.RecommendedSectionType
@@ -51,7 +54,7 @@ class AudioRecommendationQueryServiceTest {
.findLatestSnapshots( .findLatestSnapshots(
RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE,
0, 0,
AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT
) )
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>()) Mockito.doReturn(emptyList<RecommendationSnapshotRecord>())
.`when`(snapshotPort) .`when`(snapshotPort)
@@ -100,7 +103,7 @@ class AudioRecommendationQueryServiceTest {
.findLatestSnapshots( .findLatestSnapshots(
RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE,
0, 0,
AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT
) )
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>()) Mockito.doReturn(emptyList<RecommendationSnapshotRecord>())
.`when`(snapshotPort) .`when`(snapshotPort)
@@ -127,19 +130,14 @@ class AudioRecommendationQueryServiceTest {
@Test @Test
@DisplayName("인증 회원 성인 정책은 조회용 저장 preference를 사용한다") @DisplayName("인증 회원 성인 정책은 조회용 저장 preference를 사용한다")
fun shouldUseStoredPreferenceForMemberAdultVisibility() { fun shouldUseStoredPreferenceForMemberAdultVisibility() {
val member = kr.co.vividnext.sodalive.member.Member( val member = member(id = 10L)
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(true).`when`(preferenceService).canViewAdultContent(member)
Mockito.doReturn(listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 10L))) Mockito.doReturn(listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 10L)))
.`when`(snapshotPort) .`when`(snapshotPort)
.findLatestSnapshots( .findLatestSnapshots(
RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL,
0, 0,
AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT
) )
service.getRecommendations(member) service.getRecommendations(member)
@@ -149,10 +147,52 @@ class AudioRecommendationQueryServiceTest {
Mockito.verify(snapshotPort).findLatestSnapshots( Mockito.verify(snapshotPort).findLatestSnapshots(
RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL,
0, 0,
AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT
) )
} }
@Test
@DisplayName("추천 탭 첫 화면은 New & Hot 스냅샷을 12개만 조회한다")
fun shouldKeepNewAndHotHomeLimitAtTwelve() {
val member = member(id = 10L)
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_HOME_LIMIT
)
service.getRecommendations(member)
Mockito.verify(snapshotPort).findLatestSnapshots(
RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL,
0,
AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT
)
}
@Test
@DisplayName("New & Hot 전체보기는 스냅샷 offset과 limit으로 오디오 카드를 조회한다")
fun shouldFindNewAndHotAudiosWithOffsetAndLimit() {
val member = member(id = 10L)
val snapshots = listOf(
snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 3L),
snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 4L),
snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 5L)
)
Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member)
Mockito.doReturn(snapshots).`when`(snapshotPort)
.findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 20L, 21)
Mockito.doReturn(listOf(audioCard(3L), audioCard(4L), audioCard(5L))).`when`(queryPort)
.findAudioCardsByIds(eqValue(listOf(3L, 4L, 5L)), eqValue(member.id), eqValue(true), anyLocalDateTime())
val result = service.findNewAndHotAudios(member, offset = 20L, limit = 21)
assertEquals(listOf(3L, 4L, 5L), result.map { it.audioContentId })
Mockito.verify(snapshotPort).findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 20L, 21)
}
@Test @Test
@DisplayName("visibility와 섹션 조합은 신규 RecommendedSectionType으로 매핑된다") @DisplayName("visibility와 섹션 조합은 신규 RecommendedSectionType으로 매핑된다")
fun shouldMapVisibilityToAudioSectionTypes() { fun shouldMapVisibilityToAudioSectionTypes() {
@@ -186,6 +226,32 @@ class AudioRecommendationQueryServiceTest {
return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now() return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
} }
private fun member(id: Long): Member {
return Member(
email = "viewer$id@test.com",
password = "password",
nickname = "viewer$id",
role = MemberRole.USER
).apply {
this.id = id
}
}
private fun audioCard(id: Long): AudioCard {
return AudioCard(
audioContentId = id,
title = "audio$id",
duration = "00:01",
imageUrl = "https://cdn.test/audio$id.png",
price = id.toInt(),
isAdult = false,
isPointAvailable = true,
isFirstContent = true,
isOriginalSeries = false,
creatorNickname = "creator$id"
)
}
private fun snapshot(sectionType: RecommendedSectionType, targetId: Long): RecommendationSnapshotRecord { private fun snapshot(sectionType: RecommendedSectionType, targetId: Long): RecommendationSnapshotRecord {
return RecommendationSnapshotRecord( return RecommendationSnapshotRecord(
sectionType = sectionType, sectionType = sectionType,

View File

@@ -30,7 +30,7 @@ class AudioRecommendationSnapshotRefreshServiceTest {
newAndHotWindowStart, newAndHotWindowStart,
snapshotAt, snapshotAt,
AudioRecommendationVisibility.SAFE, AudioRecommendationVisibility.SAFE,
AudioRecommendationSnapshotRefreshService.NEW_AND_HOT_LIMIT AudioRecommendationSnapshotRefreshService.NEW_AND_HOT_SNAPSHOT_LIMIT
) )
Mockito.verify(queryPort).findMostCommentedSnapshots( Mockito.verify(queryPort).findMostCommentedSnapshots(
mostCommentedWindowStart, mostCommentedWindowStart,
@@ -66,7 +66,7 @@ class AudioRecommendationSnapshotRefreshServiceTest {
newAndHotWindowStart, newAndHotWindowStart,
snapshotAt, snapshotAt,
AudioRecommendationVisibility.SAFE, AudioRecommendationVisibility.SAFE,
AudioRecommendationSnapshotRefreshService.NEW_AND_HOT_LIMIT AudioRecommendationSnapshotRefreshService.NEW_AND_HOT_SNAPSHOT_LIMIT
) )
Mockito.verify(queryPort).findMostCommentedSnapshots( Mockito.verify(queryPort).findMostCommentedSnapshots(
mostCommentedWindowStart, mostCommentedWindowStart,
@@ -81,4 +81,27 @@ class AudioRecommendationSnapshotRefreshServiceTest {
AudioRecommendationSnapshotRefreshService.RECOMMENDED_AUDIO_LIMIT AudioRecommendationSnapshotRefreshService.RECOMMENDED_AUDIO_LIMIT
) )
} }
@Test
@DisplayName("New & Hot 스냅샷은 visibility별 100개 후보를 저장한다")
fun shouldRequestOneHundredNewAndHotSnapshotsPerVisibility() {
val now = LocalDateTime.of(2026, 6, 27, 0, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 6, 26, 23, 59, 59)
val windowStart = LocalDateTime.of(2026, 6, 24, 0, 0, 0)
service.refreshDailySnapshots(now)
Mockito.verify(queryPort).findNewAndHotSnapshots(
windowStart,
snapshotAt,
AudioRecommendationVisibility.SAFE,
100
)
Mockito.verify(queryPort).findNewAndHotSnapshots(
windowStart,
snapshotAt,
AudioRecommendationVisibility.ALL,
100
)
}
} }

View File

@@ -99,8 +99,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val oldest = saveLiveRoom(creator, baseAt, channelName = "paged-live-oldest", isAdult = false) val oldest = saveLiveRoom(creator, baseAt, channelName = "paged-live-oldest", isAdult = false)
flushAndClear() flushAndClear()
val page0 = repository.findLiveRecommendations(offset = 0, limit = 3, includeAdultLives = false) val page0 = repository.findLiveRecommendations(offset = 0L, limit = 3, includeAdultLives = false)
val page1 = repository.findLiveRecommendations(offset = 2, limit = 3, includeAdultLives = false) val page1 = repository.findLiveRecommendations(offset = 2L, limit = 3, includeAdultLives = false)
assertEquals(listOf(newest.id, middle.id, oldest.id), page0.map { it.liveRoomId }) assertEquals(listOf(newest.id, middle.id, oldest.id), page0.map { it.liveRoomId })
assertEquals(listOf(oldest.id), page1.map { it.liveRoomId }) assertEquals(listOf(oldest.id), page1.map { it.liveRoomId })
@@ -1032,8 +1032,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
updateCreatedAt("AudioContentLike", like.id!!, now.minusHours(1)) updateCreatedAt("AudioContentLike", like.id!!, now.minusHours(1))
flushAndClear() flushAndClear()
val page0 = repository.findRecentDebutCreators(now, offset = 0, limit = 2, includeAdultContents = false) val page0 = repository.findRecentDebutCreators(now, offset = 0L, limit = 2, includeAdultContents = false)
val page1 = repository.findRecentDebutCreators(now, offset = 1, limit = 2, includeAdultContents = false) val page1 = repository.findRecentDebutCreators(now, offset = 1L, limit = 2, includeAdultContents = false)
assertEquals(listOf(normalNewest.id, normalOldest.id), page0.map { it.creatorId }) assertEquals(listOf(normalNewest.id, normalOldest.id), page0.map { it.creatorId })
assertEquals(listOf(normalOldest.id), page1.map { it.creatorId }) assertEquals(listOf(normalOldest.id), page1.map { it.creatorId })
@@ -1071,9 +1071,9 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
saveAudioContent(creator3, now.minusDays(5), isActive = true) saveAudioContent(creator3, now.minusDays(5), isActive = true)
flushAndClear() flushAndClear()
val page0 = repository.findRecentDebutCreators(now, offset = 0, limit = 1, includeAdultContents = false) val page0 = repository.findRecentDebutCreators(now, offset = 0L, limit = 1, includeAdultContents = false)
val page1 = repository.findRecentDebutCreators(now, offset = 1, limit = 1, includeAdultContents = false) val page1 = repository.findRecentDebutCreators(now, offset = 1L, limit = 1, includeAdultContents = false)
val page2 = repository.findRecentDebutCreators(now, offset = 2, limit = 1, includeAdultContents = false) val page2 = repository.findRecentDebutCreators(now, offset = 2L, limit = 1, includeAdultContents = false)
val pagedCreatorIds = (page0 + page1 + page2).map { it.creatorId } val pagedCreatorIds = (page0 + page1 + page2).map { it.creatorId }
assertEquals(setOf(creator1.id, creator2.id, creator3.id), pagedCreatorIds.toSet()) assertEquals(setOf(creator1.id, creator2.id, creator3.id), pagedCreatorIds.toSet())
@@ -1157,13 +1157,32 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val oldest = saveAudioContent(oldestCreator, now.minusDays(21), isActive = true, isAdult = false) val oldest = saveAudioContent(oldestCreator, now.minusDays(21), isActive = true, isAdult = false)
flushAndClear() flushAndClear()
val page0 = repository.findFirstAudioContents(now, offset = 0, limit = 2, includeAdultContents = false) val page0 = repository.findFirstAudioContents(now, offset = 0L, limit = 2, includeAdultContents = false)
val page1 = repository.findFirstAudioContents(now, offset = 1, limit = 2, includeAdultContents = false) val page1 = repository.findFirstAudioContents(now, offset = 1L, limit = 2, includeAdultContents = false)
assertEquals(listOf(newest.id, oldest.id), page0.map { it.contentId }) assertEquals(listOf(newest.id, oldest.id), page0.map { it.contentId })
assertEquals(listOf(oldest.id), page1.map { it.contentId }) assertEquals(listOf(oldest.id), page1.map { it.contentId })
} }
@Test
@DisplayName("첫 오디오 콘텐츠는 성인 여부와 오리지널 시리즈 여부를 함께 조회한다")
fun shouldMapFirstAudioContentAdultAndOriginalSeriesFlags() {
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
val creator = saveMember("first-audio-flags", MemberRole.CREATOR)
val content = saveAudioContent(creator, now.minusDays(1), isActive = true, isAdult = false)
val series = saveSeries("first-audio-original-series", creator, isActive = true).apply {
isOriginal = true
}
saveSeriesContent(series, content)
updateCreatedAt("AudioContent", content.id!!, now.minusDays(1))
flushAndClear()
val contents = repository.findFirstAudioContents(now, limit = 10)
assertEquals(false, contents.single().isAdult)
assertEquals(true, contents.single().isOriginalSeries)
}
@Test @Test
@DisplayName("첫 오디오 콘텐츠는 회원과 크리에이터의 양방향 차단 관계를 제외한다") @DisplayName("첫 오디오 콘텐츠는 회원과 크리에이터의 양방향 차단 관계를 제외한다")
fun shouldExcludeBidirectionalBlockedCreatorsFromFirstAudioContents() { fun shouldExcludeBidirectionalBlockedCreatorsFromFirstAudioContents() {
@@ -1196,9 +1215,9 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val content3 = saveAudioContent(creator3, now.minusDays(5), isActive = true) val content3 = saveAudioContent(creator3, now.minusDays(5), isActive = true)
flushAndClear() flushAndClear()
val page0 = repository.findFirstAudioContents(now, offset = 0, limit = 1, includeAdultContents = false) val page0 = repository.findFirstAudioContents(now, offset = 0L, limit = 1, includeAdultContents = false)
val page1 = repository.findFirstAudioContents(now, offset = 1, limit = 1, includeAdultContents = false) val page1 = repository.findFirstAudioContents(now, offset = 1L, limit = 1, includeAdultContents = false)
val page2 = repository.findFirstAudioContents(now, offset = 2, limit = 1, includeAdultContents = false) val page2 = repository.findFirstAudioContents(now, offset = 2L, limit = 1, includeAdultContents = false)
val pagedContentIds = (page0 + page1 + page2).map { it.contentId } val pagedContentIds = (page0 + page1 + page2).map { it.contentId }
assertEquals(setOf(content1.id, content2.id, content3.id), pagedContentIds.toSet()) assertEquals(setOf(content1.id, content2.id, content3.id), pagedContentIds.toSet())

View File

@@ -615,7 +615,7 @@ class HomeRecommendationQueryServiceTest {
private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort { private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort {
var liveLimit: Int? = null var liveLimit: Int? = null
var liveOffset: Int? = null var liveOffset: Long? = null
var liveMemberId: Long? = null var liveMemberId: Long? = null
var liveIncludeAdultLives: Boolean? = null var liveIncludeAdultLives: Boolean? = null
var bannerLimit: Int? = null var bannerLimit: Int? = null
@@ -625,12 +625,12 @@ class HomeRecommendationQueryServiceTest {
var activeCreatorIncludeAdultActivities: Boolean? = null var activeCreatorIncludeAdultActivities: Boolean? = null
var recentDebutNow: LocalDateTime? = null var recentDebutNow: LocalDateTime? = null
var recentDebutLimit: Int? = null var recentDebutLimit: Int? = null
var recentDebutOffset: Int? = null var recentDebutOffset: Long? = null
var recentDebutMemberId: Long? = null var recentDebutMemberId: Long? = null
var recentDebutIncludeAdultContents: Boolean? = null var recentDebutIncludeAdultContents: Boolean? = null
var firstAudioNow: LocalDateTime? = null var firstAudioNow: LocalDateTime? = null
var firstAudioLimit: Int? = null var firstAudioLimit: Int? = null
var firstAudioOffset: Int? = null var firstAudioOffset: Long? = null
var firstAudioMemberId: Long? = null var firstAudioMemberId: Long? = null
var firstAudioIncludeAdultContents: Boolean? = null var firstAudioIncludeAdultContents: Boolean? = null
var aiCharacterDetailIds: List<Long> = emptyList() var aiCharacterDetailIds: List<Long> = emptyList()
@@ -688,7 +688,9 @@ class HomeRecommendationQueryServiceTest {
title = "first-audio", title = "first-audio",
price = 10, price = 10,
coverImage = "first-audio.png", coverImage = "first-audio.png",
isPointAvailable = true isPointAvailable = true,
isAdult = false,
isOriginalSeries = false
) )
) )
var aiCharacterDetails: List<HomeAiCharacterRecommendationRecord> = emptyList() var aiCharacterDetails: List<HomeAiCharacterRecommendationRecord> = emptyList()
@@ -699,7 +701,7 @@ class HomeRecommendationQueryServiceTest {
var genreCreatorRecommendations: List<HomeGenreCreatorRecommendationGroup> = emptyList() var genreCreatorRecommendations: List<HomeGenreCreatorRecommendationGroup> = emptyList()
override fun findLiveRecommendations( override fun findLiveRecommendations(
offset: Int, offset: Long,
limit: Int, limit: Int,
memberId: Long?, memberId: Long?,
includeAdultLives: Boolean includeAdultLives: Boolean
@@ -730,7 +732,7 @@ class HomeRecommendationQueryServiceTest {
override fun findRecentDebutCreators( override fun findRecentDebutCreators(
now: LocalDateTime, now: LocalDateTime,
offset: Int, offset: Long,
limit: Int, limit: Int,
memberId: Long?, memberId: Long?,
includeAdultContents: Boolean includeAdultContents: Boolean
@@ -745,7 +747,7 @@ class HomeRecommendationQueryServiceTest {
override fun findFirstAudioContents( override fun findFirstAudioContents(
now: LocalDateTime, now: LocalDateTime,
offset: Int, offset: Long,
limit: Int, limit: Int,
memberId: Long?, memberId: Long?,
includeAdultContents: Boolean includeAdultContents: Boolean
@@ -821,7 +823,7 @@ private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort {
override fun findLatestSnapshots( override fun findLatestSnapshots(
sectionType: RecommendedSectionType, sectionType: RecommendedSectionType,
offset: Int, offset: Long,
limit: Int limit: Int
): List<RecommendationSnapshotRecord> { ): List<RecommendationSnapshotRecord> {
val latestSnapshotAt = snapshots val latestSnapshotAt = snapshots
@@ -832,8 +834,8 @@ private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort {
.filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt } .filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt }
.sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker }) .sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker })
if (offset == 0 && limit == Int.MAX_VALUE) return all if (offset == 0L && limit == Int.MAX_VALUE) return all
return all.drop(offset).take(limit) return all.drop(offset.toInt()).take(limit)
} }
override fun replaceSnapshots( override fun replaceSnapshots(

View File

@@ -256,7 +256,7 @@ private class FakeRecommendationSnapshotPort : RecommendationSnapshotPort {
override fun findLatestSnapshots( override fun findLatestSnapshots(
sectionType: RecommendedSectionType, sectionType: RecommendedSectionType,
offset: Int, offset: Long,
limit: Int limit: Int
): List<RecommendationSnapshotRecord> { ): List<RecommendationSnapshotRecord> {
val latestSnapshotAt = snapshots val latestSnapshotAt = snapshots
@@ -267,8 +267,8 @@ private class FakeRecommendationSnapshotPort : RecommendationSnapshotPort {
.filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt } .filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt }
.sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker }) .sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker })
if (offset == 0 && limit == Int.MAX_VALUE) return all if (offset == 0L && limit == Int.MAX_VALUE) return all
return all.drop(offset).take(limit) return all.drop(offset.toInt()).take(limit)
} }
override fun replaceSnapshots( override fun replaceSnapshots(