Compare commits
3 Commits
579a48c2f1
...
631b63f5f1
| Author | SHA1 | Date | |
|---|---|---|---|
| 631b63f5f1 | |||
| 0193b77700 | |||
| 8538880d81 |
90
docs/20260805_추천탭_배너_조회조건/plan-task.md
Normal file
90
docs/20260805_추천탭_배너_조회조건/plan-task.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# 추천 탭 배너 조회 조건 보정 Implementation Plan
|
||||||
|
|
||||||
|
| 문서 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 상태 | 구현 완료 |
|
||||||
|
| 작성일 | 2026-08-05 |
|
||||||
|
| 요구사항 기준 | `docs/20260805_추천탭_배너_조회조건/prd.md` |
|
||||||
|
| API 기준 | 기존 공개 API 계약 유지 |
|
||||||
|
| 현재 Phase | Phase 1: 배너 조회 조건 보정 |
|
||||||
|
| 현재 활성 Goal | 없음 |
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
메인 홈 추천과 메인 콘텐츠 추천에서 화면별 탭과 회원의 성인 콘텐츠 조회 가능 여부에 맞는 배너만 조회한다.
|
||||||
|
|
||||||
|
## 현재 상태
|
||||||
|
|
||||||
|
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||||
|
|---:|---|---:|---|---|
|
||||||
|
| 1 | 완료 | `2/2` | 없음 | 없음 |
|
||||||
|
|
||||||
|
## 범위
|
||||||
|
|
||||||
|
### 포함
|
||||||
|
|
||||||
|
- 홈 추천 배너의 `tab_id IS NULL` 조건 유지 및 성인 배너 필터 추가
|
||||||
|
- 콘텐츠 추천 배너의 `tab_id = 2` 조건과 성인 배너 필터 추가
|
||||||
|
- application에서 계산한 성인 콘텐츠 조회 가능 여부를 persistence port까지 전달
|
||||||
|
- 관련 service/facade/Repository focused test와 직접 영향 범위 회귀
|
||||||
|
|
||||||
|
### 제외
|
||||||
|
|
||||||
|
- 공개 API endpoint와 응답 DTO 변경
|
||||||
|
- 배너 언어/활성/차단/대상 유효성/정렬/limit 정책 변경
|
||||||
|
- DB 데이터 또는 스키마 변경
|
||||||
|
|
||||||
|
## 기술적 제약
|
||||||
|
|
||||||
|
- Kotlin, Spring Boot 2.7.14, QueryDSL 기존 패턴을 유지한다.
|
||||||
|
- 성인 콘텐츠 조회 가능 여부는 `MemberContentPreferenceService.canViewAdultContent(member)` 결과를 재사용한다.
|
||||||
|
- 조회 가능 여부가 `false`이면 `audioContentBanner.isAdult.isFalse`, `true`이면 성인 조건을 추가하지 않는다.
|
||||||
|
- 신규 공통 추상화나 의존성을 추가하지 않는다.
|
||||||
|
|
||||||
|
### Phase 1: 배너 조회 조건 보정
|
||||||
|
|
||||||
|
- [x] **Task 1.1: 추천 API별 탭 및 성인 배너 조회 조건 구현 (`P1-T1`)**
|
||||||
|
- Objective: 두 추천 API의 배너가 확정된 탭과 성인 콘텐츠 조회 정책에 따라 반환된다.
|
||||||
|
- 시작 조건: PRD `BANNER-001~004`가 확정되어 있다.
|
||||||
|
- 완료 증거: 신규/보강 테스트가 RED 후 GREEN이고 관련 production/test 코드가 컴파일된다.
|
||||||
|
- 범위 밖: 언어 필터와 공개 응답 스키마 변경.
|
||||||
|
- Modify:
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
||||||
|
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt`
|
||||||
|
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
||||||
|
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||||
|
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
||||||
|
- RED: 홈 성인 조회 플래그 전달, 홈 비성인 필터, 콘텐츠 `tab_id = 2` 및 비성인 필터를 검증하는 테스트를 먼저 작성하고 실패를 확인한다.
|
||||||
|
- GREEN: 기존 QueryDSL 조건에 필요한 탭/성인 조건만 추가하고 홈 application 경로에 플래그를 전달한다.
|
||||||
|
- REFACTOR: 중복되지 않는 기존 조건 helper 패턴을 따르고 불필요한 변경이 없는지 확인한다.
|
||||||
|
- Verify:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
||||||
|
- 검증 기록:
|
||||||
|
- RED: 두 Repository 테스트를 실행해 `shouldExcludeAdultHomeBannersWhenAdultContentIsNotVisible`, `shouldFindBannersForContentRecommendationTabWithAdultVisibility` 두 건의 기대값 실패를 확인했다.
|
||||||
|
- RED: 홈 service 테스트는 `includeAdultBanners` 미구현 컴파일 실패, Facade 테스트는 플래그 미전달 assertion 실패를 각각 확인했다.
|
||||||
|
- GREEN: 위 4개 focused test class를 함께 실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
|
||||||
|
- [x] **Task 1.2: 영향 범위 회귀 및 문서 검증 (`P1-GATE`)**
|
||||||
|
- Objective: 추천 배너 변경이 기존 API 계약과 코드 품질 규칙을 깨지 않았음을 확인한다.
|
||||||
|
- 시작 조건: `P1-T1`이 완료되어 있다.
|
||||||
|
- 완료 증거: focused test, `ktlintCheck`, `tasks --all`, `git diff --check`가 통과하고 결과가 기록되어 있다.
|
||||||
|
- 범위 밖: 전체 회귀 테스트. 변경이 배너 조회 경로 두 곳에 한정되어 targeted test로 직접 영향 범위를 판단할 수 있으므로 생략한다.
|
||||||
|
- Verify:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
||||||
|
- `./gradlew ktlintCheck`
|
||||||
|
- `./gradlew tasks --all`
|
||||||
|
- `git diff --check`
|
||||||
|
- 검증 기록:
|
||||||
|
- `HomeRecommendationControllerTest`, `AudioRecommendationControllerTest`, `AudioRecommendationEndToEndTest`를 함께 실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- `./gradlew ktlintCheck`: `BUILD SUCCESSFUL`.
|
||||||
|
- `./gradlew tasks --all`: `BUILD SUCCESSFUL`.
|
||||||
|
- `git diff --check`: 출력 없음.
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
|
||||||
|
- 최초 Gradle 실행은 sandbox의 `~/.gradle` wrapper lock 접근 제한으로 실패했으며, 승인된 동일 명령을 재실행해 검증을 완료했다.
|
||||||
|
- 전체 회귀 테스트는 실행하지 않았다. 변경이 두 배너 조회 조건과 홈 플래그 전달에 한정되어 focused Repository/application 테스트와 두 API의 controller/E2E 회귀로 직접 영향 범위를 검증했다.
|
||||||
71
docs/20260805_추천탭_배너_조회조건/prd.md
Normal file
71
docs/20260805_추천탭_배너_조회조건/prd.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# PRD: 추천 탭 배너 조회 조건 보정
|
||||||
|
|
||||||
|
## 문서 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 문서 상태 | 구현 완료 |
|
||||||
|
| 작성일 | 2026-08-05 |
|
||||||
|
| 최종 수정일 | 2026-08-05 |
|
||||||
|
| 대상 제품 | 메인 홈 추천 탭, 메인 콘텐츠 추천 탭 |
|
||||||
|
| 작성자·결정권자 | 사용자 |
|
||||||
|
| 관련 구현 계획 | `docs/20260805_추천탭_배너_조회조건/plan-task.md` |
|
||||||
|
| 관련 기존 문서 | `docs/20260529_메인_홈_추천_API/prd.md`, `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md` |
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
메인 홈 추천 API와 메인 콘텐츠 추천 API의 배너 조회에 회원의 성인 콘텐츠 조회 가능 여부를 반영하고, 두 화면이 서로 다른 탭의 배너를 조회하도록 조건을 보정한다.
|
||||||
|
|
||||||
|
## 2. Problem Statement
|
||||||
|
|
||||||
|
- 두 추천 API가 모두 `content_banner.tab_id IS NULL`인 같은 배너를 조회한다.
|
||||||
|
- 두 추천 API의 배너 조회가 `content_banner.is_adult`를 필터링하지 않아 성인 콘텐츠 조회 불가 사용자에게 성인 배너가 노출될 수 있다.
|
||||||
|
|
||||||
|
문제를 해결했다는 판단은 두 API의 탭 조건과 성인 배너 노출 조건이 Repository 테스트로 구분되어 검증되는 것으로 한다.
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
|
||||||
|
- 성인 콘텐츠 조회 불가 사용자는 `is_adult = false`인 배너만 조회한다.
|
||||||
|
- 성인 콘텐츠 조회 가능 사용자는 성인·비성인 배너를 모두 조회한다.
|
||||||
|
- 메인 홈 추천 API는 기존처럼 `tab_id IS NULL`인 배너를 조회한다.
|
||||||
|
- 메인 콘텐츠 추천 API는 `tab_id = 2`인 배너를 조회한다.
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
|
||||||
|
- 공개 API endpoint와 응답 DTO를 변경하지 않는다.
|
||||||
|
- 배너 언어 필터, 정렬, 최대 조회 개수, 대상 활성/차단 정책을 변경하지 않는다.
|
||||||
|
- 배너 또는 탭 데이터와 DB 스키마를 변경하지 않는다.
|
||||||
|
|
||||||
|
## 5. 기능 요구사항
|
||||||
|
|
||||||
|
| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `BANNER-001` | 확정 | `GET /api/v2/home/recommendations`의 배너는 `tab_id IS NULL` 조건을 유지한다. | 탭이 없는 활성 배너만 조회하고 `tab_id = 2` 배너는 조회하지 않는다. | `P1-T1` |
|
||||||
|
| `BANNER-002` | 확정 | `GET /api/v2/audio/recommendations`의 배너는 `tab_id = 2` 조건을 사용한다. | `tab_id = 2`인 활성 배너만 조회하고 탭이 없는 배너는 조회하지 않는다. | `P1-T1` |
|
||||||
|
| `BANNER-003` | 확정 | 두 API 모두 회원의 성인 콘텐츠 조회 가능 여부를 배너 조회에 반영한다. | 조회 불가이면 비성인 배너만, 조회 가능이면 성인·비성인 배너를 모두 반환한다. | `P1-T1` |
|
||||||
|
| `BANNER-004` | 확정 | 기존 배너 활성/차단/대상 유효성/정렬/limit 정책을 유지한다. | 기존 관련 Repository 테스트가 계속 통과한다. | `P1-GATE` |
|
||||||
|
|
||||||
|
## 6. API 계약
|
||||||
|
|
||||||
|
| Method | Path | 변경 내용 |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/api/v2/home/recommendations` | 응답 스키마 변경 없이 배너의 성인 조회 조건만 추가한다. |
|
||||||
|
| `GET` | `/api/v2/audio/recommendations` | 응답 스키마 변경 없이 배너 탭 조건을 `tab_id = 2`로 변경하고 성인 조회 조건을 추가한다. |
|
||||||
|
|
||||||
|
## 7. 성공 기준
|
||||||
|
|
||||||
|
- [x] 홈 추천 배너가 `tab_id IS NULL` 조건을 유지한다.
|
||||||
|
- [x] 콘텐츠 추천 배너가 `tab_id = 2` 조건만 사용한다.
|
||||||
|
- [x] 성인 콘텐츠 조회 불가/가능 사용자의 배너 결과가 확정 정책과 일치한다.
|
||||||
|
- [x] 기존 배너 응답 스키마와 활성/차단/정렬/limit 정책이 유지된다.
|
||||||
|
|
||||||
|
## 8. Open Questions
|
||||||
|
|
||||||
|
- 없음.
|
||||||
|
|
||||||
|
## 9. Decision Log
|
||||||
|
|
||||||
|
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-08-05 | `DEC-001` | 확정 | 성인 조회 불가이면 비성인 배너만, 조회 가능이면 성인·비성인 배너를 모두 조회한다. | 사용자 답변 A | `BANNER-003`, `P1-T1` |
|
||||||
|
| 2026-08-05 | `DEC-002` | 확정 | 홈 추천은 `tab_id IS NULL`, 콘텐츠 추천은 `tab_id = 2`를 사용한다. | 사용자 직접 요구사항 | `BANNER-001`, `BANNER-002`, `P1-T1` |
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# 크리에이터 관리자 시리즈 상세 LazyInitializationException 수정 Plan/TASK
|
||||||
|
|
||||||
|
| 문서 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 상태 | 구현 완료 |
|
||||||
|
| 작성일 | 2026-08-05 |
|
||||||
|
| 요구사항 기준 | `docs/20260805_크리에이터관리자_시리즈상세_LazyInitializationException_수정/prd.md` |
|
||||||
|
| API 기준 | 기존 `GET /creator-admin/audio-content/series/{seriesId}` 계약 유지 |
|
||||||
|
| 현재 Phase | Phase 1 완료 |
|
||||||
|
| 현재 활성 Goal | 없음, 구현 완료 |
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
OSIV off 환경에서 크리에이터 관리자가 본인 시리즈 상세를 조회할 때 `Series.keywordList` lazy 초기화 예외 없이 기존 응답을 받게 한다.
|
||||||
|
|
||||||
|
## 현재 상태
|
||||||
|
|
||||||
|
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||||
|
|---:|---|---:|---|---|
|
||||||
|
| 1 | 완료 | `1/1` | 없음 | 없음 |
|
||||||
|
|
||||||
|
- 동시에 하나의 미완료 goal만 운용한다.
|
||||||
|
- 완료된 Task와 검증 기록은 되돌리거나 삭제하지 않는다.
|
||||||
|
|
||||||
|
## 범위
|
||||||
|
|
||||||
|
### 포함
|
||||||
|
|
||||||
|
- `CreatorAdminContentSeriesService`의 class-level read-only 트랜잭션 경계
|
||||||
|
- `Series.keywordList` lazy 예외를 재현하고 방지하는 서비스 통합 테스트
|
||||||
|
- 기존 상세 응답과 소유권 동작의 영향 범위 회귀 검증
|
||||||
|
|
||||||
|
### 제외
|
||||||
|
|
||||||
|
- endpoint와 `GetCreatorAdminContentSeriesDetailResponse` 변경
|
||||||
|
- OSIV, entity fetch 전략, repository query 변경
|
||||||
|
- 다른 시리즈 조회·수정 흐름 리팩터링
|
||||||
|
- 새 abstraction 또는 dependency 추가
|
||||||
|
|
||||||
|
## 기술적 제약
|
||||||
|
|
||||||
|
- Kotlin, Java 17, Spring Boot 2.7.14, Spring Data JPA, Hibernate, JUnit 5를 유지한다.
|
||||||
|
- production code는 `CreatorAdminContentSeriesService` class-level annotation 한 줄만 변경한다.
|
||||||
|
- 기존 쓰기 메서드 `createSeries()`, `modifySeries()`, `addingContentToTheSeries()`, `removeContentInTheSeries()`,
|
||||||
|
`updateSeriesOrders()`의 메서드 레벨 `@Transactional`은 유지해 class-level read-only 기본값을 재정의한다.
|
||||||
|
- 테스트는 실제 Spring 서비스 프록시를 사용하고 외부 테스트 트랜잭션으로 서비스 경계를 가리지 않는다.
|
||||||
|
- focused test부터 실행하고 직접 영향받는 characterization test까지만 회귀 범위를 확장한다.
|
||||||
|
- 전체 테스트는 class-level annotation 한 줄 변경과 targeted test로 영향 범위를 판정할 수 있으므로 기본적으로 생략한다. targeted test에서
|
||||||
|
범위를 설명할 수 없는 실패가 발생하거나 공통 경계 변경으로 확대될 때만 실행한다.
|
||||||
|
|
||||||
|
## Phase 1: 상세 조회 트랜잭션 회귀 수정
|
||||||
|
|
||||||
|
**Phase 결과:** 크리에이터 관리자 시리즈 상세 조회가 OSIV off 환경에서 키워드를 포함한 기존 DTO를 정상 반환한다.
|
||||||
|
|
||||||
|
**선행조건:** `CASD-001`~`CASD-003` 요구사항과 `DEC-CASD-002`, `DEC-CASD-003` 결정 확정.
|
||||||
|
|
||||||
|
**Phase 완료 조건:** `P1-T1`과 `P1-GATE` 완료, focused·영향 범위 회귀 결과 기록.
|
||||||
|
|
||||||
|
### 구현 항목
|
||||||
|
|
||||||
|
#### Task 1.1 상세 조회 lazy 예외 재현 및 최소 수정
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T1`:** 서비스 클래스의 기본 read-only 트랜잭션 안에서 상세 조회가 키워드 lazy 컬렉션을 DTO로 변환하게 한다.
|
||||||
|
|
||||||
|
- **시작 조건:** PRD 구현 기준 확정, production code 미수정 상태.
|
||||||
|
- **완료 증거:** RED/GREEN/REFACTOR 체크박스 완료, focused test 실제 실행 결과, class-level annotation 이외 production diff 없음,
|
||||||
|
기존 쓰기 메서드 annotation 유지.
|
||||||
|
- **범위 밖:** OSIV·entity mapping·repository query·controller·response DTO 변경.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesServiceIntegrationTest.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt`
|
||||||
|
- Modify: `docs/20260805_크리에이터관리자_시리즈상세_LazyInitializationException_수정/plan-task.md`
|
||||||
|
- Verify: `src/test/resources/application.yml`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `CreatorAdminContentSeriesService.getDetail(id: Long, memberId: Long)`과 기존 `Series.toDetailResponse(imageHost)`
|
||||||
|
- Produces: 기존 시그니처·DTO를 유지하면서 class-level read-only 트랜잭션 안에서 완성된
|
||||||
|
`GetCreatorAdminContentSeriesDetailResponse`
|
||||||
|
|
||||||
|
- [x] **RED:** `@SpringBootTest`, `EmbeddedRedisInitializer`, 실제 `CreatorAdminContentSeriesService` 빈을 사용하는 통합 테스트를 작성한다.
|
||||||
|
테스트 외부 트랜잭션은 사용하지 않고 `TransactionTemplate` 안에서 소유 회원, 장르, 시리즈, 해시태그와 `SeriesKeyword` fixture를
|
||||||
|
저장한 뒤 트랜잭션 밖에서 `service.getDetail()`을 호출해 `keywords`와 주요 상세 필드를 검증한다.
|
||||||
|
- [x] **RED 확인:** `./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesServiceIntegrationTest`
|
||||||
|
를 실행해 `Series.keywordList`의 `LazyInitializationException`으로 실패하는지 확인한다. 환경·fixture·컴파일 실패는 RED 증거로
|
||||||
|
인정하지 않고 먼저 바로잡는다.
|
||||||
|
- [x] **GREEN:** `CreatorAdminContentSeriesService` 클래스 선언 바로 위에 기존 import를 사용하는
|
||||||
|
`@Transactional(readOnly = true)` 한 줄을 추가하고 기존 쓰기 메서드의 메서드 레벨 `@Transactional`을 유지한다.
|
||||||
|
- [x] **GREEN 확인:** RED와 같은 focused test를 다시 실행해 `BUILD SUCCESSFUL`과 fixture의 `keywords`, `seriesId`,
|
||||||
|
`publishedDaysOfWeek`, `state` 값 일치를 확인한다.
|
||||||
|
- [x] **REFACTOR:** 새 abstraction 없이 테스트 fixture의 중복만 파일 내부 private helper로 제한한다. focused test와
|
||||||
|
`./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.LegacyCreatorAdminSeriesCharacterizationTest`
|
||||||
|
를 실행하고, production diff가 class-level annotation 한 줄이며 모든 기존 쓰기 메서드 annotation이 유지되는지 확인해 결과를
|
||||||
|
이 Task 아래에 기록한다.
|
||||||
|
|
||||||
|
### 완료 조건
|
||||||
|
|
||||||
|
- [x] `P1-T1`의 RED/GREEN/REFACTOR와 완료 증거가 모두 충족됐다.
|
||||||
|
- [x] `CASD-001`~`CASD-003`이 자동 검증 결과로 추적된다.
|
||||||
|
- [x] API 계약, OSIV, entity mapping, repository query에 변경이 없다.
|
||||||
|
|
||||||
|
### 검증 방법
|
||||||
|
|
||||||
|
#### Phase 1 Gate
|
||||||
|
|
||||||
|
**Goal 실행 `P1-GATE`:** 상세 조회 수정의 기능·회귀·문서 범위를 최종 판정한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T1` 완료.
|
||||||
|
- **완료 증거:** 아래 명령 통과 및 실제 결과를 검증 기록에 누적.
|
||||||
|
- **범위 밖:** Gate 통과를 위한 테스트 완화와 관련 없는 코드 수정.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesServiceIntegrationTest
|
||||||
|
./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.LegacyCreatorAdminSeriesCharacterizationTest
|
||||||
|
./gradlew --no-daemon ktlintCheck
|
||||||
|
./gradlew --no-daemon tasks --all
|
||||||
|
git diff --check
|
||||||
|
./gradlew --no-daemon test
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] focused test가 실제 실행되고 failures/errors 0으로 통과한다.
|
||||||
|
- [x] 기존 시리즈 상세·소유권 characterization test가 통과한다.
|
||||||
|
- [x] `ktlintCheck`, `tasks --all`, `git diff --check`가 통과한다.
|
||||||
|
- [x] ultrawork verification 요구에 따라 전체 테스트를 실행하고 통과했다.
|
||||||
|
- [x] production code 변경이 `CreatorAdminContentSeriesService`의 class-level `@Transactional(readOnly = true)` 한 줄뿐이다.
|
||||||
|
- [x] 기존 쓰기 메서드 5개의 메서드 레벨 `@Transactional`이 유지된다.
|
||||||
|
|
||||||
|
## 실행 순서
|
||||||
|
|
||||||
|
| 순서 | Goal | 완료 후 다음 Goal |
|
||||||
|
|---:|---|---|
|
||||||
|
| 1 | `P1-T1` | `P1-GATE` |
|
||||||
|
| 2 | `P1-GATE` | 구현 완료 |
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- 2026-08-05: 운영 stack trace와 controller → service → repository → entity DTO 변환 흐름을 확인했다.
|
||||||
|
- 2026-08-05: `Series.keywordList` lazy 접근과 트랜잭션 없는 `getDetail()`을 원인으로 확정했다.
|
||||||
|
- 2026-08-05: PRD와 Plan/TASK를 작성했으며 production code와 테스트는 아직 변경하지 않았다.
|
||||||
|
- 2026-08-05: 메서드 분류 재검토 결과 트랜잭션 없는 public 메서드 4개는 모두 조회이고, 쓰기 메서드 5개는 모두 메서드 레벨
|
||||||
|
`@Transactional`을 보유함을 확인했다. 해결안을 `getDetail()` 메서드 단위에서 서비스 class-level read-only 기본값으로 정정했다.
|
||||||
|
- 2026-08-05: `P1-T1` RED/GREEN/REFACTOR를 완료했다. 다음 Goal은 `P1-GATE`이며 Gate 체크박스는 아직 미완료다.
|
||||||
|
- 2026-08-05: `P1-GATE`의 focused·characterization·정적 검사·Gradle task 확인·전체 테스트를 모두 통과해 Phase 1과 구현을 완료했다.
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
|
||||||
|
- 문서 작성 시점에는 구현용 RED/GREEN 테스트를 실행하지 않았다.
|
||||||
|
- 2026-08-05: 문서 변경 후 `./gradlew --no-daemon tasks --all`로 계획에 사용한 Gradle 명령이 유효한지 확인했다.
|
||||||
|
- sandbox 실행은 `/Users/klaus/.gradle/wrapper/dists/.../gradle-8.1.1-bin.zip.lck` 접근 제한으로 실패했다.
|
||||||
|
- 승인 실행은 `BUILD SUCCESSFUL in 8s`로 통과했다.
|
||||||
|
- 2026-08-05: `git diff --check`가 출력 없이 통과했고, 구현 파일과 테스트 파일은 변경하지 않았음을 확인했다.
|
||||||
|
- 2026-08-05: class-level read-only 트랜잭션 기준으로 문서를 보완한 뒤 `./gradlew --no-daemon tasks --all`을 재실행해
|
||||||
|
`BUILD SUCCESSFUL in 7s`를 확인했다.
|
||||||
|
- 2026-08-05: production 수정 전 focused test를 실행해 `BUILD FAILED in 6m 50s`와
|
||||||
|
`org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: kr.co.vividnext.sodalive.creator.admin.content.series.Series.keywordList, could not initialize proxy - no Session`을 확인했다.
|
||||||
|
- 2026-08-05: `CreatorAdminContentSeriesService`에 `@Transactional(readOnly = true)` 한 줄을 추가한 뒤 같은 focused test를
|
||||||
|
재실행해 `BUILD SUCCESSFUL in 4m 52s`를 확인했다. 테스트는 fixture의 `seriesId`, `title`, `introduction`, `coverImageUrl`,
|
||||||
|
`publishedDaysOfWeek`, `genre`, `keywords`, `isAdult`, `state`, `writer`, `studio` 값을 검증한다.
|
||||||
|
- 2026-08-05: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.LegacyCreatorAdminSeriesCharacterizationTest`
|
||||||
|
를 실행해 `BUILD SUCCESSFUL in 47s`를 확인했다.
|
||||||
|
- 2026-08-05: production diff는 `CreatorAdminContentSeriesService` 클래스 선언 위의 `@Transactional(readOnly = true)` 한 줄뿐이며,
|
||||||
|
`createSeries()`, `modifySeries()`, `addingContentToTheSeries()`, `removeContentInTheSeries()`, `updateSeriesOrders()`의 기존 메서드 레벨
|
||||||
|
`@Transactional` 5개가 유지됨을 확인했다. API 계약, OSIV, entity mapping, repository query 변경은 없다.
|
||||||
|
- 2026-08-05: `P1-GATE`에서 명령을 순차 실행해 다음 결과를 확인했다.
|
||||||
|
- `./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesServiceIntegrationTest`: exit code 0, `BUILD SUCCESSFUL in 4m 44s`.
|
||||||
|
- `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.LegacyCreatorAdminSeriesCharacterizationTest`: exit code 0, `BUILD SUCCESSFUL in 41s`.
|
||||||
|
- `./gradlew --no-daemon ktlintCheck`: exit code 0, `BUILD SUCCESSFUL in 30s`.
|
||||||
|
- `./gradlew --no-daemon tasks --all`: exit code 0, `BUILD SUCCESSFUL in 7s`.
|
||||||
|
- `git diff --check`: exit code 0, 출력 없음.
|
||||||
|
- `./gradlew --no-daemon test`: exit code 0, `BUILD SUCCESSFUL in 6m 20s`. 기본 계획의 targeted 검증 범위를 넘어 전체 테스트를 실행한 이유는 ultrawork verification 요구사항 때문이다.
|
||||||
|
- 2026-08-05: Gate 검증 후 production diff가 class-level `@Transactional(readOnly = true)` 한 줄뿐이고, 새 테스트 파일은
|
||||||
|
`CreatorAdminContentSeriesServiceIntegrationTest.kt` 하나이며, 기존 쓰기 메서드의 `@Transactional` 5개가 유지됨을 재확인했다.
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# PRD: 크리에이터 관리자 시리즈 상세 LazyInitializationException 수정
|
||||||
|
|
||||||
|
## 문서 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 문서 상태 | 구현 기준 확정 |
|
||||||
|
| 작성일 | 2026-08-05 |
|
||||||
|
| 최종 수정일 | 2026-08-05 |
|
||||||
|
| 대상 기능 | 크리에이터 관리자 시리즈 상세 조회 |
|
||||||
|
| 관련 구현 계획 | `docs/20260805_크리에이터관리자_시리즈상세_LazyInitializationException_수정/plan-task.md` |
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
`spring.jpa.open-in-view=false` 환경에서 `GET /creator-admin/audio-content/series/{seriesId}` 호출 시
|
||||||
|
`Series.keywordList` 접근으로 발생하는 `LazyInitializationException`을 서비스 클래스의 기본 read-only 트랜잭션 경계로 방지한다.
|
||||||
|
|
||||||
|
## 2. Problem Statement
|
||||||
|
|
||||||
|
- `CreatorAdminContentSeriesController.getDetail()`은 `CreatorAdminContentSeriesService.getDetail()`에 상세 조회를 위임한다.
|
||||||
|
- `CreatorAdminContentSeriesService.getDetail()`에는 트랜잭션이 없으며,
|
||||||
|
`CreatorAdminContentSeriesRepository.findByIdAndCreatorId()`가 반환한 `Series`로 상세 DTO를 생성한다.
|
||||||
|
- 같은 서비스의 트랜잭션 없는 public 메서드는 모두 조회 기능이고, 데이터를 변경하는 public 메서드에는 이미 메서드 레벨
|
||||||
|
`@Transactional`이 적용되어 있다.
|
||||||
|
- `Series.keywordList`는 별도 fetch 설정이 없는 `@OneToMany`이므로 lazy 컬렉션이다.
|
||||||
|
- `Series.toDetailResponse()`은 `keywordList.map { it.keyword!!.tag }`를 실행한다.
|
||||||
|
- 운영·테스트 설정의 `spring.jpa.open-in-view=false` 때문에 리포지토리 호출 후 영속성 컨텍스트가 종료되고, DTO 변환 중
|
||||||
|
`org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: kr.co.vividnext.sodalive.creator.admin.content.series.Series.keywordList, could not initialize proxy - no Session`
|
||||||
|
예외가 발생한다.
|
||||||
|
- 기존 `LegacyCreatorAdminSeriesCharacterizationTest`는 클래스 레벨 `@Transactional`과 직접 생성한 서비스 객체를 사용하므로,
|
||||||
|
실제 Spring 서비스 프록시의 트랜잭션 유무에 따른 회귀를 검증하지 못한다.
|
||||||
|
|
||||||
|
문제를 해결했다는 판단은 외부 테스트 트랜잭션이 없는 OSIV off 통합 테스트에서 실제 Spring 서비스 프록시로 상세 조회 후
|
||||||
|
키워드가 포함된 기존 응답을 정상 생성하는 것으로 한다.
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
|
||||||
|
- OSIV off 환경에서도 소유한 시리즈 상세 조회가 `LazyInitializationException` 없이 완료된다.
|
||||||
|
- 서비스 클래스의 조회 기본값을 read-only 트랜잭션으로 두고, 시리즈 조회부터 `toDetailResponse()`의 lazy 컬렉션 접근까지
|
||||||
|
같은 영속성 컨텍스트에서 처리한다.
|
||||||
|
- 실제 Spring 서비스 프록시를 호출하는 통합 테스트로 수정 전 실패와 수정 후 성공을 검증한다.
|
||||||
|
- 기존 endpoint, 인증·소유권 검사, 성공 응답 필드와 값 형식을 유지한다.
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
|
||||||
|
- `spring.jpa.open-in-view`를 활성화하지 않는다.
|
||||||
|
- `Series.keywordList`를 전역 eager fetch로 변경하지 않는다.
|
||||||
|
- 상세 조회 쿼리를 fetch join 또는 projection으로 재작성하지 않는다.
|
||||||
|
- `Series.toDetailResponse()` 또는 `GetCreatorAdminContentSeriesDetailResponse` 구조를 변경하지 않는다.
|
||||||
|
- 시리즈 목록·수정·콘텐츠 연결 등 다른 흐름을 함께 리팩터링하지 않는다.
|
||||||
|
|
||||||
|
## 5. Target Users and Permissions
|
||||||
|
|
||||||
|
- 대상 사용자: 본인이 소유한 시리즈 상세를 조회하는 `CREATOR` 역할의 크리에이터 관리자
|
||||||
|
- 인증·권한: 기존 `@PreAuthorize("hasRole('CREATOR')")`와 인증 회원 검사를 유지한다.
|
||||||
|
- 소유권: 기존 `findByIdAndCreatorId(id, creatorId)` 조건과 `creator.admin.series.invalid_access` 오류를 유지한다.
|
||||||
|
|
||||||
|
## 6. 기능 요구사항
|
||||||
|
|
||||||
|
| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `CASD-001` | 확정 | 서비스 클래스에 read-only 트랜잭션을 기본 적용하고 기존 쓰기 메서드의 메서드 레벨 트랜잭션을 유지한다. | 키워드가 있는 소유 시리즈 조회가 OSIV off 환경에서 예외 없이 완료되고 기존 쓰기 메서드 annotation이 보존된다. | `P1-T1` |
|
||||||
|
| `CASD-002` | 확정 | 기존 상세 조회 API 계약을 유지한다. | endpoint, 권한, 오류 key, 응답 DTO의 필드·형식이 바뀌지 않는다. | `P1-T1`, `P1-GATE` |
|
||||||
|
| `CASD-003` | 확정 | 테스트 외부 트랜잭션 없이 실제 서비스 프록시를 검증한다. | 수정 전 `Series.keywordList` 예외를 재현하고, 수정 후 같은 테스트가 통과한다. | `P1-T1` |
|
||||||
|
|
||||||
|
## 7. API 계약
|
||||||
|
|
||||||
|
| Method | Path | 변경 사항 |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/creator-admin/audio-content/series/{seriesId}` | 공개 계약 변경 없음 |
|
||||||
|
|
||||||
|
- 성공 응답은 기존 `ApiResponse.ok(GetCreatorAdminContentSeriesDetailResponse)`를 유지한다.
|
||||||
|
- `seriesId`, `title`, `introduction`, `coverImageUrl`, `publishedDaysOfWeek`, `genre`, `keywords`, `isAdult`,
|
||||||
|
`state`, `writer`, `studio` 필드와 기존 문자열 변환 규칙을 유지한다.
|
||||||
|
- 인증 실패와 타 소유자·미존재 시리즈 오류 처리를 변경하지 않는다.
|
||||||
|
|
||||||
|
## 8. 해결 방안
|
||||||
|
|
||||||
|
`CreatorAdminContentSeriesService` 클래스에 `@Transactional(readOnly = true)`를 기본 적용한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
class CreatorAdminContentSeriesService(
|
||||||
|
```
|
||||||
|
|
||||||
|
`getDetail()`의 `findByIdAndCreatorId()` 조회와 `series.toDetailResponse()` 변환이 이 경계 안에서 모두 끝나므로 `keywordList`를
|
||||||
|
정상 초기화할 수 있다. 다른 조회 메서드도 같은 기본 경계를 사용하며, 기존 쓰기 메서드의 메서드 레벨 `@Transactional`은
|
||||||
|
class-level `readOnly = true`를 쓰기 트랜잭션으로 재정의한다. 이미 서비스 파일에서 `Transactional`을 사용하고 있어 새 의존성이나
|
||||||
|
import는 필요하지 않다.
|
||||||
|
|
||||||
|
### 현재 메서드 분류
|
||||||
|
|
||||||
|
| 기본 read-only 트랜잭션을 사용하는 조회 메서드 | 메서드 레벨 쓰기 트랜잭션을 유지하는 메서드 |
|
||||||
|
|---|---|
|
||||||
|
| `getSeriesList()` | `createSeries()` |
|
||||||
|
| `getDetail()` | `modifySeries()` |
|
||||||
|
| `getSeriesContent()` | `addingContentToTheSeries()` |
|
||||||
|
| `searchContentNotInSeries()` | `removeContentInTheSeries()` |
|
||||||
|
| | `updateSeriesOrders()` |
|
||||||
|
|
||||||
|
### 제외한 대안
|
||||||
|
|
||||||
|
- OSIV 활성화: 요청 전체로 영속성 컨텍스트를 확장해 현재 저장소 정책을 되돌리므로 제외한다.
|
||||||
|
- `keywordList` eager 변경: 모든 `Series` 조회 비용에 영향을 주는 전역 변경이므로 제외한다.
|
||||||
|
- fetch join/projection 추가: 이 endpoint만의 결함을 고치는 데 리포지토리 계약과 쿼리 변경이 불필요하므로 제외한다.
|
||||||
|
- 컨트롤러 트랜잭션: 영속성 및 DTO 변환 경계는 서비스가 소유하는 기존 구조에 맞지 않으므로 제외한다.
|
||||||
|
- `getDetail()`에만 read-only 트랜잭션 적용: 현재 트랜잭션 없는 메서드가 모두 조회 기능이므로 class-level 기본값보다 반복과
|
||||||
|
누락 가능성이 크다.
|
||||||
|
|
||||||
|
## 9. 기술적 제약
|
||||||
|
|
||||||
|
- Kotlin, Java 17, Spring Boot 2.7.14, Spring Data JPA, Hibernate, JUnit 5, Gradle Wrapper를 사용한다.
|
||||||
|
- `src/main/resources/application.yml`과 `src/test/resources/application.yml`의 `spring.jpa.open-in-view=false`를 유지한다.
|
||||||
|
- 테스트는 클래스 외부 트랜잭션을 비활성화하고 fixture 생성만 `TransactionTemplate`로 분리한다.
|
||||||
|
- 실제 Spring `CreatorAdminContentSeriesService` 빈을 주입해 proxy annotation 동작을 검증한다.
|
||||||
|
- production code 변경은 `CreatorAdminContentSeriesService` class-level annotation 한 줄로 제한한다.
|
||||||
|
- `createSeries()`, `modifySeries()`, `addingContentToTheSeries()`, `removeContentInTheSeries()`, `updateSeriesOrders()`의 기존
|
||||||
|
메서드 레벨 `@Transactional`을 유지한다.
|
||||||
|
|
||||||
|
## 10. 성공 기준
|
||||||
|
|
||||||
|
- [ ] 수정 전 focused test가 `Series.keywordList`의 `LazyInitializationException`으로 실패한다.
|
||||||
|
- [ ] 서비스 클래스에 `@Transactional(readOnly = true)` 적용 후 같은 테스트가 통과한다.
|
||||||
|
- [ ] 응답의 `keywords`와 주요 기존 상세 필드 값이 fixture와 일치한다.
|
||||||
|
- [ ] 기존 소유권·상세 동작 characterization test와 `ktlintCheck`가 통과한다.
|
||||||
|
- [ ] `tasks --all`과 `git diff --check`가 통과한다.
|
||||||
|
- [ ] API 스키마, 엔티티 fetch 전략, repository query에는 변경이 없다.
|
||||||
|
- [ ] 모든 기존 쓰기 메서드의 메서드 레벨 `@Transactional`이 유지된다.
|
||||||
|
|
||||||
|
## 11. 요구사항 추적표
|
||||||
|
|
||||||
|
| 요구사항 | 계획 Phase | Goal | 자동 검증 |
|
||||||
|
|---|---:|---|---|
|
||||||
|
| `CASD-001`, `CASD-003` | 1 | `P1-T1` | `CreatorAdminContentSeriesServiceIntegrationTest` |
|
||||||
|
| `CASD-002` | 1 | `P1-T1`, `P1-GATE` | focused test, `LegacyCreatorAdminSeriesCharacterizationTest` |
|
||||||
|
|
||||||
|
## 12. Open Questions
|
||||||
|
|
||||||
|
없음. 운영 stack trace, entity mapping, 서비스 호출 경계와 OSIV 설정으로 원인과 최소 해결 범위가 확인됐다.
|
||||||
|
|
||||||
|
## 13. Decision Log
|
||||||
|
|
||||||
|
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-08-05 | `DEC-CASD-001` | 확정 | `CreatorAdminContentSeriesService.getDetail()`에 메서드 단위 read-only 트랜잭션을 적용한다. | DTO 변환이 이미 서비스 내부에 있어 한 줄로 lazy 접근 전체를 영속성 컨텍스트 안에 포함할 수 있다. | `CASD-001`, `CASD-002`, `P1-T1` |
|
||||||
|
| 2026-08-05 | `DEC-CASD-002` | 확정 | 실제 Spring 서비스 프록시와 외부 트랜잭션이 없는 통합 테스트로 회귀를 고정한다. | 기존 characterization test는 테스트 트랜잭션과 직접 생성한 서비스 때문에 annotation 회귀를 검증할 수 없다. | `CASD-003`, `P1-T1` |
|
||||||
|
| 2026-08-05 | `DEC-CASD-003` | 정정 | `DEC-CASD-001`의 메서드 단위 적용을 class-level `@Transactional(readOnly = true)` 적용으로 정정하고 기존 쓰기 메서드의 메서드 레벨 `@Transactional`을 유지한다. | 트랜잭션 없는 기존 public 메서드는 모두 조회 기능이며, 쓰기 메서드는 이미 메서드 레벨 annotation으로 read-only 기본값을 재정의한다. | `CASD-001`, `CASD-002`, `P1-T1` |
|
||||||
@@ -26,6 +26,7 @@ import org.springframework.transaction.annotation.Transactional
|
|||||||
import org.springframework.web.multipart.MultipartFile
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
class CreatorAdminContentSeriesService(
|
class CreatorAdminContentSeriesService(
|
||||||
private val repository: CreatorAdminContentSeriesRepository,
|
private val repository: CreatorAdminContentSeriesRepository,
|
||||||
private val genreRepository: CreatorAdminContentSeriesGenreRepository,
|
private val genreRepository: CreatorAdminContentSeriesGenreRepository,
|
||||||
|
|||||||
@@ -53,7 +53,11 @@ class HomeRecommendationFacade(
|
|||||||
memberId = member?.id,
|
memberId = member?.id,
|
||||||
includeAdultLives = includeAdult
|
includeAdultLives = includeAdult
|
||||||
).map { it.toItem() },
|
).map { it.toItem() },
|
||||||
banners = queryService.findHomeBanners(HOME_BANNER_LIMIT, member?.id)
|
banners = queryService.findHomeBanners(
|
||||||
|
limit = HOME_BANNER_LIMIT,
|
||||||
|
memberId = member?.id,
|
||||||
|
includeAdultBanners = includeAdult
|
||||||
|
)
|
||||||
.map { RecommendationBannerResponse.from(it.toBanner()) },
|
.map { RecommendationBannerResponse.from(it.toBanner()) },
|
||||||
recentlyActiveCreators = queryService.findRecentlyActiveCreators(
|
recentlyActiveCreators = queryService.findRecentlyActiveCreators(
|
||||||
HOME_ACTIVE_CREATOR_LIMIT,
|
HOME_ACTIVE_CREATOR_LIMIT,
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
.leftJoin(series.member, seriesOwner)
|
.leftJoin(series.member, seriesOwner)
|
||||||
.where(
|
.where(
|
||||||
audioContentBanner.isActive.isTrue,
|
audioContentBanner.isActive.isTrue,
|
||||||
audioContentBanner.tab.isNull,
|
audioContentBanner.tab.id.eq(2L),
|
||||||
|
adultBannerCondition(canViewAdultContent),
|
||||||
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
|
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
|
||||||
)
|
)
|
||||||
.orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc())
|
.orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc())
|
||||||
@@ -544,6 +545,10 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
return if (canViewAdultContent) null else series.isAdult.isFalse
|
return if (canViewAdultContent) null else series.isAdult.isFalse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun adultBannerCondition(canViewAdultContent: Boolean): BooleanExpression? {
|
||||||
|
return if (canViewAdultContent) null else audioContentBanner.isAdult.isFalse
|
||||||
|
}
|
||||||
|
|
||||||
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
|
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
|
||||||
return if (canViewAdultContent) null else audioContent.isAdult.isFalse
|
return if (canViewAdultContent) null else audioContent.isAdult.isFalse
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
|
|
||||||
override fun findHomeBanners(
|
override fun findHomeBanners(
|
||||||
limit: Int,
|
limit: Int,
|
||||||
memberId: Long?
|
memberId: Long?,
|
||||||
|
includeAdultBanners: Boolean
|
||||||
): List<HomeBannerRecommendationRecord> {
|
): List<HomeBannerRecommendationRecord> {
|
||||||
val bannerCreator = QMember("bannerCreator")
|
val bannerCreator = QMember("bannerCreator")
|
||||||
val seriesOwner = QMember("seriesOwner")
|
val seriesOwner = QMember("seriesOwner")
|
||||||
@@ -115,6 +116,7 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
.where(
|
.where(
|
||||||
audioContentBanner.isActive.isTrue,
|
audioContentBanner.isActive.isTrue,
|
||||||
audioContentBanner.tab.isNull,
|
audioContentBanner.tab.isNull,
|
||||||
|
includeAdultBannerCondition(includeAdultBanners),
|
||||||
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
|
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
|
||||||
)
|
)
|
||||||
.orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc())
|
.orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc())
|
||||||
@@ -1265,6 +1267,10 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
return if (includeAdultCommunities) null else creatorCommunity.isAdult.isFalse
|
return if (includeAdultCommunities) null else creatorCommunity.isAdult.isFalse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun includeAdultBannerCondition(includeAdultBanners: Boolean): BooleanExpression? {
|
||||||
|
return if (includeAdultBanners) null else audioContentBanner.isAdult.isFalse
|
||||||
|
}
|
||||||
|
|
||||||
private fun includeAdultLiveCondition(includeAdultLives: Boolean): BooleanExpression? {
|
private fun includeAdultLiveCondition(includeAdultLives: Boolean): BooleanExpression? {
|
||||||
return if (includeAdultLives) null else liveRoom.isAdult.isFalse
|
return if (includeAdultLives) null else liveRoom.isAdult.isFalse
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,9 +43,10 @@ class HomeRecommendationQueryService(
|
|||||||
|
|
||||||
fun findHomeBanners(
|
fun findHomeBanners(
|
||||||
limit: Int = DEFAULT_BANNER_LIMIT,
|
limit: Int = DEFAULT_BANNER_LIMIT,
|
||||||
memberId: Long? = null
|
memberId: Long? = null,
|
||||||
|
includeAdultBanners: Boolean = false
|
||||||
): List<HomeBannerRecommendationRecord> {
|
): List<HomeBannerRecommendationRecord> {
|
||||||
return queryPort.findHomeBanners(limit, memberId)
|
return queryPort.findHomeBanners(limit, memberId, includeAdultBanners)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun findRecentlyActiveCreators(
|
fun findRecentlyActiveCreators(
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ interface HomeRecommendationQueryPort {
|
|||||||
|
|
||||||
fun findHomeBanners(
|
fun findHomeBanners(
|
||||||
limit: Int,
|
limit: Int,
|
||||||
memberId: Long? = null
|
memberId: Long? = null,
|
||||||
|
includeAdultBanners: Boolean = false
|
||||||
): List<HomeBannerRecommendationRecord>
|
): List<HomeBannerRecommendationRecord>
|
||||||
|
|
||||||
fun findRecentlyActiveCreators(
|
fun findRecentlyActiveCreators(
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package kr.co.vividnext.sodalive.creator.admin.content.series
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
|
||||||
|
import kr.co.vividnext.sodalive.content.hashtag.HashTag
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.keyword.SeriesKeyword
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://cdn.test"])
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
class CreatorAdminContentSeriesServiceIntegrationTest @Autowired constructor(
|
||||||
|
private val service: CreatorAdminContentSeriesService,
|
||||||
|
private val entityManager: EntityManager,
|
||||||
|
private val transactionTemplate: TransactionTemplate
|
||||||
|
) {
|
||||||
|
@Test
|
||||||
|
@DisplayName("OSIV off 환경에서 크리에이터 관리자 시리즈 상세는 키워드와 기존 필드를 반환한다")
|
||||||
|
fun shouldReturnOwnedSeriesDetailWithKeywordsWhenOpenInViewIsDisabled() {
|
||||||
|
val fixtureIds = createSeriesFixture()
|
||||||
|
|
||||||
|
val response = service.getDetail(id = fixtureIds.seriesId, memberId = fixtureIds.memberId)
|
||||||
|
|
||||||
|
assertEquals(fixtureIds.seriesId, response.seriesId)
|
||||||
|
assertEquals("lazy series", response.title)
|
||||||
|
assertEquals("lazy series introduction", response.introduction)
|
||||||
|
assertEquals("https://cdn.test/series/lazy-series.png", response.coverImageUrl)
|
||||||
|
assertEquals("월, 수", response.publishedDaysOfWeek)
|
||||||
|
assertEquals("lazy genre", response.genre)
|
||||||
|
assertEquals("#lazy-keyword", response.keywords)
|
||||||
|
assertEquals(true, response.isAdult)
|
||||||
|
assertEquals("완결", response.state)
|
||||||
|
assertEquals("lazy writer", response.writer)
|
||||||
|
assertEquals("lazy studio", response.studio)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createSeriesFixture(): FixtureIds {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val member = Member(
|
||||||
|
email = "creator-admin-series-detail@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "creator-admin-series-detail",
|
||||||
|
role = MemberRole.CREATOR
|
||||||
|
)
|
||||||
|
entityManager.persist(member)
|
||||||
|
|
||||||
|
val genre = SeriesGenre(genre = "lazy genre")
|
||||||
|
entityManager.persist(genre)
|
||||||
|
|
||||||
|
val hashTag = HashTag("#lazy-keyword")
|
||||||
|
entityManager.persist(hashTag)
|
||||||
|
|
||||||
|
val series = Series(
|
||||||
|
title = "lazy series",
|
||||||
|
introduction = "lazy series introduction",
|
||||||
|
state = SeriesState.COMPLETE,
|
||||||
|
writer = "lazy writer",
|
||||||
|
studio = "lazy studio",
|
||||||
|
publishedDaysOfWeek = mutableSetOf(
|
||||||
|
SeriesPublishedDaysOfWeek.MON,
|
||||||
|
SeriesPublishedDaysOfWeek.WED
|
||||||
|
),
|
||||||
|
isAdult = true
|
||||||
|
).apply {
|
||||||
|
this.member = member
|
||||||
|
this.genre = genre
|
||||||
|
coverImage = "series/lazy-series.png"
|
||||||
|
}
|
||||||
|
series.keywordList.add(
|
||||||
|
SeriesKeyword().apply {
|
||||||
|
this.series = series
|
||||||
|
keyword = hashTag
|
||||||
|
}
|
||||||
|
)
|
||||||
|
entityManager.persist(series)
|
||||||
|
|
||||||
|
entityManager.flush()
|
||||||
|
val fixtureIds = FixtureIds(memberId = member.id!!, seriesId = series.id!!)
|
||||||
|
entityManager.clear()
|
||||||
|
fixtureIds
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class FixtureIds(
|
||||||
|
val memberId: Long,
|
||||||
|
val seriesId: Long
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.home.application
|
package kr.co.vividnext.sodalive.v2.api.home.application
|
||||||
|
|
||||||
|
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.recommendation.application.HomeRecommendationQueryService
|
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||||
@@ -44,6 +45,17 @@ class HomeRecommendationFacadeTest {
|
|||||||
assertFalse(response.firstAudioContents[2].isPointAvailable)
|
assertFalse(response.firstAudioContents[2].isPointAvailable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("홈 추천은 회원의 성인 콘텐츠 조회 가능 여부를 배너 조회에 전달한다")
|
||||||
|
fun shouldPassAdultVisibilityToHomeBannerQuery() {
|
||||||
|
val member = Member(password = "password", nickname = "member").apply { id = 10L }
|
||||||
|
Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member)
|
||||||
|
|
||||||
|
facade.getHomeRecommendations(member)
|
||||||
|
|
||||||
|
assertTrue(queryPort.bannerIncludeAdultBanners)
|
||||||
|
}
|
||||||
|
|
||||||
private fun firstAudio(price: Int, isPointAvailable: Boolean): HomeFirstAudioContentRecord {
|
private fun firstAudio(price: Int, isPointAvailable: Boolean): HomeFirstAudioContentRecord {
|
||||||
return HomeFirstAudioContentRecord(
|
return HomeFirstAudioContentRecord(
|
||||||
contentId = price.toLong() + 1,
|
contentId = price.toLong() + 1,
|
||||||
@@ -61,6 +73,7 @@ class HomeRecommendationFacadeTest {
|
|||||||
|
|
||||||
private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort {
|
private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort {
|
||||||
var firstAudioContents: List<HomeFirstAudioContentRecord> = emptyList()
|
var firstAudioContents: List<HomeFirstAudioContentRecord> = emptyList()
|
||||||
|
var bannerIncludeAdultBanners: Boolean = false
|
||||||
|
|
||||||
override fun findLiveRecommendations(
|
override fun findLiveRecommendations(
|
||||||
offset: Long,
|
offset: Long,
|
||||||
@@ -69,7 +82,14 @@ class HomeRecommendationFacadeTest {
|
|||||||
includeAdultLives: Boolean
|
includeAdultLives: Boolean
|
||||||
): List<HomeLiveRecommendationRecord> = emptyList()
|
): List<HomeLiveRecommendationRecord> = emptyList()
|
||||||
|
|
||||||
override fun findHomeBanners(limit: Int, memberId: Long?): List<HomeBannerRecommendationRecord> = emptyList()
|
override fun findHomeBanners(
|
||||||
|
limit: Int,
|
||||||
|
memberId: Long?,
|
||||||
|
includeAdultBanners: Boolean
|
||||||
|
): List<HomeBannerRecommendationRecord> {
|
||||||
|
bannerIncludeAdultBanners = includeAdultBanners
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
override fun findRecentlyActiveCreators(
|
override fun findRecentlyActiveCreators(
|
||||||
limit: Int,
|
limit: Int,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.content.comment.AudioContentComment
|
|||||||
import kr.co.vividnext.sodalive.content.like.AudioContentLike
|
import kr.co.vividnext.sodalive.content.like.AudioContentLike
|
||||||
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
|
||||||
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
||||||
|
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
|
||||||
@@ -41,25 +42,58 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
private val repository = DefaultAudioRecommendationQueryRepository(queryFactory, entityManager, "https://cdn.test")
|
private val repository = DefaultAudioRecommendationQueryRepository(queryFactory, entityManager, "https://cdn.test")
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("배너는 홈 추천 배너와 같은 활성/탭/차단 정책과 CDN URL을 적용한다")
|
@DisplayName("배너는 콘텐츠 추천 탭과 성인 조회 가능 여부를 적용한다")
|
||||||
fun shouldFindBannersWithHomeBannerPolicy() {
|
fun shouldFindBannersForContentRecommendationTabWithAdultVisibility() {
|
||||||
val viewer = saveMember("viewer", MemberRole.USER)
|
val viewer = saveMember("viewer", MemberRole.USER)
|
||||||
val visibleCreator = saveMember("visible-creator", MemberRole.CREATOR)
|
val visibleCreator = saveMember("visible-creator", MemberRole.CREATOR)
|
||||||
val blockedCreator = saveMember("blocked-creator", MemberRole.CREATOR)
|
val blockedCreator = saveMember("blocked-creator", MemberRole.CREATOR)
|
||||||
val visibleBanner = saveBanner("visible.png", AudioContentBannerType.CREATOR, 1, creator = visibleCreator)
|
val recommendationTab = saveContentRecommendationTab()
|
||||||
val adultBanner = saveBanner("adult.png", AudioContentBannerType.LINK, 2, isAdult = true, link = "https://adult.test")
|
val visibleBanner = saveBanner(
|
||||||
saveBanner("inactive.png", AudioContentBannerType.LINK, 2, isActive = false, link = "https://inactive.test")
|
"visible.png",
|
||||||
saveBanner("blocked.png", AudioContentBannerType.CREATOR, 3, creator = blockedCreator)
|
AudioContentBannerType.CREATOR,
|
||||||
|
1,
|
||||||
|
creator = visibleCreator,
|
||||||
|
tab = recommendationTab
|
||||||
|
)
|
||||||
|
val adultBanner = saveBanner(
|
||||||
|
"adult.png",
|
||||||
|
AudioContentBannerType.LINK,
|
||||||
|
2,
|
||||||
|
isAdult = true,
|
||||||
|
link = "https://adult.test",
|
||||||
|
tab = recommendationTab
|
||||||
|
)
|
||||||
|
saveBanner(
|
||||||
|
"inactive.png",
|
||||||
|
AudioContentBannerType.LINK,
|
||||||
|
3,
|
||||||
|
isActive = false,
|
||||||
|
link = "https://inactive.test",
|
||||||
|
tab = recommendationTab
|
||||||
|
)
|
||||||
|
saveBanner(
|
||||||
|
"blocked.png",
|
||||||
|
AudioContentBannerType.CREATOR,
|
||||||
|
4,
|
||||||
|
creator = blockedCreator,
|
||||||
|
tab = recommendationTab
|
||||||
|
)
|
||||||
|
saveBanner("home.png", AudioContentBannerType.LINK, 1, link = "https://home.test")
|
||||||
saveBlock(viewer, blockedCreator)
|
saveBlock(viewer, blockedCreator)
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
val banners = repository.findBanners(limit = 20, memberId = viewer.id, canViewAdultContent = false)
|
val safeBanners = repository.findBanners(limit = 20, memberId = viewer.id, canViewAdultContent = false)
|
||||||
|
val allBanners = repository.findBanners(limit = 20, memberId = viewer.id, canViewAdultContent = true)
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("https://cdn.test/${visibleBanner.thumbnailImage}", "https://cdn.test/${adultBanner.thumbnailImage}"),
|
listOf("https://cdn.test/${visibleBanner.thumbnailImage}"),
|
||||||
banners.map { it.imageUrl }
|
safeBanners.map { it.imageUrl }
|
||||||
)
|
)
|
||||||
assertEquals(visibleCreator.id, banners.first().creatorId)
|
assertEquals(
|
||||||
|
listOf("https://cdn.test/${visibleBanner.thumbnailImage}", "https://cdn.test/${adultBanner.thumbnailImage}"),
|
||||||
|
allBanners.map { it.imageUrl }
|
||||||
|
)
|
||||||
|
assertEquals(visibleCreator.id, safeBanners.first().creatorId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -369,7 +403,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
isActive: Boolean = true,
|
isActive: Boolean = true,
|
||||||
isAdult: Boolean = false,
|
isAdult: Boolean = false,
|
||||||
creator: Member? = null,
|
creator: Member? = null,
|
||||||
link: String? = null
|
link: String? = null,
|
||||||
|
tab: AudioContentMainTab? = null
|
||||||
): AudioContentBanner {
|
): AudioContentBanner {
|
||||||
val banner = AudioContentBanner(
|
val banner = AudioContentBanner(
|
||||||
thumbnailImage = thumbnailImage,
|
thumbnailImage = thumbnailImage,
|
||||||
@@ -380,10 +415,18 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
)
|
)
|
||||||
banner.creator = creator
|
banner.creator = creator
|
||||||
banner.link = link
|
banner.link = link
|
||||||
|
banner.tab = tab
|
||||||
entityManager.persist(banner)
|
entityManager.persist(banner)
|
||||||
return banner
|
return banner
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveContentRecommendationTab(): AudioContentMainTab {
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"insert into audio_content_main_tab (id, title, is_active) values (2, '추천', true)"
|
||||||
|
).executeUpdate()
|
||||||
|
return entityManager.getReference(AudioContentMainTab::class.java, 2L)
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveSeries(
|
private fun saveSeries(
|
||||||
title: String,
|
title: String,
|
||||||
creator: Member,
|
creator: Member,
|
||||||
|
|||||||
@@ -262,6 +262,36 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
assertEquals(listOf(homeBanner.thumbnailImage), banners.map { it.thumbnailImage })
|
assertEquals(listOf(homeBanner.thumbnailImage), banners.map { it.thumbnailImage })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("홈 배너는 성인 콘텐츠 조회가 불가능하면 비성인 배너만 반환한다")
|
||||||
|
fun shouldExcludeAdultHomeBannersWhenAdultContentIsNotVisible() {
|
||||||
|
val safeBanner = saveBanner(
|
||||||
|
"safe-home-banner.png",
|
||||||
|
AudioContentBannerType.LINK,
|
||||||
|
orders = 1,
|
||||||
|
isActive = true,
|
||||||
|
link = "https://safe-home-banner.test"
|
||||||
|
)
|
||||||
|
val adultBanner = saveBanner(
|
||||||
|
"adult-home-banner.png",
|
||||||
|
AudioContentBannerType.LINK,
|
||||||
|
orders = 2,
|
||||||
|
isActive = true,
|
||||||
|
isAdult = true,
|
||||||
|
link = "https://adult-home-banner.test"
|
||||||
|
)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val safeBanners = repository.findHomeBanners(limit = 20)
|
||||||
|
val allBanners = repository.findHomeBanners(limit = 20, includeAdultBanners = true)
|
||||||
|
|
||||||
|
assertEquals(listOf(safeBanner.thumbnailImage), safeBanners.map { it.thumbnailImage })
|
||||||
|
assertEquals(
|
||||||
|
listOf(safeBanner.thumbnailImage, adultBanner.thumbnailImage),
|
||||||
|
allBanners.map { it.thumbnailImage }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("홈 배너는 비활성 대상 엔티티를 제외하고 LINK는 배너 자체 활성 상태만으로 조회한다")
|
@DisplayName("홈 배너는 비활성 대상 엔티티를 제외하고 LINK는 배너 자체 활성 상태만으로 조회한다")
|
||||||
fun shouldExcludeHomeBannersWithInactiveTargetsExceptLink() {
|
fun shouldExcludeHomeBannersWithInactiveTargetsExceptLink() {
|
||||||
@@ -2658,6 +2688,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
type: AudioContentBannerType,
|
type: AudioContentBannerType,
|
||||||
orders: Int,
|
orders: Int,
|
||||||
isActive: Boolean,
|
isActive: Boolean,
|
||||||
|
isAdult: Boolean = false,
|
||||||
creator: Member? = null,
|
creator: Member? = null,
|
||||||
event: Event? = null,
|
event: Event? = null,
|
||||||
series: Series? = null,
|
series: Series? = null,
|
||||||
@@ -2668,7 +2699,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
thumbnailImage = thumbnailImage,
|
thumbnailImage = thumbnailImage,
|
||||||
type = type,
|
type = type,
|
||||||
lang = Lang.KO,
|
lang = Lang.KO,
|
||||||
isAdult = false,
|
isAdult = isAdult,
|
||||||
isActive = isActive,
|
isActive = isActive,
|
||||||
orders = orders
|
orders = orders
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -86,12 +86,13 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("홈 배너 추천은 기본 20개를 활성 배너 조회 포트에 위임한다")
|
@DisplayName("홈 배너 추천은 기본 20개와 성인 노출 여부를 조회 포트에 위임한다")
|
||||||
fun shouldFindHomeBannersWithDefaultLimit() {
|
fun shouldFindHomeBannersWithDefaultLimit() {
|
||||||
val banners = service.findHomeBanners(memberId = 100L)
|
val banners = service.findHomeBanners(memberId = 100L, includeAdultBanners = true)
|
||||||
|
|
||||||
assertEquals(20, port.bannerLimit)
|
assertEquals(20, port.bannerLimit)
|
||||||
assertEquals(100L, port.bannerMemberId)
|
assertEquals(100L, port.bannerMemberId)
|
||||||
|
assertEquals(true, port.bannerIncludeAdultBanners)
|
||||||
assertEquals(port.banners, banners)
|
assertEquals(port.banners, banners)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -964,6 +965,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
var liveIncludeAdultLives: Boolean? = null
|
var liveIncludeAdultLives: Boolean? = null
|
||||||
var bannerLimit: Int? = null
|
var bannerLimit: Int? = null
|
||||||
var bannerMemberId: Long? = null
|
var bannerMemberId: Long? = null
|
||||||
|
var bannerIncludeAdultBanners: Boolean? = null
|
||||||
var activeCreatorLimit: Int? = null
|
var activeCreatorLimit: Int? = null
|
||||||
var activeCreatorMemberId: Long? = null
|
var activeCreatorMemberId: Long? = null
|
||||||
var activeCreatorIncludeAdultActivities: Boolean? = null
|
var activeCreatorIncludeAdultActivities: Boolean? = null
|
||||||
@@ -1063,9 +1065,14 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
return liveRecommendations
|
return liveRecommendations
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findHomeBanners(limit: Int, memberId: Long?): List<HomeBannerRecommendationRecord> {
|
override fun findHomeBanners(
|
||||||
|
limit: Int,
|
||||||
|
memberId: Long?,
|
||||||
|
includeAdultBanners: Boolean
|
||||||
|
): List<HomeBannerRecommendationRecord> {
|
||||||
bannerLimit = limit
|
bannerLimit = limit
|
||||||
bannerMemberId = memberId
|
bannerMemberId = memberId
|
||||||
|
bannerIncludeAdultBanners = includeAdultBanners
|
||||||
return banners
|
return banners
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user