Compare commits
12
Commits
a5f3db487d
...
50409e41c0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50409e41c0 | ||
|
|
e5230c0950 | ||
|
|
21f72f2827 | ||
|
|
14394b8263 | ||
|
|
dba70483ad | ||
|
|
44008c7623 | ||
|
|
ee3dfc40d9 | ||
|
|
328c433020 | ||
|
|
12b9a912c0 | ||
|
|
950ff98e18 | ||
|
|
6688bd7117 | ||
|
|
feb987dac9 |
@@ -0,0 +1,332 @@
|
|||||||
|
# v2 콘텐츠 목록 요청 언어별 번역 구현 계획
|
||||||
|
|
||||||
|
- 작성일: 2026-09-09
|
||||||
|
- 상태: Phase 1·2 구현 및 자동 검증 완료, test 서버 HTTP Gate 대기
|
||||||
|
- 요구사항·API 기준: [prd.md](prd.md)
|
||||||
|
- 현재 활성 Goal: P1-GATE test 서버 HTTP 확인 대기
|
||||||
|
- 다음 Goal: P2-GATE test 서버 HTTP 확인
|
||||||
|
- 실행 여부와 결과는 각 Task/Gate 실행 기록을 기준으로 한다.
|
||||||
|
|
||||||
|
## 목표와 범위
|
||||||
|
|
||||||
|
PRD의 7개 GET API에서 오디오 제목·시리즈 제목/시리즈명·테마명을 요청 언어로 표시하고,
|
||||||
|
번역이 없거나 공백이면 기존 원문을 반환한다. 제외 항목은 PRD §2와 동일하다.
|
||||||
|
|
||||||
|
| Phase | 결과 | 상태 | 완료 Task |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 채널·메인 전체 목록 번역 통일 | 자동 검증 완료, test 서버 HTTP Gate 대기 | 2/2 |
|
||||||
|
| 2 | 추천·더보기·랭킹 번역 통일 | 자동 검증 완료, test 서버 HTTP Gate 대기 | 2/2 |
|
||||||
|
|
||||||
|
## 구현 원칙
|
||||||
|
|
||||||
|
- 조사는 v2 패키지에서 시작하고 연결된 기존 번역 저장·조회 코드를 확인한다.
|
||||||
|
- 기존 LangInterceptor/LangContext와 ko/en/ja 파싱을 재사용하며 공통 언어 파서를 변경하지 않는다.
|
||||||
|
- Kotlin/Spring 기존 계층과 스타일을 따른다. 공개 응답 스키마와 인증·필터·정렬·페이지 조건은 유지한다.
|
||||||
|
- 번역 테이블 locale 조인 또는 기존 일괄 조회를 재사용한다. 항목 수만큼 추가 조회하는 N+1은 금지한다.
|
||||||
|
- 랭킹·추천 스냅샷의 집계/저장과 요청별 번역 표시를 분리한다.
|
||||||
|
- 저장 데이터·DDL·번역 생성 파이프라인·배너 국가별 선택 정책은 변경하지 않는다.
|
||||||
|
- 파일 목록은 수정 후보 및 확인 책임 범위다. 기존 동작이 맞는 파일은 테스트로만 확인한다.
|
||||||
|
- 기존 함수의 모든 호출자를 확인하고 공용 호출에 변경이 전파되면 관련 테스트와 이 문서에 기록한다.
|
||||||
|
- 새 추상화·의존성 없이 기존 Port/Repository를 최소 확장한다.
|
||||||
|
|
||||||
|
## 공통 참조 파일
|
||||||
|
|
||||||
|
- src/main/kotlin/kr/co/vividnext/sodalive/i18n/LangInterceptor.kt
|
||||||
|
- src/main/kotlin/kr/co/vividnext/sodalive/i18n/LangContext.kt
|
||||||
|
- src/main/kotlin/kr/co/vividnext/sodalive/i18n/Lang.kt
|
||||||
|
- src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslationRepository.kt
|
||||||
|
- src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt
|
||||||
|
|
||||||
|
## 공통 테스트 데이터
|
||||||
|
|
||||||
|
같은 원문에 서로 다른 ko/en/ja 번역을 저장한다. 번역 행 없음·빈 문자열·공백·다른 locale만 존재·
|
||||||
|
오디오와 시리즈 중 한쪽만 번역된 경우를 포함한다.
|
||||||
|
조회 요청에는 ko/en/ja, en-US, ja-JP, 누락, 빈 값, 미지원 fr를 사용한다.
|
||||||
|
en→ja→en 연속 요청에서 언어가 섞이지 않아야 한다.
|
||||||
|
언어별 응답은 대상 문자열만 달라지고 ID·개수·순서·필터·페이지·가격·제외 필드는 같아야 한다.
|
||||||
|
|
||||||
|
### Phase 1: 채널·메인 전체 목록
|
||||||
|
|
||||||
|
시작 조건: 사용자 구현 요청. 결과: LIST-01~04의 번역 표시와 기존 동작 유지.
|
||||||
|
|
||||||
|
- [x] **Task 1.1: 채널 오디오·시리즈·홈의 번역 표시 통일**
|
||||||
|
|
||||||
|
**Goal P1-T1**
|
||||||
|
- Objective: 오디오 title·seriesName·테마명, 홈 오디오/시리즈/AUDIO 일정 제목의 번역과 필드별 fallback을 검증한다. 이미 구현된 시리즈·테마 번역은 회귀를 먼저 확인하며 불필요하게 수정하지 않는다.
|
||||||
|
- 요구사항: LIST-01, LIST-02, LIST-04, LANG-01/02, TEXT-01/02, COMPAT-01, READ-01, ISOLATE-01
|
||||||
|
- 시작 조건: 사용자의 구현 착수 요청과 PRD 확인.
|
||||||
|
- 완료 증거: 아래 TDD 체크박스, focused test 성공, 실제 결과 기록.
|
||||||
|
- 범위 밖: 메인 목록·추천·랭킹 변경.
|
||||||
|
- Consumes: 기존 원문·저장된 번역·LangContext.lang.code.
|
||||||
|
- Produces: 기존 응답 계약을 유지한 요청 locale별 표시 문자열.
|
||||||
|
|
||||||
|
**확인/수정 후보 파일**
|
||||||
|
|
||||||
|
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt
|
||||||
|
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/port/out/CreatorChannelAudioQueryPort.kt
|
||||||
|
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt
|
||||||
|
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt
|
||||||
|
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt
|
||||||
|
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt
|
||||||
|
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt
|
||||||
|
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt
|
||||||
|
|
||||||
|
**테스트 파일**
|
||||||
|
|
||||||
|
- src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt
|
||||||
|
- src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt
|
||||||
|
- src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt
|
||||||
|
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioEndToEndTest.kt
|
||||||
|
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesEndToEndTest.kt
|
||||||
|
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt
|
||||||
|
|
||||||
|
- [x] **RED:** 위 테스트에서 대상 경로의 언어 선택·원문 fallback·기존 값 불변을 검증하는 가장 작은 실패 사례를 작성한다.
|
||||||
|
- [x] **RED 확인:** 아래 명령으로 미구현 번역 때문에 발생한 제목 assertion 실패를 확인한다. 환경 실패는 RED 증거가 아니다.
|
||||||
|
- [x] **GREEN:** 기존 계층에서 locale 전달과 번역 조회/매핑만 최소 구현한다.
|
||||||
|
- [x] **GREEN 확인:** 같은 명령으로 의도한 번역·fallback 성공을 확인한다.
|
||||||
|
- [x] **REFACTOR:** 이번 변경이 만든 중복만 정리하고 아래 focused test 및 직접 호출자 회귀를 확인한다. 실행 결과를 Task 기록에 누적한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test --tests '*DefaultCreatorChannelAudioQueryRepositoryTest' --tests '*DefaultCreatorChannelHomeQueryRepositoryTest' --tests '*DefaultCreatorChannelSeriesQueryRepositoryTest' --tests '*CreatorChannelAudioEndToEndTest' --tests '*CreatorChannelSeriesEndToEndTest' --tests '*CreatorChannelHomeEndToEndTest'
|
||||||
|
```
|
||||||
|
|
||||||
|
기대 결과: 종료 코드 0. 번역이 없어서 콘텐츠가 누락되거나 순서·개수·페이지가 달라지는 사례 0건.
|
||||||
|
|
||||||
|
**Task 실행 기록:**
|
||||||
|
|
||||||
|
- 2026-09-09: 사용자가 현재 브랜치 구현을 지시해 worktree 없이 `test` 브랜치에서 진행했다.
|
||||||
|
- RED: `./gradlew test --tests '*DefaultCreatorChannelAudioQueryRepositoryTest.shouldFindAudioContentsWithTranslatedTitleFallback' --tests '*DefaultCreatorChannelHomeQueryRepositoryTest.shouldFindHomeAudioContentsWithTranslatedTitleFallback' --tests '*DefaultCreatorChannelHomeQueryRepositoryTest.shouldFindHomeSchedulesAndSeriesWithTranslatedTitleFallback'` 실행. 종료 코드 1, 3개 테스트가 제목 assertion 실패로 실패했다.
|
||||||
|
- GREEN: 같은 RED 명령 재실행. 종료 코드 0, 오디오 title·홈 오디오 title/seriesName·AUDIO 스케줄 title·홈 시리즈 title의 번역 및 공백 fallback을 확인했다.
|
||||||
|
- Focused: `./gradlew test --tests '*DefaultCreatorChannelAudioQueryRepositoryTest' --tests '*DefaultCreatorChannelHomeQueryRepositoryTest' --tests '*DefaultCreatorChannelSeriesQueryRepositoryTest' --tests '*CreatorChannelAudioEndToEndTest' --tests '*CreatorChannelSeriesEndToEndTest' --tests '*CreatorChannelHomeEndToEndTest'` 실행. 종료 코드 0.
|
||||||
|
- 리뷰 보완: 같은 콘텐츠에 en/ja 번역을 함께 저장해 locale별 제목 선택과 ID 순서 불변을 추가 검증했다. 홈 service는 `LangContext.lang.code`가 최신 오디오·오디오 목록·스케줄·시리즈 port 호출에 전달되는지 확인했다.
|
||||||
|
- 보완 후 Focused 재실행: 위 P1-T1 focused command 실행. 종료 코드 0.
|
||||||
|
|
||||||
|
- [x] **Task 1.2: 메인 전체 목록 오디오 번역과 시리즈 회귀 확인**
|
||||||
|
|
||||||
|
**Goal P1-T2**
|
||||||
|
- Objective: 각 기존 type에서 오디오 제목 번역을 확인하고 기존 시리즈 locale 전달·fallback을 보존한다. 기존 요일·정렬·유료/무료 필터와 페이지 결과가 동일해야 한다.
|
||||||
|
- 요구사항: LIST-03, LANG-01/02, TEXT-01/02, COMPAT-01, READ-01, ISOLATE-01
|
||||||
|
- 시작 조건: P1-T1 완료.
|
||||||
|
- 완료 증거: 아래 TDD 체크박스, focused test 성공, 실제 결과 기록.
|
||||||
|
- 범위 밖: 추천·랭킹 변경.
|
||||||
|
- Consumes: 기존 원문·저장된 번역·LangContext.lang.code.
|
||||||
|
- Produces: 기존 응답 계약을 유지한 요청 locale별 표시 문자열.
|
||||||
|
|
||||||
|
**확인/수정 후보 파일**
|
||||||
|
|
||||||
|
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt
|
||||||
|
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/port/out/MainContentAllQueryPort.kt
|
||||||
|
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt
|
||||||
|
|
||||||
|
**테스트 파일**
|
||||||
|
|
||||||
|
- src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt
|
||||||
|
- src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt
|
||||||
|
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt
|
||||||
|
|
||||||
|
- [x] **RED:** 위 테스트에서 대상 경로의 언어 선택·원문 fallback·기존 값 불변을 검증하는 가장 작은 실패 사례를 작성한다.
|
||||||
|
- [x] **RED 확인:** 아래 명령으로 미구현 번역 때문에 발생한 제목 assertion 실패를 확인한다. 환경 실패는 RED 증거가 아니다.
|
||||||
|
- [x] **GREEN:** 기존 계층에서 locale 전달과 번역 조회/매핑만 최소 구현한다.
|
||||||
|
- [x] **GREEN 확인:** 같은 명령으로 의도한 번역·fallback 성공을 확인한다.
|
||||||
|
- [x] **REFACTOR:** 이번 변경이 만든 중복만 정리하고 아래 focused test 및 직접 호출자 회귀를 확인한다. 실행 결과를 Task 기록에 누적한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test --tests '*DefaultMainContentAllQueryRepositoryTest' --tests '*MainContentAllQueryServiceTest' --tests '*MainContentAllEndToEndTest'
|
||||||
|
```
|
||||||
|
|
||||||
|
기대 결과: 종료 코드 0. 번역이 없어서 콘텐츠가 누락되거나 순서·개수·페이지가 달라지는 사례 0건.
|
||||||
|
|
||||||
|
**Task 실행 기록:**
|
||||||
|
|
||||||
|
- RED: `./gradlew test --tests '*MainContentAllQueryServiceTest.shouldQueryAudiosForAudioType' --tests '*DefaultMainContentAllQueryRepositoryTest.shouldFindAudiosWithTranslatedTitleFallback'` 실행. 종료 코드 1, service locale 전달 assertion과 repository 오디오 title assertion이 실패했다.
|
||||||
|
- GREEN/Focused: `./gradlew test --tests '*DefaultMainContentAllQueryRepositoryTest' --tests '*MainContentAllQueryServiceTest' --tests '*MainContentAllEndToEndTest'` 실행. 종료 코드 0, 오디오 title 번역·공백 fallback과 기존 시리즈 번역 회귀를 확인했다.
|
||||||
|
- 리뷰 보완: 같은 오디오에 en/ja 번역을 함께 저장해 locale별 title 선택과 ID 순서 불변을 추가 검증했다.
|
||||||
|
- 보완 후 Focused 재실행: 위 P1-T2 focused command 실행. 종료 코드 0.
|
||||||
|
|
||||||
|
#### Phase 1 Gate — P1-GATE
|
||||||
|
|
||||||
|
- Objective: Phase 1의 요청 헤더부터 DB 번역 선택·HTTP 응답까지 확인한다.
|
||||||
|
- 시작 조건: P1-T1, P1-T2 완료.
|
||||||
|
- 완료 증거: 해당 Task 명령 통과, 아래 수동 확인과 실제 명령·결과·환경 기록.
|
||||||
|
- 범위 밖: 운영 데이터 변경, Papago 실행, 테스트 삭제·완화.
|
||||||
|
- [x] 해당 Phase의 모든 focused test 명령을 실행하여 종료 코드 0을 확인한다.
|
||||||
|
- [ ] test 서버에서 같은 API·query를 유지하고 Accept-Language만 바꿔 GET 요청한다.
|
||||||
|
- [ ] ko/en/ja 번역·없는 번역·공백 번역·기본 언어 동작을 실제 JSON에서 확인한다.
|
||||||
|
- [ ] 대상 외 필드, 순서·개수·페이지·인증·성인/차단/구매 정책이 보존되는지 확인한다.
|
||||||
|
- [ ] 요청별 번역 조회가 항목 수에 비례해 늘지 않고 GET에서 번역 작업/외부 API/쓰기 호출이 없는지 확인한다.
|
||||||
|
- [ ] 검증 로그는 인증 토큰·개인정보를 제거해 기록한다.
|
||||||
|
|
||||||
|
수동 HTTP는 test 서버와 합성 테스트 계정을 사용한다. 인증이 필요한 API는 test 서버용 토큰을 사용하고 문서에 값을 기록하지 않는다.
|
||||||
|
test 서버 배포·DB 준비가 안 되면 수동 Gate를 완료 처리하지 않고 원인과 재개 조건을 남긴다.
|
||||||
|
|
||||||
|
**Phase 1 Gate 실행 기록:**
|
||||||
|
|
||||||
|
- 2026-09-09: P1-T1 focused command 재실행. 종료 코드 0.
|
||||||
|
- 2026-09-09: P1-T2 focused command 실행. 종료 코드 0.
|
||||||
|
- 2026-09-09: `./gradlew ktlintCheck`는 신규 테스트 호출 줄바꿈과 import 정렬 지적 후 수정해 재실행했다. 최종 종료 코드 0.
|
||||||
|
- 2026-09-09: `./gradlew assemble` 실행. 종료 코드 0.
|
||||||
|
- 2026-09-09: Oracle 리뷰에서 자동 검증 공백을 지적받아 다중 locale 선택, 홈 locale 전달, 공백 fallback 보강 테스트를 추가했다. 보강 focused test 종료 코드 0.
|
||||||
|
- 2026-09-09: 보강 후 P1-T1 focused command, P1-T2 focused command, `./gradlew ktlintCheck`, `./gradlew assemble`을 다시 실행했다. 각 종료 코드 0.
|
||||||
|
- 로컬 수동 테스트가 불가능하다는 사용자 확인에 따라 HTTP Gate를 test 서버로 이관했다. 재개 조건: test 서버 배포와 합성 인증 수단 준비 후 동일 query에서 `Accept-Language`만 바꿔 LIST-01~04 JSON을 확인한다.
|
||||||
|
|
||||||
|
### Phase 2: 추천·더보기·랭킹
|
||||||
|
|
||||||
|
시작 조건: P1-GATE 통과. 결과: LIST-05~07의 번역 표시와 요청 간 격리.
|
||||||
|
|
||||||
|
- [x] **Task 2.1: 추천·더보기의 모든 오디오 제목 번역**
|
||||||
|
|
||||||
|
**Goal P2-T1**
|
||||||
|
- Objective: 일반 카드와 별도 SQL을 쓰는 mostCommentedAudios를 모두 검증한다. 더보기 두 type과 FIRST_AUDIO_CONTENT 공유 호출자를 확인한다. 추천 스냅샷·백그라운드 작업에 요청 범위 의존을 추가하지 않으며, 공유 홈 추천 호출의 기존 응답을 회귀 검증한다.
|
||||||
|
- 요구사항: LIST-05, LIST-07, LANG-01/02, TEXT-01/02, COMPAT-01, READ-01, ISOLATE-01
|
||||||
|
- 시작 조건: P1-GATE 완료.
|
||||||
|
- 완료 증거: 아래 TDD 체크박스, focused test 성공, 실제 결과 기록.
|
||||||
|
- 범위 밖: 배너·댓글·닉네임 및 다른 API의 신규 번역 기능.
|
||||||
|
- Consumes: 기존 원문·저장된 번역·LangContext.lang.code.
|
||||||
|
- Produces: 기존 응답 계약을 유지한 요청 locale별 표시 문자열.
|
||||||
|
|
||||||
|
**확인/수정 후보 파일**
|
||||||
|
|
||||||
|
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt
|
||||||
|
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt
|
||||||
|
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt
|
||||||
|
- src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacade.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/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt
|
||||||
|
- src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt
|
||||||
|
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacadeTest.kt
|
||||||
|
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt
|
||||||
|
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt
|
||||||
|
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt
|
||||||
|
|
||||||
|
- [x] **RED:** 위 테스트에서 대상 경로의 언어 선택·원문 fallback·기존 값 불변을 검증하는 가장 작은 실패 사례를 작성한다.
|
||||||
|
- [x] **RED 확인:** 아래 명령으로 미구현 번역 때문에 발생한 제목 assertion 실패를 확인한다. 환경 실패는 RED 증거가 아니다.
|
||||||
|
- [x] **GREEN:** 기존 계층에서 locale 전달과 번역 조회/매핑만 최소 구현한다.
|
||||||
|
- [x] **GREEN 확인:** 같은 명령으로 의도한 번역·fallback 성공을 확인한다.
|
||||||
|
- [x] **REFACTOR:** 이번 변경이 만든 중복만 정리하고 아래 focused test 및 직접 호출자 회귀를 확인한다. 실행 결과를 Task 기록에 누적한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test --tests '*DefaultAudioRecommendationQueryRepositoryTest' --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*ContentOverviewFacadeTest' --tests '*AudioRecommendationEndToEndTest' --tests '*ContentOverviewEndToEndTest' --tests '*HomeRecommendationFacadeTest'
|
||||||
|
```
|
||||||
|
|
||||||
|
기대 결과: 종료 코드 0. 번역이 없어서 콘텐츠가 누락되거나 순서·개수·페이지가 달라지는 사례 0건.
|
||||||
|
|
||||||
|
**Task 실행 기록:**
|
||||||
|
|
||||||
|
- 2026-09-09: RED는 이전 구현 담당자가 아래 focused command로 포착했다. 원시 Gradle 콘솔 로그는 현재 세션에 보관되어 있지 않아 출력은 재구성하지 않는다. LIST-05 전체 카드와 LIST-07 NEW_AND_HOT_AUDIO/FIRST_AUDIO_CONTENT가 요청 locale 번역 대신 원문 제목을 반환한 assertion 실패였다.
|
||||||
|
- 2026-09-09: GREEN 구현 후 저장소에서 locale별 배치 조회와 nonblank title fallback을 적용했다. Oracle 보완으로 같은 fixture의 en→ja→en MockMvc 격리와 빈 문자열·공백·누락·다른 locale fallback, most-commented의 ID 순서·댓글·작성자 프로필 필드 보존을 추가 검증했다.
|
||||||
|
- 2026-09-09: Focused: 위 command 실행, `BUILD SUCCESSFUL in 1m 43s`. production 코드는 Oracle 보완에서 변경하지 않았고, `pointAudios`의 기존 개수·인덱스 검증을 누적 fixture와 `rand()` 정렬에 독립적인 fixture ID·가격 조건으로 교체했다.
|
||||||
|
- 수동 HTTP는 로컬 실행이 불가능하다는 사용자 확인에 따라 test 서버 P2-GATE에서 대기한다.
|
||||||
|
|
||||||
|
- [x] **Task 2.2: 랭킹 응답 제목을 요청 언어로 표시**
|
||||||
|
|
||||||
|
**Goal P2-T2**
|
||||||
|
- Objective: 같은 랭킹 스냅샷의 contentId로 요청 locale 번역을 일괄 조회하여 표시 제목에만 반영한다. 번역 없음·공백이면 기존 snapshot.title을 사용한다. 집계 재실행 없이 번역 반영과 rank·rankChange·점수 불변을 검증한다.
|
||||||
|
- 요구사항: LIST-06, RANK-01, LANG-01/02, TEXT-01/02, COMPAT-01, READ-01, ISOLATE-01
|
||||||
|
- 시작 조건: P2-T1 완료.
|
||||||
|
- 완료 증거: 아래 TDD 체크박스, focused test 성공, 실제 결과 기록.
|
||||||
|
- 범위 밖: 집계·점수·스냅샷 저장 형식 변경.
|
||||||
|
- Consumes: 기존 원문·저장된 번역·LangContext.lang.code.
|
||||||
|
- Produces: 기존 응답 계약을 유지한 요청 locale별 표시 문자열.
|
||||||
|
|
||||||
|
**확인/수정 후보 파일**
|
||||||
|
|
||||||
|
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt
|
||||||
|
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotPort.kt
|
||||||
|
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt
|
||||||
|
|
||||||
|
**테스트 파일**
|
||||||
|
|
||||||
|
- src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt
|
||||||
|
- src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt
|
||||||
|
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt
|
||||||
|
|
||||||
|
- [x] **RED:** 위 테스트에서 대상 경로의 언어 선택·원문 fallback·기존 값 불변을 검증하는 가장 작은 실패 사례를 작성한다.
|
||||||
|
- [x] **RED 확인:** 아래 명령으로 미구현 번역 때문에 발생한 제목 assertion 실패를 확인한다. 환경 실패는 RED 증거가 아니다.
|
||||||
|
- [x] **GREEN:** 기존 계층에서 locale 전달과 번역 조회/매핑만 최소 구현한다.
|
||||||
|
- [x] **GREEN 확인:** 같은 명령으로 의도한 번역·fallback 성공을 확인한다.
|
||||||
|
- [x] **REFACTOR:** 이번 변경이 만든 중복만 정리하고 아래 focused test 및 직접 호출자 회귀를 확인한다. 실행 결과를 Task 기록에 누적한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test --tests '*AudioRankingQueryServiceTest' --tests '*DefaultAudioRankingSnapshotPersistenceAdapterTest' --tests '*AudioRankingControllerTest'
|
||||||
|
```
|
||||||
|
|
||||||
|
기대 결과: 종료 코드 0. 번역이 없어서 콘텐츠가 누락되거나 순서·개수·페이지가 달라지는 사례 0건.
|
||||||
|
|
||||||
|
**Task 실행 기록:**
|
||||||
|
|
||||||
|
- 2026-09-09: RED: `./gradlew test --tests '*AudioRankingQueryServiceTest' --tests '*DefaultAudioRankingSnapshotPersistenceAdapterTest' --tests '*AudioRankingControllerTest'` 종료 코드 1. `AudioRankingQueryServiceTest.shouldUseRequestedLocaleTitlesWithoutChangingRankingValues`의 `expected: <[en-title-2, en-title-1]> but was: <[audio-2, audio-1]>` 및 `AudioRankingControllerTest.shouldIsolateRequestedLocaleTitlesWithoutChangingRankingResponse`의 `JSON path "$.data.items[0].title" expected:<en-title-2> but was:<audio-2>`를 확인했다. `DefaultAudioRankingSnapshotPersistenceAdapterTest.shouldTranslateSnapshotTitlesInBatchesWithSnapshotFallback`도 snapshot title 반환 assertion으로 실패했다.
|
||||||
|
- 2026-09-09: GREEN: `LangContext.lang.code`을 조회 port에만 전달하고, 최신·이전 각 스냅샷 결과 집합에서 `findByContentIdInAndLocale` 한 번으로 유효한 title만 적용했다. 빈 문자열·공백·누락·다른 locale 전용 번역은 각 snapshot.title로 fallback하며, replace/집계/refresh 경로는 변경하지 않았다.
|
||||||
|
- 2026-09-09: Focused: 위 명령 종료 코드 0, `BUILD SUCCESSFUL in 40s`. query service의 en→ja→en locale 전달과 title 외 `showRankChange`·type·순서·rank·rankChange·isNew·닉네임·cover URL 불변, 실제 adapter의 최신·이전 조회 배치 8회 statement, controller의 en→ja→en JSON을 검증했다.
|
||||||
|
- 2026-09-09: 직접 호출자 회귀: `./gradlew test --tests '*AudioRankingSnapshotRefreshServiceTest'` 종료 코드 0, `BUILD SUCCESSFUL in 6s`.
|
||||||
|
- 수동 HTTP는 test 서버 배포 후 P2-GATE에서 수행한다.
|
||||||
|
- 2026-09-09: P2-T2 spec-review 보강: `./gradlew test --tests '*DefaultAudioRankingSnapshotPersistenceAdapterTest'` 종료 코드 0, `BUILD SUCCESSFUL in 34s`. `rendered_payload`를 SQL NULL로 저장한 실제 translation row는 converter의 빈 payload로 읽혀 snapshot.title으로 fallback했다. `{ "title": null }` JSON은 non-null `ContentTranslationPayload.title` 계약 밖이라 생성하지 않았다.
|
||||||
|
- 2026-09-09: 빈 latest 조회 후 Hibernate `prepareStatementCount=1`, 이어진 빈 previous 조회 후 누적 `=2`를 확인했다. 각 snapshot native SELECT만 발생했고 ContentTranslation SELECT는 0회다. non-empty latest·previous en→ja→en 여섯 조회는 snapshot+batch translation 각 1회씩 총 12 statements였으며, title만 빈 문자열로 정규화한 전체 `AudioRankingSnapshotRecord`가 locale 간 동일했다. latest score base 10~14와 previous 20~24의 final/normalized/raw·count·growth·boost 필드를 포함했고, 재조회한 저장 snapshot title과 모든 score 값도 변경되지 않았다.
|
||||||
|
|
||||||
|
#### Phase 2 Gate — P2-GATE
|
||||||
|
|
||||||
|
- Objective: Phase 2의 요청 헤더부터 DB 번역 선택·HTTP 응답까지 확인한다.
|
||||||
|
- 시작 조건: P2-T1, P2-T2 완료.
|
||||||
|
- 완료 증거: 해당 Task 명령 통과, 아래 수동 확인과 실제 명령·결과·환경 기록.
|
||||||
|
- 범위 밖: 운영 데이터 변경, Papago 실행, 테스트 삭제·완화.
|
||||||
|
- [x] 해당 Phase의 모든 focused test 명령을 실행하여 종료 코드 0을 확인한다.
|
||||||
|
- [ ] test 서버에서 같은 API·query를 유지하고 Accept-Language만 바꿔 GET 요청한다.
|
||||||
|
- [ ] ko/en/ja 번역·없는 번역·공백 번역·기본 언어 동작을 실제 JSON에서 확인한다.
|
||||||
|
- [ ] 대상 외 필드, 순서·개수·페이지·인증·성인/차단/구매 정책이 보존되는지 확인한다.
|
||||||
|
- [ ] 요청별 번역 조회가 항목 수에 비례해 늘지 않고 GET에서 번역 작업/외부 API/쓰기 호출이 없는지 확인한다.
|
||||||
|
- [ ] 검증 로그는 인증 토큰·개인정보를 제거해 기록한다.
|
||||||
|
|
||||||
|
수동 HTTP는 test 서버와 합성 테스트 계정을 사용한다. 인증이 필요한 API는 test 서버용 토큰을 사용하고 문서에 값을 기록하지 않는다.
|
||||||
|
test 서버 배포·DB 준비가 안 되면 수동 Gate를 완료 처리하지 않고 원인과 재개 조건을 남긴다.
|
||||||
|
|
||||||
|
**Phase 2 Gate 실행 기록:**
|
||||||
|
|
||||||
|
- 2026-09-09: P2-T1 focused command를 `--rerun-tasks`로 강제 재실행했다. 종료 코드 0, `BUILD SUCCESSFUL in 5m 12s`, 10 tasks executed.
|
||||||
|
- 2026-09-09: P2-T2 focused command를 `--rerun-tasks`로 강제 재실행했다. 종료 코드 0, `BUILD SUCCESSFUL in 5m 14s`, 10 tasks executed.
|
||||||
|
- 2026-09-09: `AudioRankingSnapshotRefreshServiceTest` 직접 호출자 회귀를 실행했다. 종료 코드 0, `BUILD SUCCESSFUL in 6s`.
|
||||||
|
- 로컬 수동 테스트는 불가능하다는 사용자 확인에 따라 LIST-01~07 HTTP 검증을 test 서버 배포 후 수행한다.
|
||||||
|
|
||||||
|
## 실행 순서와 완료 기준
|
||||||
|
|
||||||
|
P1-T1 → P1-T2 → P1-GATE → P2-T1 → P2-T2 → P2-GATE. 2026-09-09 사용자 지시로 로컬 P1-GATE를 test 서버로 이관하고 P2 자동 구현·검증을 먼저 완료했다.
|
||||||
|
한 번에 하나의 Task 또는 Gate만 활성화하며, 완료된 기록은 되돌리거나 삭제하지 않는다.
|
||||||
|
|
||||||
|
최종 품질 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.*' --tests 'kr.co.vividnext.sodalive.v2.content.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.*' --tests '*HomeRecommendation*'
|
||||||
|
./gradlew ktlintCheck
|
||||||
|
./gradlew assemble
|
||||||
|
```
|
||||||
|
|
||||||
|
기대 결과: 각 자동 검증 명령 종료 코드 0. 대상 7개 API의 실제 HTTP 확인은 test 서버 Gate에서 별도 완료한다.
|
||||||
|
전체 test는 공통 인증·언어 파서·serialization 변경 또는 targeted 결과로 영향 판단이 불가능할 때 추가한다.
|
||||||
|
생략 시 국소적인 표시값 변경이라는 근거와 대신 실행한 위 영향 범위 회귀를 기록한다.
|
||||||
|
초기 문서 작성 단계에서는 위 구현 검증 명령을 실행하지 않았으며, 구현 후 결과는 각 Task/Gate 기록에 누적한다.
|
||||||
|
|
||||||
|
## 결정 기록
|
||||||
|
|
||||||
|
PRD의 DEC-LIST-01~07을 따른다.
|
||||||
|
기존 파싱·API 계약·원문 fallback을 유지하며, 요청 외 영역으로 확대해야 할 경우 먼저 문서와 사용자 결정을 갱신한다.
|
||||||
|
|
||||||
|
## 문서 검증 기록
|
||||||
|
|
||||||
|
- 2026-09-09: 사용자 인터뷰의 원문 fallback과 댓글·닉네임·배너 유지 결정을 PRD와 Task에 반영.
|
||||||
|
- 초기 문서 작성 당시 코드·테스트·운영 데이터 변경 없이 구현 Task와 Gate는 전부 미착수였다.
|
||||||
|
- 경로 존재 확인 및 필수 Gradle 명령 확인 결과는 아래에 누적한다.
|
||||||
|
- 2026-09-09: Task의 기존 소스·테스트 경로 존재를 확인했다. 채널 홈 EndToEndTest는 현재 없으므로 신규 작성 예정으로 구분했다. 구현 완료 체크박스와 템플릿 placeholder가 남아 있지 않음을 확인했다.
|
||||||
|
- 2026-09-09: 문서 유지보수 규칙에 따라 `./gradlew tasks --all` 실행. 종료 코드 0, `BUILD SUCCESSFUL in 26s`; test·ktlintCheck·assemble 태스크를 확인했다. 기능 테스트·빌드·HTTP 검증은 구현하지 않은 문서 작업이므로 실행하지 않았다.
|
||||||
|
- 2026-09-09: Phase 1·2 전체 영향 범위 test 명령 실행. 종료 코드 0, `BUILD SUCCESSFUL in 3m 23s`.
|
||||||
|
- 2026-09-09: 최초 `./gradlew ktlintCheck`에서 신규 랭킹·추천 테스트의 들여쓰기와 긴 줄 위반을 확인해 포맷만 수정했다. 관련 두 repository test 종료 코드 0, 최종 `./gradlew ktlintCheck` 종료 코드 0.
|
||||||
|
- 2026-09-09: `./gradlew assemble` 실행. 종료 코드 0, `BUILD SUCCESSFUL in 6s`.
|
||||||
|
- 2026-09-09: 전체 `./gradlew test` 실행. 종료 코드 0, `BUILD SUCCESSFUL in 8m 19s`.
|
||||||
|
- 2026-09-09: 문서 상태를 Phase 1·2 자동 검증 완료와 test 서버 HTTP Gate 대기로 갱신하고 `./gradlew tasks --all`로 명령 유효성을 재확인했다. 종료 코드 0.
|
||||||
|
- 2026-09-09: 전체 영향 범위 회귀 명령을 실행했다. 종료 코드 0, `BUILD SUCCESSFUL in 3m 23s`.
|
||||||
|
- 2026-09-09: `./gradlew ktlintCheck` 최초 실행에서 신규 테스트 두 파일의 들여쓰기·긴 줄 위반을 확인해 포맷만 수정했다. 관련 ranking adapter·recommendation repository 테스트 재실행 종료 코드 0, 최종 `ktlintCheck` 종료 코드 0.
|
||||||
|
- 2026-09-09: `./gradlew assemble` 실행. 종료 코드 0, `BUILD SUCCESSFUL in 6s`.
|
||||||
|
- 2026-09-09: 문서 상태와 test 서버 Gate 갱신 후 `./gradlew tasks --all` 재실행. 종료 코드 0, `BUILD SUCCESSFUL in 928ms`.
|
||||||
|
- 로컬 수동 테스트는 불가능하므로 자동 검증 완료 상태로 test 서버 배포를 준비한다. 배포 후 P1-GATE부터 LIST-01~07의 실제 HTTP 응답을 순서대로 확인한다.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# v2 콘텐츠 목록 요청 언어별 번역 PRD
|
||||||
|
|
||||||
|
## 문서 정보
|
||||||
|
|
||||||
|
- 작성일: 2026-09-09
|
||||||
|
- 상태: Phase 1·2 구현 및 자동 검증 완료, test 서버 HTTP Gate 대기
|
||||||
|
- 결정권자: 사용자
|
||||||
|
- 관련 계획: [plan-task.md](plan-task.md)
|
||||||
|
- 공개 API 경로·요청·응답 스키마는 유지하며 별도 API 계약 문서는 만들지 않는다.
|
||||||
|
- 사용자의 2026-09-09 구현 지시 이후 Phase 1·2 코드·테스트 구현을 진행했다. 운영 데이터 변경과 번역 실행은 수행하지 않는다.
|
||||||
|
|
||||||
|
## 1. 문제와 목표
|
||||||
|
|
||||||
|
동일한 콘텐츠라도 v2 조회 위치에 따라 번역 제목과 원문이 혼재한다.
|
||||||
|
요청의 Accept-Language로 결정된 언어에 맞춰 이미 저장된 번역을 표시하고,
|
||||||
|
번역이 없거나 공백이면 해당 필드의 기존 원문을 표시한다.
|
||||||
|
|
||||||
|
서버의 언어 결정은 기존 LangInterceptor → 요청 범위 LangContext를 재사용한다.
|
||||||
|
앱의 설정 언어가 실제 헤더로 전송되는지는 이 서버 작업에서 보장하지 않는다.
|
||||||
|
|
||||||
|
## 2. 대상 API와 필드
|
||||||
|
|
||||||
|
아래 경로는 모두 GET이다. 기존 응답에 존재하는 필드만 변경하며 설명·태그 같은 새 필드를 추가하지 않는다.
|
||||||
|
|
||||||
|
| ID | API | 대상 |
|
||||||
|
|---|---|---|
|
||||||
|
| LIST-01 | /api/v2/creator-channels/{creatorId}/audio | 오디오 title, 연결된 seriesName, 테마명 |
|
||||||
|
| LIST-02 | /api/v2/creator-channels/{creatorId}/series | 시리즈 title. 기존 연재 요일 언어 처리 유지 |
|
||||||
|
| LIST-03 | /api/v2/audio/contents | 오디오 title, 시리즈 title. 기존 type·정렬·요일 필터 유지 |
|
||||||
|
| LIST-04 | /api/v2/creator-channels/{creatorId}/home | 오디오 카드 title·seriesName, 시리즈 title, 일정 중 AUDIO 유형의 title |
|
||||||
|
| LIST-05 | /api/v2/audio/recommendations | latestAudios, newAndHotAudios, freeAudios, pointAudios, mostCommentedAudios, recommendedAudios의 title |
|
||||||
|
| LIST-06 | /api/v2/audio/rankings | 각 랭킹 항목 title |
|
||||||
|
| LIST-07 | /api/v2/contents | NEW_AND_HOT_AUDIO, FIRST_AUDIO_CONTENT의 title |
|
||||||
|
|
||||||
|
- LIST-05의 originalSeries는 현재 seriesId·coverImageUrl만 반환한다. 번역용 title 필드를 추가하지 않는다.
|
||||||
|
- LIST-04의 라이브 제목, 후원·팬톡 문구 등 오디오·시리즈·테마 외 필드는 대상이 아니다.
|
||||||
|
- /api/v2/home/recommendations 등 다른 경로는 신규 기능 범위가 아니다. 공유 조회 코드 변경 시 기존 동작 회귀를 검증한다.
|
||||||
|
- 관리자 목록, 상세·검색 API, 음성 파일 번역, 설명·태그 응답 확장은 제외한다.
|
||||||
|
|
||||||
|
## 3. 기능 요구사항과 수용 기준
|
||||||
|
|
||||||
|
| ID | 상태/근거 | 요구사항 | 수용 기준 | 연결 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| LANG-01 | 사용자 확정 | 대상 API 전체에서 Accept-Language 기준 번역 선택 | 동일 데이터에 ko/en/ja 요청 시 해당 locale 번역 표시 | 모든 Task |
|
||||||
|
| LANG-02 | 기존 동작 유지 | Lang.fromAcceptLanguage 파싱 재사용 | 헤더 누락·빈 값·미지원 값은 ko, en-US는 en, ja-JP는 ja. q 가중치 파싱 확장 없음 | P1-GATE |
|
||||||
|
| TEXT-01 | 사용자 확정 | 번역 없음·빈 문자열·공백 문자열은 원문 표시 | null/빈 문자열/공백별 원문 일치, 다른 언어 번역으로 대체하지 않음 | 모든 Task |
|
||||||
|
| TEXT-02 | 사용자 확정 | 오디오 제목·시리즈 제목/시리즈명·테마명만 적용 | 댓글·닉네임·배너는 기존 값·선택 정책 유지 | 모든 Task |
|
||||||
|
| COMPAT-01 | 기존 기능 유지 | 언어는 표시 문자열에만 영향 | ID·개수·순서·hasNext·권한·성인/차단/구매 필터·가격·이미지 불변 | 각 Gate |
|
||||||
|
| RANK-01 | 구현 제약 | 랭킹 집계와 번역 표시를 분리 | 같은 스냅샷으로 언어만 바꿔 제목 변경, 순위·점수·rankChange 불변 | P2-T2 |
|
||||||
|
| READ-01 | 범위 제약 | 저장된 번역만 조회 | GET에서 Papago 호출·번역 작업 생성·DB 쓰기 없음 | 각 Gate |
|
||||||
|
| ISOLATE-01 | 기존 요청 범위 유지 | 언어가 다른 요청끼리 값 공유 금지 | en→ja→en 요청에서도 언어 혼입 없음 | 각 Gate |
|
||||||
|
|
||||||
|
원문이란 번역 적용 전 해당 경로에서 반환하던 문자열이다.
|
||||||
|
랭킹은 기존 스냅샷 title을 fallback으로 유지하며 최신 콘텐츠 제목으로 갱신하는 별도 기능을 추가하지 않는다.
|
||||||
|
번역 완료 전 원문이 보이는 것은 정상이다. 원문과 요청 언어가 같아도 별도 추정 없이 같은 locale 번역 조회/fallback 규칙을 적용한다.
|
||||||
|
|
||||||
|
## 4. 기술 근거와 최소 변경 방향
|
||||||
|
|
||||||
|
모든 경로는 src/main/kotlin/kr/co/vividnext/sodalive/ 아래를 기준으로 한다.
|
||||||
|
|
||||||
|
| 근거 파일 | 현재 확인한 동작 |
|
||||||
|
|---|---|
|
||||||
|
| i18n/LangInterceptor.kt, i18n/Lang.kt, configs/WebConfig.kt | 전체 경로에서 헤더를 읽고 ko/en/ja로 결정 |
|
||||||
|
| content/translation/ContentTranslationRepository.kt | findByContentIdInAndLocale 일괄 조회가 이미 존재 |
|
||||||
|
| i18n/translation/TranslationReadModelMaterializer.kt | content·series 번역을 renderedPayload에 저장 |
|
||||||
|
| v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt | 오디오 title·seriesName·테마는 요청 locale 번역 우선 |
|
||||||
|
| v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt | 시리즈 번역 우선과 원문 fallback 구현됨 |
|
||||||
|
| v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt | 오디오·시리즈 제목 및 AUDIO 일정 제목은 요청 locale 번역 우선 |
|
||||||
|
| v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt | 오디오·시리즈 제목은 요청 locale 번역 우선 |
|
||||||
|
| v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt | 일반 카드 및 mostCommentedAudios 제목은 요청 locale 번역 우선 |
|
||||||
|
| v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt | FIRST_AUDIO_CONTENT 제목은 요청 locale 번역 우선 |
|
||||||
|
| v2/content/ranking/application/AudioRankingQueryService.kt | 요청 locale을 조회 port에 전달하고 snapshot 표시 제목만 번역 |
|
||||||
|
|
||||||
|
기존 QueryService → Port → Persistence 경계를 유지하며 필요한 locale을 전달한다.
|
||||||
|
기존 번역 조인 또는 ID 일괄 조회를 재사용한다. 항목별 단건 조회로 N+1을 만들지 않는다.
|
||||||
|
페이지·정렬·필터 결과를 유지한 채 제목을 매핑한다. 별도 범용 번역 프레임워크·캐시·의존성·DDL은 추가하지 않는다.
|
||||||
|
랭킹/추천 스냅샷 생성 작업에 요청 범위 LangContext를 주입하지 않는다.
|
||||||
|
|
||||||
|
## 5. 검증 시나리오
|
||||||
|
|
||||||
|
1. 같은 원문에 ko/en/ja별 서로 다른 번역을 준비하고 7개 API의 대상 필드가 헤더에 맞는지 확인한다.
|
||||||
|
2. 번역 행 없음, 빈 제목, 공백 제목, 다른 locale 번역만 존재하는 경우 필드별 원문 fallback을 확인한다.
|
||||||
|
3. 오디오 번역만 있고 시리즈 번역이 없는 혼합 사례에서 각 필드가 독립적으로 선택되는지 확인한다.
|
||||||
|
4. 언어를 바꿔도 콘텐츠 ID·순서·페이지·필터·랭킹과 제외 필드가 동일한지 확인한다.
|
||||||
|
5. 실제 HTTP 요청과 DB 조회 테스트로 연결을 검증한다. Mock 응답의 문자열 확인만으로 완료 처리하지 않는다.
|
||||||
|
|
||||||
|
## 6. 결정 기록
|
||||||
|
|
||||||
|
| 날짜 | ID | 구분 | 내용 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 2026-09-09 | DEC-LIST-01 | 사용자 확정 | 앞서 조사한 조회 위치 모두 Accept-Language에 따른 번역 표시 |
|
||||||
|
| 2026-09-09 | DEC-LIST-02 | 사용자 확정 | 번역 없음·공백이면 원문 표시 |
|
||||||
|
| 2026-09-09 | DEC-LIST-03 | 사용자 확정 | 오디오 제목·시리즈 제목/시리즈명·테마명 대상, 댓글·닉네임·배너 유지 |
|
||||||
|
| 2026-09-09 | DEC-LIST-04 | 기존 동작 유지 | 언어 파싱·기본 언어·인증·조회 조건·응답 스키마 유지 |
|
||||||
|
| 2026-09-09 | DEC-LIST-05 | 사용자 제한 | 문서만 작성, 구현하지 않음 |
|
||||||
|
| 2026-09-09 | DEC-LIST-06 | 사용자 지시 | 현재 브랜치에서 Phase 1 구현 진행. PRD에도 구현 상태를 최종 갱신 |
|
||||||
|
| 2026-09-09 | DEC-LIST-07 | 사용자 지시 | 로컬 수동 테스트가 불가능하므로 Phase 2까지 자동 검증을 완료하고 test 서버에서 HTTP 검증 |
|
||||||
|
|
||||||
|
제품 결정이 필요한 미결 항목은 없다. test 서버 배포 후 대상 7개 API의 수동 HTTP 검증이 남아 있다.
|
||||||
+22
-1
@@ -12,6 +12,7 @@ import kr.co.vividnext.sodalive.content.order.QOrder
|
|||||||
import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation
|
import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation
|
||||||
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme
|
||||||
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.QContentTranslation
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
@@ -58,6 +59,7 @@ class DefaultMainContentAllQueryRepository(
|
|||||||
sort: ContentSort,
|
sort: ContentSort,
|
||||||
offset: Long,
|
offset: Long,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
|
locale: String,
|
||||||
onlyPaid: Boolean,
|
onlyPaid: Boolean,
|
||||||
onlyFree: Boolean,
|
onlyFree: Boolean,
|
||||||
onlyPointAvailable: Boolean
|
onlyPointAvailable: Boolean
|
||||||
@@ -69,13 +71,14 @@ class DefaultMainContentAllQueryRepository(
|
|||||||
val creatorIds = rows.map { it.get(audioContent.member.id)!! }.distinct()
|
val creatorIds = rows.map { it.get(audioContent.member.id)!! }.distinct()
|
||||||
val firstContentIdByCreatorId = firstAudioContentIds(creatorIds, now, canViewAdultContent)
|
val firstContentIdByCreatorId = firstAudioContentIds(creatorIds, now, canViewAdultContent)
|
||||||
val originalSeriesByContentId = originalSeriesFlags(contentIds)
|
val originalSeriesByContentId = originalSeriesFlags(contentIds)
|
||||||
|
val titlesByContentId = audioTitlesByContentIds(contentIds, locale)
|
||||||
|
|
||||||
return rows.map { row ->
|
return rows.map { row ->
|
||||||
val contentId = row.get(audioContent.id)!!
|
val contentId = row.get(audioContent.id)!!
|
||||||
val creatorId = row.get(audioContent.member.id)!!
|
val creatorId = row.get(audioContent.member.id)!!
|
||||||
MainContentAllAudio(
|
MainContentAllAudio(
|
||||||
audioContentId = contentId,
|
audioContentId = contentId,
|
||||||
title = row.get(audioContent.title)!!,
|
title = titlesByContentId[contentId] ?: row.get(audioContent.title)!!,
|
||||||
imageUrl = row.get(audioContent.coverImage).toCdnUrl(cloudFrontHost),
|
imageUrl = row.get(audioContent.coverImage).toCdnUrl(cloudFrontHost),
|
||||||
price = row.get(audioContent.price)!!,
|
price = row.get(audioContent.price)!!,
|
||||||
isAdult = row.get(audioContent.isAdult)!!,
|
isAdult = row.get(audioContent.isAdult)!!,
|
||||||
@@ -343,6 +346,24 @@ class DefaultMainContentAllQueryRepository(
|
|||||||
.associate { it.get(seriesContent.content.id)!! to it.get(series.isOriginal)!! }
|
.associate { it.get(seriesContent.content.id)!! to it.get(series.isOriginal)!! }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun audioTitlesByContentIds(contentIds: List<Long>, locale: String): Map<Long, String> {
|
||||||
|
if (contentIds.isEmpty()) return emptyMap()
|
||||||
|
val contentTranslation = QContentTranslation("mainContentAllContentTranslation")
|
||||||
|
return queryFactory
|
||||||
|
.select(contentTranslation.contentId, contentTranslation.renderedPayload)
|
||||||
|
.from(contentTranslation)
|
||||||
|
.where(
|
||||||
|
contentTranslation.contentId.`in`(contentIds),
|
||||||
|
contentTranslation.locale.eq(locale)
|
||||||
|
)
|
||||||
|
.fetch()
|
||||||
|
.mapNotNull {
|
||||||
|
val title = it.get(contentTranslation.renderedPayload)?.title.takeUnless(String?::isNullOrBlank)
|
||||||
|
title?.let { translatedTitle -> it.get(contentTranslation.contentId)!! to translatedTitle }
|
||||||
|
}
|
||||||
|
.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
private fun audioCondition(
|
private fun audioCondition(
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
|
|||||||
+1
@@ -122,6 +122,7 @@ class MainContentAllQueryService(
|
|||||||
sort = sort,
|
sort = sort,
|
||||||
offset = page.offset,
|
offset = page.offset,
|
||||||
limit = page.size + 1,
|
limit = page.size + 1,
|
||||||
|
locale = langContext.lang.code,
|
||||||
onlyPaid = onlyPaid,
|
onlyPaid = onlyPaid,
|
||||||
onlyFree = onlyFree,
|
onlyFree = onlyFree,
|
||||||
onlyPointAvailable = onlyPointAvailable
|
onlyPointAvailable = onlyPointAvailable
|
||||||
|
|||||||
+1
@@ -23,6 +23,7 @@ interface MainContentAllQueryPort {
|
|||||||
sort: ContentSort,
|
sort: ContentSort,
|
||||||
offset: Long,
|
offset: Long,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
|
locale: String,
|
||||||
onlyPaid: Boolean = false,
|
onlyPaid: Boolean = false,
|
||||||
onlyFree: Boolean = false,
|
onlyFree: Boolean = false,
|
||||||
onlyPointAvailable: Boolean = false
|
onlyPointAvailable: Boolean = false
|
||||||
|
|||||||
+22
-7
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence
|
package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||||
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
|
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
|
||||||
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort
|
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort
|
||||||
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord
|
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord
|
||||||
@@ -9,25 +10,28 @@ import java.time.LocalDateTime
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
class DefaultAudioRankingSnapshotPersistenceAdapter(
|
class DefaultAudioRankingSnapshotPersistenceAdapter(
|
||||||
private val repository: AudioRankingSnapshotRepository
|
private val repository: AudioRankingSnapshotRepository,
|
||||||
|
private val contentTranslationRepository: ContentTranslationRepository
|
||||||
) : AudioRankingSnapshotPort {
|
) : AudioRankingSnapshotPort {
|
||||||
override fun findLatestVisibleSnapshots(
|
override fun findLatestVisibleSnapshots(
|
||||||
rankingType: AudioRankingType,
|
rankingType: AudioRankingType,
|
||||||
nowUtc: LocalDateTime
|
nowUtc: LocalDateTime,
|
||||||
|
locale: String
|
||||||
): List<AudioRankingSnapshotRecord> {
|
): List<AudioRankingSnapshotRecord> {
|
||||||
return repository.findLatestVisibleSnapshots(rankingType.name, nowUtc).map { it.toRecord() }
|
return repository.findLatestVisibleSnapshots(rankingType.name, nowUtc).toRecords(locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findPreviousVisibleSnapshots(
|
override fun findPreviousVisibleSnapshots(
|
||||||
rankingType: AudioRankingType,
|
rankingType: AudioRankingType,
|
||||||
currentAggregationStartAtUtc: LocalDateTime,
|
currentAggregationStartAtUtc: LocalDateTime,
|
||||||
nowUtc: LocalDateTime
|
nowUtc: LocalDateTime,
|
||||||
|
locale: String
|
||||||
): List<AudioRankingSnapshotRecord> {
|
): List<AudioRankingSnapshotRecord> {
|
||||||
return repository.findPreviousVisibleSnapshots(
|
return repository.findPreviousVisibleSnapshots(
|
||||||
rankingType = rankingType.name,
|
rankingType = rankingType.name,
|
||||||
currentAggregationStartAtUtc = currentAggregationStartAtUtc,
|
currentAggregationStartAtUtc = currentAggregationStartAtUtc,
|
||||||
nowUtc = nowUtc
|
nowUtc = nowUtc
|
||||||
).map { it.toRecord() }
|
).toRecords(locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -46,14 +50,25 @@ class DefaultAudioRankingSnapshotPersistenceAdapter(
|
|||||||
repository.saveAll(newSnapshots.map { it.toEntity(visibleFromAtUtc) })
|
repository.saveAll(newSnapshots.map { it.toEntity(visibleFromAtUtc) })
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun AudioRankingSnapshot.toRecord(): AudioRankingSnapshotRecord {
|
private fun List<AudioRankingSnapshot>.toRecords(locale: String): List<AudioRankingSnapshotRecord> {
|
||||||
|
if (isEmpty()) return emptyList()
|
||||||
|
val translatedTitleByContentId = contentTranslationRepository.findByContentIdInAndLocale(
|
||||||
|
map { it.contentId },
|
||||||
|
locale
|
||||||
|
).mapNotNull { translation ->
|
||||||
|
translation.renderedPayload.title.takeIf { it.isNotBlank() }?.let { translation.contentId to it }
|
||||||
|
}.toMap()
|
||||||
|
return map { snapshot -> snapshot.toRecord(translatedTitleByContentId[snapshot.contentId] ?: snapshot.title) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AudioRankingSnapshot.toRecord(displayTitle: String = title): AudioRankingSnapshotRecord {
|
||||||
return AudioRankingSnapshotRecord(
|
return AudioRankingSnapshotRecord(
|
||||||
rankingType = rankingType,
|
rankingType = rankingType,
|
||||||
aggregationStartAtUtc = aggregationStartAtUtc,
|
aggregationStartAtUtc = aggregationStartAtUtc,
|
||||||
aggregationEndAtUtc = aggregationEndAtUtc,
|
aggregationEndAtUtc = aggregationEndAtUtc,
|
||||||
visibleFromAtUtc = visibleFromAtUtc,
|
visibleFromAtUtc = visibleFromAtUtc,
|
||||||
contentId = contentId,
|
contentId = contentId,
|
||||||
title = title,
|
title = displayTitle,
|
||||||
creatorMemberId = creatorMemberId,
|
creatorMemberId = creatorMemberId,
|
||||||
creatorNickname = creatorNickname,
|
creatorNickname = creatorNickname,
|
||||||
coverImageUrl = coverImageUrl,
|
coverImageUrl = coverImageUrl,
|
||||||
|
|||||||
+10
-5
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.content.ranking.application
|
package kr.co.vividnext.sodalive.v2.content.ranking.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
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.common.domain.toCdnUrl
|
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
|
||||||
@@ -18,6 +19,7 @@ import java.time.ZonedDateTime
|
|||||||
@Service
|
@Service
|
||||||
class AudioRankingQueryService(
|
class AudioRankingQueryService(
|
||||||
private val snapshotPort: AudioRankingSnapshotPort,
|
private val snapshotPort: AudioRankingSnapshotPort,
|
||||||
|
private val langContext: LangContext,
|
||||||
private val memberContentPreferenceService: MemberContentPreferenceService,
|
private val memberContentPreferenceService: MemberContentPreferenceService,
|
||||||
private val blockPort: AudioRankingBlockPort,
|
private val blockPort: AudioRankingBlockPort,
|
||||||
private val jobService: AudioRankingSnapshotJobService,
|
private val jobService: AudioRankingSnapshotJobService,
|
||||||
@@ -29,7 +31,8 @@ class AudioRankingQueryService(
|
|||||||
|
|
||||||
fun getRankings(type: AudioRankingType, member: Member?): AudioRanking {
|
fun getRankings(type: AudioRankingType, member: Member?): AudioRanking {
|
||||||
val nowUtc = nowProvider().withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()
|
val nowUtc = nowProvider().withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()
|
||||||
val latestSnapshots = findLatestVisibleSnapshots(type, nowUtc)
|
val locale = langContext.lang.code
|
||||||
|
val latestSnapshots = findLatestVisibleSnapshots(type, nowUtc, locale)
|
||||||
if (latestSnapshots.isEmpty()) {
|
if (latestSnapshots.isEmpty()) {
|
||||||
return AudioRanking(showRankChange = false, type = type, items = emptyList())
|
return AudioRanking(showRankChange = false, type = type, items = emptyList())
|
||||||
}
|
}
|
||||||
@@ -37,7 +40,8 @@ class AudioRankingQueryService(
|
|||||||
val previousSnapshots = snapshotPort.findPreviousVisibleSnapshots(
|
val previousSnapshots = snapshotPort.findPreviousVisibleSnapshots(
|
||||||
rankingType = type,
|
rankingType = type,
|
||||||
currentAggregationStartAtUtc = latestSnapshots.first().aggregationStartAtUtc,
|
currentAggregationStartAtUtc = latestSnapshots.first().aggregationStartAtUtc,
|
||||||
nowUtc = nowUtc
|
nowUtc = nowUtc,
|
||||||
|
locale = locale
|
||||||
)
|
)
|
||||||
val blockedCreatorMemberIds = blockedCreatorMemberIds(member, latestSnapshots + previousSnapshots)
|
val blockedCreatorMemberIds = blockedCreatorMemberIds(member, latestSnapshots + previousSnapshots)
|
||||||
val latestVisibleSnapshots = latestSnapshots.visibleTo(canViewAdultContent, blockedCreatorMemberIds).take(ITEM_LIMIT)
|
val latestVisibleSnapshots = latestSnapshots.visibleTo(canViewAdultContent, blockedCreatorMemberIds).take(ITEM_LIMIT)
|
||||||
@@ -58,9 +62,10 @@ class AudioRankingQueryService(
|
|||||||
|
|
||||||
private fun findLatestVisibleSnapshots(
|
private fun findLatestVisibleSnapshots(
|
||||||
type: AudioRankingType,
|
type: AudioRankingType,
|
||||||
nowUtc: java.time.LocalDateTime
|
nowUtc: java.time.LocalDateTime,
|
||||||
|
locale: String
|
||||||
): List<AudioRankingSnapshotRecord> {
|
): List<AudioRankingSnapshotRecord> {
|
||||||
val latestSnapshots = snapshotPort.findLatestVisibleSnapshots(type, nowUtc)
|
val latestSnapshots = snapshotPort.findLatestVisibleSnapshots(type, nowUtc, locale)
|
||||||
if (latestSnapshots.isNotEmpty()) return latestSnapshots
|
if (latestSnapshots.isNotEmpty()) return latestSnapshots
|
||||||
|
|
||||||
runCatching { jobService.refreshLastCompletedWeekByFallback(type) }
|
runCatching { jobService.refreshLastCompletedWeekByFallback(type) }
|
||||||
@@ -72,7 +77,7 @@ class AudioRankingQueryService(
|
|||||||
ex
|
ex
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return snapshotPort.findLatestVisibleSnapshots(type, nowUtc)
|
return snapshotPort.findLatestVisibleSnapshots(type, nowUtc, locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun canViewAdultContent(member: Member?): Boolean {
|
private fun canViewAdultContent(member: Member?): Boolean {
|
||||||
|
|||||||
+4
-2
@@ -6,13 +6,15 @@ import java.time.LocalDateTime
|
|||||||
interface AudioRankingSnapshotPort {
|
interface AudioRankingSnapshotPort {
|
||||||
fun findLatestVisibleSnapshots(
|
fun findLatestVisibleSnapshots(
|
||||||
rankingType: AudioRankingType,
|
rankingType: AudioRankingType,
|
||||||
nowUtc: LocalDateTime
|
nowUtc: LocalDateTime,
|
||||||
|
locale: String
|
||||||
): List<AudioRankingSnapshotRecord>
|
): List<AudioRankingSnapshotRecord>
|
||||||
|
|
||||||
fun findPreviousVisibleSnapshots(
|
fun findPreviousVisibleSnapshots(
|
||||||
rankingType: AudioRankingType,
|
rankingType: AudioRankingType,
|
||||||
currentAggregationStartAtUtc: LocalDateTime,
|
currentAggregationStartAtUtc: LocalDateTime,
|
||||||
nowUtc: LocalDateTime
|
nowUtc: LocalDateTime,
|
||||||
|
locale: String
|
||||||
): List<AudioRankingSnapshotRecord>
|
): List<AudioRankingSnapshotRecord>
|
||||||
|
|
||||||
fun replaceSnapshots(
|
fun replaceSnapshots(
|
||||||
|
|||||||
+40
-12
@@ -11,6 +11,7 @@ import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
|||||||
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
||||||
import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner
|
import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner
|
||||||
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
||||||
import kr.co.vividnext.sodalive.event.EventItem
|
import kr.co.vividnext.sodalive.event.EventItem
|
||||||
@@ -38,6 +39,7 @@ import javax.persistence.EntityManager
|
|||||||
class DefaultAudioRecommendationQueryRepository(
|
class DefaultAudioRecommendationQueryRepository(
|
||||||
private val queryFactory: JPAQueryFactory,
|
private val queryFactory: JPAQueryFactory,
|
||||||
private val entityManager: EntityManager,
|
private val entityManager: EntityManager,
|
||||||
|
private val contentTranslationRepository: ContentTranslationRepository,
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val cloudFrontHost: String
|
private val cloudFrontHost: String
|
||||||
) : AudioRecommendationQueryRepository {
|
) : AudioRecommendationQueryRepository {
|
||||||
@@ -115,32 +117,35 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
limit: Int,
|
limit: Int,
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
now: LocalDateTime
|
now: LocalDateTime,
|
||||||
|
locale: String
|
||||||
): List<AudioCard> {
|
): List<AudioCard> {
|
||||||
val rows = audioRows(memberId, canViewAdultContent, now) {
|
val rows = audioRows(memberId, canViewAdultContent, now) {
|
||||||
orderBy(audioContent.releaseDate.desc(), audioContent.id.desc()).limit(limit.toLong())
|
orderBy(audioContent.releaseDate.desc(), audioContent.id.desc()).limit(limit.toLong())
|
||||||
}
|
}
|
||||||
return rows.toAudioCards(now, canViewAdultContent)
|
return rows.toAudioCards(now, canViewAdultContent, locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findFreeAudios(
|
override fun findFreeAudios(
|
||||||
limit: Int,
|
limit: Int,
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
now: LocalDateTime
|
now: LocalDateTime,
|
||||||
|
locale: String
|
||||||
): List<AudioCard> {
|
): List<AudioCard> {
|
||||||
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
|
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
|
||||||
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.price.eq(0)) {
|
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.price.eq(0)) {
|
||||||
orderBy(randomTieBreaker.asc()).limit(limit.toLong())
|
orderBy(randomTieBreaker.asc()).limit(limit.toLong())
|
||||||
}
|
}
|
||||||
return rows.toAudioCards(now, canViewAdultContent)
|
return rows.toAudioCards(now, canViewAdultContent, locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findPointAudios(
|
override fun findPointAudios(
|
||||||
limit: Int,
|
limit: Int,
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
now: LocalDateTime
|
now: LocalDateTime,
|
||||||
|
locale: String
|
||||||
): List<AudioCard> {
|
): List<AudioCard> {
|
||||||
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
|
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
|
||||||
val rows = audioRows(
|
val rows = audioRows(
|
||||||
@@ -151,25 +156,27 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
) {
|
) {
|
||||||
orderBy(randomTieBreaker.asc()).limit(limit.toLong())
|
orderBy(randomTieBreaker.asc()).limit(limit.toLong())
|
||||||
}
|
}
|
||||||
return rows.toAudioCards(now, canViewAdultContent)
|
return rows.toAudioCards(now, canViewAdultContent, locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findAudioCardsByIds(
|
override fun findAudioCardsByIds(
|
||||||
contentIds: List<Long>,
|
contentIds: List<Long>,
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
now: LocalDateTime
|
now: LocalDateTime,
|
||||||
|
locale: String
|
||||||
): List<AudioCard> {
|
): List<AudioCard> {
|
||||||
if (contentIds.isEmpty()) return emptyList()
|
if (contentIds.isEmpty()) return emptyList()
|
||||||
val orderById = contentIds.withIndex().associate { it.value to it.index }
|
val orderById = contentIds.withIndex().associate { it.value to it.index }
|
||||||
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.id.`in`(contentIds)) { this }
|
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.id.`in`(contentIds)) { this }
|
||||||
return rows.toAudioCards(now, canViewAdultContent).sortedBy { orderById[it.audioContentId] ?: Int.MAX_VALUE }
|
return rows.toAudioCards(now, canViewAdultContent, locale).sortedBy { orderById[it.audioContentId] ?: Int.MAX_VALUE }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findCommentedAudiosByIds(
|
override fun findCommentedAudiosByIds(
|
||||||
contentIds: List<Long>,
|
contentIds: List<Long>,
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
canViewAdultContent: Boolean
|
canViewAdultContent: Boolean,
|
||||||
|
locale: String
|
||||||
): List<CommentedAudio> {
|
): List<CommentedAudio> {
|
||||||
if (contentIds.isEmpty()) return emptyList()
|
if (contentIds.isEmpty()) return emptyList()
|
||||||
val contentOrder = contentIds.withIndex().associate { it.value to it.index }
|
val contentOrder = contentIds.withIndex().associate { it.value to it.index }
|
||||||
@@ -235,7 +242,7 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
return entityManager.createNativeQuery(sql)
|
val commentedAudios = entityManager.createNativeQuery(sql)
|
||||||
.setParameter("contentIds", contentIds)
|
.setParameter("contentIds", contentIds)
|
||||||
.setParameter("memberId", memberId)
|
.setParameter("memberId", memberId)
|
||||||
.setParameter("canViewAdultContent", canViewAdultContent)
|
.setParameter("canViewAdultContent", canViewAdultContent)
|
||||||
@@ -251,6 +258,11 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
?: "$cloudFrontHost/profile/default-profile.png"
|
?: "$cloudFrontHost/profile/default-profile.png"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val translatedTitleByContentId = translatedTitles(commentedAudios.map { it.audioContentId }, locale)
|
||||||
|
return commentedAudios
|
||||||
|
.map { audio ->
|
||||||
|
translatedTitleByContentId[audio.audioContentId]?.let { audio.copy(title = it) } ?: audio
|
||||||
|
}
|
||||||
.sortedBy { contentOrder[it.audioContentId] ?: Int.MAX_VALUE }
|
.sortedBy { contentOrder[it.audioContentId] ?: Int.MAX_VALUE }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,18 +467,23 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<Tuple>.toAudioCards(now: LocalDateTime, canViewAdultContent: Boolean): List<AudioCard> {
|
private fun List<Tuple>.toAudioCards(
|
||||||
|
now: LocalDateTime,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
locale: String
|
||||||
|
): List<AudioCard> {
|
||||||
if (isEmpty()) return emptyList()
|
if (isEmpty()) return emptyList()
|
||||||
val contentIds = map { it.get(audioContent.id)!! }
|
val contentIds = map { it.get(audioContent.id)!! }
|
||||||
val creatorIds = map { it.get(audioContent.member.id)!! }.distinct()
|
val creatorIds = map { it.get(audioContent.member.id)!! }.distinct()
|
||||||
val firstContentIdByCreatorId = firstAudioContentIds(creatorIds, now, canViewAdultContent)
|
val firstContentIdByCreatorId = firstAudioContentIds(creatorIds, now, canViewAdultContent)
|
||||||
val isOriginalSeriesByContentId = originalSeriesFlags(contentIds)
|
val isOriginalSeriesByContentId = originalSeriesFlags(contentIds)
|
||||||
|
val translatedTitleByContentId = translatedTitles(contentIds, locale)
|
||||||
return map { row ->
|
return map { row ->
|
||||||
val contentId = row.get(audioContent.id)!!
|
val contentId = row.get(audioContent.id)!!
|
||||||
val creatorId = row.get(audioContent.member.id)!!
|
val creatorId = row.get(audioContent.member.id)!!
|
||||||
AudioCard(
|
AudioCard(
|
||||||
audioContentId = contentId,
|
audioContentId = contentId,
|
||||||
title = row.get(audioContent.title)!!,
|
title = translatedTitleByContentId[contentId] ?: row.get(audioContent.title)!!,
|
||||||
duration = row.get(audioContent.duration),
|
duration = row.get(audioContent.duration),
|
||||||
imageUrl = row.get(audioContent.coverImage).toCdnUrl(cloudFrontHost),
|
imageUrl = row.get(audioContent.coverImage).toCdnUrl(cloudFrontHost),
|
||||||
price = row.get(audioContent.price)!!,
|
price = row.get(audioContent.price)!!,
|
||||||
@@ -479,6 +496,17 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun translatedTitles(contentIds: List<Long>, locale: String): Map<Long, String> {
|
||||||
|
if (contentIds.isEmpty()) return emptyMap()
|
||||||
|
return contentTranslationRepository.findByContentIdInAndLocale(contentIds, locale)
|
||||||
|
.mapNotNull { translation ->
|
||||||
|
translation.renderedPayload.title
|
||||||
|
.takeIf { it.isNotBlank() }
|
||||||
|
?.let { translation.contentId to it }
|
||||||
|
}
|
||||||
|
.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
private fun firstAudioContentIds(
|
private fun firstAudioContentIds(
|
||||||
creatorIds: List<Long>,
|
creatorIds: List<Long>,
|
||||||
now: LocalDateTime,
|
now: LocalDateTime,
|
||||||
|
|||||||
+15
-8
@@ -1,6 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.content.recommendation.application
|
package kr.co.vividnext.sodalive.v2.content.recommendation.application
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.i18n.Lang
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
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.AudioCard
|
||||||
@@ -17,7 +18,8 @@ import java.time.ZoneId
|
|||||||
class AudioRecommendationQueryService(
|
class AudioRecommendationQueryService(
|
||||||
private val queryPort: AudioRecommendationQueryPort,
|
private val queryPort: AudioRecommendationQueryPort,
|
||||||
private val memberContentPreferenceService: MemberContentPreferenceService,
|
private val memberContentPreferenceService: MemberContentPreferenceService,
|
||||||
private val snapshotFallbackService: AudioRecommendationSnapshotFallbackService
|
private val snapshotFallbackService: AudioRecommendationSnapshotFallbackService,
|
||||||
|
private val langContext: LangContext
|
||||||
) {
|
) {
|
||||||
fun getRecommendations(member: Member?): AudioRecommendations {
|
fun getRecommendations(member: Member?): AudioRecommendations {
|
||||||
val now = LocalDateTime.now()
|
val now = LocalDateTime.now()
|
||||||
@@ -25,6 +27,7 @@ class AudioRecommendationQueryService(
|
|||||||
val canViewAdultContent = canViewAdultContent(member)
|
val canViewAdultContent = canViewAdultContent(member)
|
||||||
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 locale = langContext.lang.code
|
||||||
val bannerLang = if (memberContentPreferenceService.resolveCountryCode(member) == "JP") Lang.JA else Lang.KO
|
val bannerLang = if (memberContentPreferenceService.resolveCountryCode(member) == "JP") Lang.JA else Lang.KO
|
||||||
val newAndHotSectionType = newAndHotSectionType(visibility)
|
val newAndHotSectionType = newAndHotSectionType(visibility)
|
||||||
val mostCommentedSectionType = mostCommentedSectionType(visibility)
|
val mostCommentedSectionType = mostCommentedSectionType(visibility)
|
||||||
@@ -48,25 +51,28 @@ class AudioRecommendationQueryService(
|
|||||||
return AudioRecommendations(
|
return AudioRecommendations(
|
||||||
banners = queryPort.findBanners(BANNER_LIMIT, memberId, canViewAdultContent, bannerLang),
|
banners = queryPort.findBanners(BANNER_LIMIT, memberId, canViewAdultContent, bannerLang),
|
||||||
originalSeries = queryPort.findOriginalSeries(ORIGINAL_SERIES_LIMIT, memberId, canViewAdultContent, now),
|
originalSeries = queryPort.findOriginalSeries(ORIGINAL_SERIES_LIMIT, memberId, canViewAdultContent, now),
|
||||||
latestAudios = queryPort.findLatestAudios(LATEST_AUDIO_LIMIT, memberId, canViewAdultContent, now),
|
latestAudios = queryPort.findLatestAudios(LATEST_AUDIO_LIMIT, memberId, canViewAdultContent, now, locale),
|
||||||
newAndHotAudios = queryPort.findAudioCardsByIds(
|
newAndHotAudios = queryPort.findAudioCardsByIds(
|
||||||
newAndHotSnapshots.map { it.targetId },
|
newAndHotSnapshots.map { it.targetId },
|
||||||
memberId,
|
memberId,
|
||||||
canViewAdultContent,
|
canViewAdultContent,
|
||||||
now
|
now,
|
||||||
|
locale
|
||||||
),
|
),
|
||||||
freeAudios = queryPort.findFreeAudios(FREE_AUDIO_LIMIT, memberId, canViewAdultContent, now),
|
freeAudios = queryPort.findFreeAudios(FREE_AUDIO_LIMIT, memberId, canViewAdultContent, now, locale),
|
||||||
pointAudios = queryPort.findPointAudios(POINT_AUDIO_LIMIT, memberId, canViewAdultContent, now),
|
pointAudios = queryPort.findPointAudios(POINT_AUDIO_LIMIT, memberId, canViewAdultContent, now, locale),
|
||||||
mostCommentedAudios = queryPort.findCommentedAudiosByIds(
|
mostCommentedAudios = queryPort.findCommentedAudiosByIds(
|
||||||
mostCommentedSnapshots.map { it.targetId },
|
mostCommentedSnapshots.map { it.targetId },
|
||||||
memberId,
|
memberId,
|
||||||
canViewAdultContent
|
canViewAdultContent,
|
||||||
|
locale
|
||||||
),
|
),
|
||||||
recommendedAudios = queryPort.findAudioCardsByIds(
|
recommendedAudios = queryPort.findAudioCardsByIds(
|
||||||
recommendedSnapshots.map { it.targetId },
|
recommendedSnapshots.map { it.targetId },
|
||||||
memberId,
|
memberId,
|
||||||
canViewAdultContent,
|
canViewAdultContent,
|
||||||
now
|
now,
|
||||||
|
locale
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -83,7 +89,8 @@ class AudioRecommendationQueryService(
|
|||||||
snapshots.map { it.targetId },
|
snapshots.map { it.targetId },
|
||||||
member.id,
|
member.id,
|
||||||
canViewAdultContent,
|
canViewAdultContent,
|
||||||
now
|
now,
|
||||||
|
langContext.lang.code
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-5
@@ -17,16 +17,40 @@ interface AudioRecommendationQueryPort {
|
|||||||
lang: Lang = Lang.KO
|
lang: Lang = Lang.KO
|
||||||
): List<RecommendationBanner>
|
): List<RecommendationBanner>
|
||||||
fun findOriginalSeries(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<OriginalSeries>
|
fun findOriginalSeries(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<OriginalSeries>
|
||||||
fun findLatestAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
|
fun findLatestAudios(
|
||||||
fun findFreeAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
|
limit: Int,
|
||||||
fun findPointAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
locale: String
|
||||||
|
): List<AudioCard>
|
||||||
|
fun findFreeAudios(
|
||||||
|
limit: Int,
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
locale: String
|
||||||
|
): List<AudioCard>
|
||||||
|
fun findPointAudios(
|
||||||
|
limit: Int,
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
locale: String
|
||||||
|
): List<AudioCard>
|
||||||
fun findAudioCardsByIds(
|
fun findAudioCardsByIds(
|
||||||
contentIds: List<Long>,
|
contentIds: List<Long>,
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
now: LocalDateTime
|
now: LocalDateTime,
|
||||||
|
locale: String
|
||||||
): List<AudioCard>
|
): List<AudioCard>
|
||||||
fun findCommentedAudiosByIds(contentIds: List<Long>, memberId: Long?, canViewAdultContent: Boolean): List<CommentedAudio>
|
fun findCommentedAudiosByIds(
|
||||||
|
contentIds: List<Long>,
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
locale: String
|
||||||
|
): List<CommentedAudio>
|
||||||
fun findNewAndHotSnapshots(
|
fun findNewAndHotSnapshots(
|
||||||
windowStart: LocalDateTime,
|
windowStart: LocalDateTime,
|
||||||
snapshotAt: LocalDateTime,
|
snapshotAt: LocalDateTime,
|
||||||
|
|||||||
+23
-2
@@ -12,6 +12,7 @@ import kr.co.vividnext.sodalive.content.order.QOrder.order
|
|||||||
import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation
|
import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation
|
||||||
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
|
||||||
import kr.co.vividnext.sodalive.content.theme.translation.QContentThemeTranslation
|
import kr.co.vividnext.sodalive.content.theme.translation.QContentThemeTranslation
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.QContentTranslation
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
||||||
import kr.co.vividnext.sodalive.member.QMember.member
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
@@ -173,10 +174,11 @@ class DefaultCreatorChannelAudioQueryRepository(
|
|||||||
val rows = findAudioContentRows(creatorId, viewerId, themeId, now, canViewAdultContent, sort, offset, limit)
|
val rows = findAudioContentRows(creatorId, viewerId, themeId, now, canViewAdultContent, sort, offset, limit)
|
||||||
val contentIds = rows.map { itAudioId(it) }
|
val contentIds = rows.map { itAudioId(it) }
|
||||||
val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent)
|
val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent)
|
||||||
|
val titlesByContentId = audioTitlesByContentIds(contentIds, locale)
|
||||||
val seriesByContentId = audioSeriesByContentIds(contentIds, locale)
|
val seriesByContentId = audioSeriesByContentIds(contentIds, locale)
|
||||||
val orderStatesByContentId = orderStatesByContentIds(viewerId, contentIds, now)
|
val orderStatesByContentId = orderStatesByContentIds(viewerId, contentIds, now)
|
||||||
|
|
||||||
return rows.map { it.toAudioRecord(firstContentId, seriesByContentId, orderStatesByContentId) }
|
return rows.map { it.toAudioRecord(firstContentId, titlesByContentId, seriesByContentId, orderStatesByContentId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun findAudioContentRows(
|
private fun findAudioContentRows(
|
||||||
@@ -307,6 +309,7 @@ class DefaultCreatorChannelAudioQueryRepository(
|
|||||||
|
|
||||||
private fun Tuple.toAudioRecord(
|
private fun Tuple.toAudioRecord(
|
||||||
firstContentId: Long?,
|
firstContentId: Long?,
|
||||||
|
titlesByContentId: Map<Long, String>,
|
||||||
seriesByContentId: Map<Long, AudioSeriesSummary>,
|
seriesByContentId: Map<Long, AudioSeriesSummary>,
|
||||||
orderStatesByContentId: Map<Long, AudioOrderState>
|
orderStatesByContentId: Map<Long, AudioOrderState>
|
||||||
): CreatorChannelAudioContentRecord {
|
): CreatorChannelAudioContentRecord {
|
||||||
@@ -315,7 +318,7 @@ class DefaultCreatorChannelAudioQueryRepository(
|
|||||||
val orderState = orderStatesByContentId[audioContentId]
|
val orderState = orderStatesByContentId[audioContentId]
|
||||||
return CreatorChannelAudioContentRecord(
|
return CreatorChannelAudioContentRecord(
|
||||||
audioContentId = audioContentId,
|
audioContentId = audioContentId,
|
||||||
title = get(audioContent.title)!!,
|
title = titlesByContentId[audioContentId] ?: get(audioContent.title)!!,
|
||||||
duration = get(audioContent.duration),
|
duration = get(audioContent.duration),
|
||||||
imagePath = get(audioContent.coverImage),
|
imagePath = get(audioContent.coverImage),
|
||||||
price = get(audioContent.price)!!,
|
price = get(audioContent.price)!!,
|
||||||
@@ -329,6 +332,24 @@ class DefaultCreatorChannelAudioQueryRepository(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun audioTitlesByContentIds(contentIds: List<Long>, locale: String): Map<Long, String> {
|
||||||
|
if (contentIds.isEmpty()) return emptyMap()
|
||||||
|
val contentTranslation = QContentTranslation("audioContentTranslation")
|
||||||
|
return queryFactory
|
||||||
|
.select(contentTranslation.contentId, contentTranslation.renderedPayload)
|
||||||
|
.from(contentTranslation)
|
||||||
|
.where(
|
||||||
|
contentTranslation.contentId.`in`(contentIds),
|
||||||
|
contentTranslation.locale.eq(locale)
|
||||||
|
)
|
||||||
|
.fetch()
|
||||||
|
.mapNotNull {
|
||||||
|
val title = it.get(contentTranslation.renderedPayload)?.title.takeUnless(String?::isNullOrBlank)
|
||||||
|
title?.let { translatedTitle -> it.get(contentTranslation.contentId)!! to translatedTitle }
|
||||||
|
}
|
||||||
|
.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
private fun firstAudioContentId(
|
private fun firstAudioContentId(
|
||||||
creatorId: Long,
|
creatorId: Long,
|
||||||
now: LocalDateTime,
|
now: LocalDateTime,
|
||||||
|
|||||||
+74
-17
@@ -10,6 +10,8 @@ import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
|||||||
import kr.co.vividnext.sodalive.content.order.OrderType
|
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||||
import kr.co.vividnext.sodalive.content.order.QOrder.order
|
import kr.co.vividnext.sodalive.content.order.QOrder.order
|
||||||
import kr.co.vividnext.sodalive.content.pin.QPinContent.pinContent
|
import kr.co.vividnext.sodalive.content.pin.QPinContent.pinContent
|
||||||
|
import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.QContentTranslation
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers.creatorCheers
|
import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers.creatorCheers
|
||||||
@@ -153,13 +155,15 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
creatorId: Long,
|
creatorId: Long,
|
||||||
now: LocalDateTime,
|
now: LocalDateTime,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
viewerId: Long?
|
viewerId: Long?,
|
||||||
|
locale: String
|
||||||
): CreatorChannelAudioContentRecord? {
|
): CreatorChannelAudioContentRecord? {
|
||||||
val row = findAudioContentRows(creatorId, now, null, canViewAdultContent, 1).firstOrNull() ?: return null
|
val row = findAudioContentRows(creatorId, now, null, canViewAdultContent, 1).firstOrNull() ?: return null
|
||||||
val contentId = itAudioId(row)
|
val contentId = itAudioId(row)
|
||||||
return row.toAudioRecord(
|
return row.toAudioRecord(
|
||||||
firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent),
|
firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent),
|
||||||
seriesByContentId = audioSeriesByContentIds(listOf(contentId)),
|
titlesByContentId = audioTitlesByContentIds(listOf(contentId), locale),
|
||||||
|
seriesByContentId = audioSeriesByContentIds(listOf(contentId), locale),
|
||||||
orderStatesByContentId = orderStatesByContentIds(viewerId, listOf(contentId), now)
|
orderStatesByContentId = orderStatesByContentIds(viewerId, listOf(contentId), now)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -209,6 +213,7 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
viewerId: Long?,
|
viewerId: Long?,
|
||||||
isViewerCreator: Boolean,
|
isViewerCreator: Boolean,
|
||||||
effectiveViewerGender: Gender?,
|
effectiveViewerGender: Gender?,
|
||||||
|
locale: String,
|
||||||
limit: Int
|
limit: Int
|
||||||
): List<CreatorChannelScheduleRecord> {
|
): List<CreatorChannelScheduleRecord> {
|
||||||
val liveSchedules = queryFactory
|
val liveSchedules = queryFactory
|
||||||
@@ -235,18 +240,21 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
)
|
)
|
||||||
.fetch()
|
.fetch()
|
||||||
|
|
||||||
|
val contentTranslation = QContentTranslation("homeScheduleContentTranslation")
|
||||||
val audioSchedules = queryFactory
|
val audioSchedules = queryFactory
|
||||||
.select(
|
.select(
|
||||||
Projections.constructor(
|
audioContent.releaseDate,
|
||||||
CreatorChannelScheduleRecord::class.java,
|
audioContent.title,
|
||||||
audioContent.releaseDate,
|
contentTranslation.renderedPayload,
|
||||||
audioContent.title,
|
audioContent.id,
|
||||||
Expressions.constant(CreatorActivityType.AUDIO),
|
audioContent.isAdult
|
||||||
audioContent.id,
|
|
||||||
audioContent.isAdult
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.from(audioContent)
|
.from(audioContent)
|
||||||
|
.leftJoin(contentTranslation)
|
||||||
|
.on(
|
||||||
|
contentTranslation.contentId.eq(audioContent.id),
|
||||||
|
contentTranslation.locale.eq(locale)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
audioContent.member.id.eq(creatorId),
|
audioContent.member.id.eq(creatorId),
|
||||||
audioContent.member.isActive.isTrue,
|
audioContent.member.isActive.isTrue,
|
||||||
@@ -256,6 +264,17 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
adultAudioCondition(canViewAdultContent)
|
adultAudioCondition(canViewAdultContent)
|
||||||
)
|
)
|
||||||
.fetch()
|
.fetch()
|
||||||
|
.map {
|
||||||
|
val originalTitle = it.get(audioContent.title)!!
|
||||||
|
val translatedTitle = it.get(contentTranslation.renderedPayload)?.title
|
||||||
|
CreatorChannelScheduleRecord(
|
||||||
|
scheduledAt = it.get(audioContent.releaseDate)!!,
|
||||||
|
title = translatedTitle.takeUnless(String?::isNullOrBlank) ?: originalTitle,
|
||||||
|
type = CreatorActivityType.AUDIO,
|
||||||
|
targetId = it.get(audioContent.id)!!,
|
||||||
|
isAdult = it.get(audioContent.isAdult)!!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (liveSchedules + audioSchedules)
|
return (liveSchedules + audioSchedules)
|
||||||
.sortedWith(compareBy<CreatorChannelScheduleRecord> { it.scheduledAt }.thenBy { it.type.sortOrder })
|
.sortedWith(compareBy<CreatorChannelScheduleRecord> { it.scheduledAt }.thenBy { it.type.sortOrder })
|
||||||
@@ -268,14 +287,16 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
latestAudioContentId: Long?,
|
latestAudioContentId: Long?,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
viewerId: Long?,
|
viewerId: Long?,
|
||||||
|
locale: String,
|
||||||
limit: Int
|
limit: Int
|
||||||
): List<CreatorChannelAudioContentRecord> {
|
): List<CreatorChannelAudioContentRecord> {
|
||||||
val rows = findAudioContentRows(creatorId, now, latestAudioContentId, canViewAdultContent, limit, pinFirst = true)
|
val rows = findAudioContentRows(creatorId, now, latestAudioContentId, canViewAdultContent, limit, pinFirst = true)
|
||||||
val contentIds = rows.map { itAudioId(it) }
|
val contentIds = rows.map { itAudioId(it) }
|
||||||
val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent)
|
val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent)
|
||||||
val seriesByContentId = audioSeriesByContentIds(contentIds)
|
val titlesByContentId = audioTitlesByContentIds(contentIds, locale)
|
||||||
|
val seriesByContentId = audioSeriesByContentIds(contentIds, locale)
|
||||||
val orderStatesByContentId = orderStatesByContentIds(viewerId, contentIds, now)
|
val orderStatesByContentId = orderStatesByContentIds(viewerId, contentIds, now)
|
||||||
return rows.map { it.toAudioRecord(firstContentId, seriesByContentId, orderStatesByContentId) }
|
return rows.map { it.toAudioRecord(firstContentId, titlesByContentId, seriesByContentId, orderStatesByContentId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findSeries(
|
override fun findSeries(
|
||||||
@@ -284,16 +305,24 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
now: LocalDateTime,
|
now: LocalDateTime,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
|
locale: String,
|
||||||
limit: Int
|
limit: Int
|
||||||
): List<CreatorChannelSeriesRecord> {
|
): List<CreatorChannelSeriesRecord> {
|
||||||
|
val seriesTranslation = QSeriesTranslation("homeSeriesTranslation")
|
||||||
val seriesRows = queryFactory
|
val seriesRows = queryFactory
|
||||||
.select(
|
.select(
|
||||||
series.id,
|
series.id,
|
||||||
series.title,
|
series.title,
|
||||||
|
seriesTranslation.renderedPayload,
|
||||||
series.coverImage,
|
series.coverImage,
|
||||||
series.isOriginal
|
series.isOriginal
|
||||||
)
|
)
|
||||||
.from(series)
|
.from(series)
|
||||||
|
.leftJoin(seriesTranslation)
|
||||||
|
.on(
|
||||||
|
seriesTranslation.seriesId.eq(series.id),
|
||||||
|
seriesTranslation.locale.eq(locale)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
series.member.id.eq(creatorId),
|
series.member.id.eq(creatorId),
|
||||||
series.member.isActive.isTrue,
|
series.member.isActive.isTrue,
|
||||||
@@ -317,7 +346,8 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
val seriesId = seriesRow.get(series.id)!!
|
val seriesId = seriesRow.get(series.id)!!
|
||||||
CreatorChannelSeriesRecord(
|
CreatorChannelSeriesRecord(
|
||||||
seriesId = seriesId,
|
seriesId = seriesId,
|
||||||
title = seriesRow.get(series.title)!!,
|
title = seriesRow.get(seriesTranslation.renderedPayload)?.title.takeUnless(String?::isNullOrBlank)
|
||||||
|
?: seriesRow.get(series.title)!!,
|
||||||
coverImagePath = seriesRow.get(series.coverImage),
|
coverImagePath = seriesRow.get(series.coverImage),
|
||||||
numberOfContent = stats.contentCount,
|
numberOfContent = stats.contentCount,
|
||||||
isNew = seriesId in newSeriesIds,
|
isNew = seriesId in newSeriesIds,
|
||||||
@@ -493,6 +523,7 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
|
|
||||||
private fun com.querydsl.core.Tuple.toAudioRecord(
|
private fun com.querydsl.core.Tuple.toAudioRecord(
|
||||||
firstContentId: Long?,
|
firstContentId: Long?,
|
||||||
|
titlesByContentId: Map<Long, String>,
|
||||||
seriesByContentId: Map<Long, AudioSeriesSummary>,
|
seriesByContentId: Map<Long, AudioSeriesSummary>,
|
||||||
orderStatesByContentId: Map<Long, AudioOrderState>
|
orderStatesByContentId: Map<Long, AudioOrderState>
|
||||||
): CreatorChannelAudioContentRecord {
|
): CreatorChannelAudioContentRecord {
|
||||||
@@ -501,7 +532,7 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
val orderState = orderStatesByContentId[audioContentId]
|
val orderState = orderStatesByContentId[audioContentId]
|
||||||
return CreatorChannelAudioContentRecord(
|
return CreatorChannelAudioContentRecord(
|
||||||
audioContentId = audioContentId,
|
audioContentId = audioContentId,
|
||||||
title = get(audioContent.title)!!,
|
title = titlesByContentId[audioContentId] ?: get(audioContent.title)!!,
|
||||||
duration = get(audioContent.duration),
|
duration = get(audioContent.duration),
|
||||||
imagePath = get(audioContent.coverImage),
|
imagePath = get(audioContent.coverImage),
|
||||||
price = get(audioContent.price)!!,
|
price = get(audioContent.price)!!,
|
||||||
@@ -515,6 +546,24 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun audioTitlesByContentIds(contentIds: List<Long>, locale: String): Map<Long, String> {
|
||||||
|
if (contentIds.isEmpty()) return emptyMap()
|
||||||
|
val contentTranslation = QContentTranslation("homeAudioContentTranslation")
|
||||||
|
return queryFactory
|
||||||
|
.select(contentTranslation.contentId, contentTranslation.renderedPayload)
|
||||||
|
.from(contentTranslation)
|
||||||
|
.where(
|
||||||
|
contentTranslation.contentId.`in`(contentIds),
|
||||||
|
contentTranslation.locale.eq(locale)
|
||||||
|
)
|
||||||
|
.fetch()
|
||||||
|
.mapNotNull {
|
||||||
|
val title = it.get(contentTranslation.renderedPayload)?.title.takeUnless(String?::isNullOrBlank)
|
||||||
|
title?.let { translatedTitle -> it.get(contentTranslation.contentId)!! to translatedTitle }
|
||||||
|
}
|
||||||
|
.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
private fun orderStatesByContentIds(
|
private fun orderStatesByContentIds(
|
||||||
viewerId: Long?,
|
viewerId: Long?,
|
||||||
contentIds: List<Long>,
|
contentIds: List<Long>,
|
||||||
@@ -542,17 +591,25 @@ class DefaultCreatorChannelHomeQueryRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun audioSeriesByContentIds(contentIds: List<Long>): Map<Long, AudioSeriesSummary> {
|
private fun audioSeriesByContentIds(contentIds: List<Long>, locale: String): Map<Long, AudioSeriesSummary> {
|
||||||
if (contentIds.isEmpty()) return emptyMap()
|
if (contentIds.isEmpty()) return emptyMap()
|
||||||
|
val seriesTranslation = QSeriesTranslation("homeAudioSeriesTranslation")
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(seriesContent.content.id, series.title, series.isOriginal)
|
.select(seriesContent.content.id, series.title, seriesTranslation.renderedPayload, series.isOriginal)
|
||||||
.from(seriesContent)
|
.from(seriesContent)
|
||||||
.innerJoin(seriesContent.series, series)
|
.innerJoin(seriesContent.series, series)
|
||||||
|
.leftJoin(seriesTranslation)
|
||||||
|
.on(
|
||||||
|
seriesTranslation.seriesId.eq(series.id),
|
||||||
|
seriesTranslation.locale.eq(locale)
|
||||||
|
)
|
||||||
.where(seriesContent.content.id.`in`(contentIds))
|
.where(seriesContent.content.id.`in`(contentIds))
|
||||||
.fetch()
|
.fetch()
|
||||||
.associate {
|
.associate {
|
||||||
|
val originalTitle = it.get(series.title)!!
|
||||||
|
val translatedTitle = it.get(seriesTranslation.renderedPayload)?.title
|
||||||
it.get(seriesContent.content.id)!! to AudioSeriesSummary(
|
it.get(seriesContent.content.id)!! to AudioSeriesSummary(
|
||||||
title = it.get(series.title)!!,
|
title = translatedTitle.takeUnless(String?::isNullOrBlank) ?: originalTitle,
|
||||||
isOriginal = it.get(series.isOriginal)!!
|
isOriginal = it.get(series.isOriginal)!!
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-4
@@ -73,8 +73,9 @@ class CreatorChannelHomeQueryService(
|
|||||||
val canViewAdultContent = preference.isAdult
|
val canViewAdultContent = preference.isAdult
|
||||||
val isViewerCreator = viewer.role == MemberRole.CREATOR
|
val isViewerCreator = viewer.role == MemberRole.CREATOR
|
||||||
val effectiveViewerGender = viewer.effectiveGender()
|
val effectiveViewerGender = viewer.effectiveGender()
|
||||||
|
val locale = langContext.lang.code
|
||||||
val latestAudioContent = queryPort
|
val latestAudioContent = queryPort
|
||||||
.findLatestAudioContent(creatorId, now, canViewAdultContent, viewerId)
|
.findLatestAudioContent(creatorId, now, canViewAdultContent, viewerId, locale)
|
||||||
?.toDomain()
|
?.toDomain()
|
||||||
val audioContents = queryPolicy.excludeLatestAudioContent(
|
val audioContents = queryPolicy.excludeLatestAudioContent(
|
||||||
queryPort.findAudioContents(
|
queryPort.findAudioContents(
|
||||||
@@ -82,7 +83,8 @@ class CreatorChannelHomeQueryService(
|
|||||||
now = now,
|
now = now,
|
||||||
latestAudioContentId = latestAudioContent?.audioContentId,
|
latestAudioContentId = latestAudioContent?.audioContentId,
|
||||||
canViewAdultContent = canViewAdultContent,
|
canViewAdultContent = canViewAdultContent,
|
||||||
viewerId = viewerId
|
viewerId = viewerId,
|
||||||
|
locale = locale
|
||||||
).map { it.toDomain() },
|
).map { it.toDomain() },
|
||||||
latestAudioContent?.audioContentId
|
latestAudioContent?.audioContentId
|
||||||
)
|
)
|
||||||
@@ -113,7 +115,8 @@ class CreatorChannelHomeQueryService(
|
|||||||
canViewAdultContent = canViewAdultContent,
|
canViewAdultContent = canViewAdultContent,
|
||||||
viewerId = viewerId,
|
viewerId = viewerId,
|
||||||
isViewerCreator = isViewerCreator,
|
isViewerCreator = isViewerCreator,
|
||||||
effectiveViewerGender = effectiveViewerGender
|
effectiveViewerGender = effectiveViewerGender,
|
||||||
|
locale = locale
|
||||||
).map { it.toDomain() },
|
).map { it.toDomain() },
|
||||||
now,
|
now,
|
||||||
canViewAdultContent
|
canViewAdultContent
|
||||||
@@ -124,7 +127,8 @@ class CreatorChannelHomeQueryService(
|
|||||||
viewerId = viewerId,
|
viewerId = viewerId,
|
||||||
now = now,
|
now = now,
|
||||||
canViewAdultContent = canViewAdultContent,
|
canViewAdultContent = canViewAdultContent,
|
||||||
contentType = preference.contentType
|
contentType = preference.contentType,
|
||||||
|
locale = locale
|
||||||
).map { it.toDomain() },
|
).map { it.toDomain() },
|
||||||
communities = communityQueryService.findHomeCommunityPosts(
|
communities = communityQueryService.findHomeCommunityPosts(
|
||||||
creatorId = creatorId,
|
creatorId = creatorId,
|
||||||
|
|||||||
+5
-1
@@ -24,7 +24,8 @@ interface CreatorChannelHomeQueryPort {
|
|||||||
creatorId: Long,
|
creatorId: Long,
|
||||||
now: LocalDateTime,
|
now: LocalDateTime,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
viewerId: Long? = null
|
viewerId: Long? = null,
|
||||||
|
locale: String = "ko"
|
||||||
): CreatorChannelAudioContentRecord?
|
): CreatorChannelAudioContentRecord?
|
||||||
|
|
||||||
fun findChannelDonations(
|
fun findChannelDonations(
|
||||||
@@ -41,6 +42,7 @@ interface CreatorChannelHomeQueryPort {
|
|||||||
viewerId: Long?,
|
viewerId: Long?,
|
||||||
isViewerCreator: Boolean,
|
isViewerCreator: Boolean,
|
||||||
effectiveViewerGender: Gender?,
|
effectiveViewerGender: Gender?,
|
||||||
|
locale: String = "ko",
|
||||||
limit: Int = 3
|
limit: Int = 3
|
||||||
): List<CreatorChannelScheduleRecord>
|
): List<CreatorChannelScheduleRecord>
|
||||||
|
|
||||||
@@ -50,6 +52,7 @@ interface CreatorChannelHomeQueryPort {
|
|||||||
latestAudioContentId: Long?,
|
latestAudioContentId: Long?,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
viewerId: Long? = null,
|
viewerId: Long? = null,
|
||||||
|
locale: String = "ko",
|
||||||
limit: Int = 9
|
limit: Int = 9
|
||||||
): List<CreatorChannelAudioContentRecord>
|
): List<CreatorChannelAudioContentRecord>
|
||||||
|
|
||||||
@@ -59,6 +62,7 @@ interface CreatorChannelHomeQueryPort {
|
|||||||
now: LocalDateTime,
|
now: LocalDateTime,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
|
locale: String = "ko",
|
||||||
limit: Int = 8
|
limit: Int = 8
|
||||||
): List<CreatorChannelSeriesRecord>
|
): List<CreatorChannelSeriesRecord>
|
||||||
|
|
||||||
|
|||||||
+19
-3
@@ -15,6 +15,7 @@ import kr.co.vividnext.sodalive.chat.room.QChatMessage.chatMessage
|
|||||||
import kr.co.vividnext.sodalive.chat.room.QChatParticipant.chatParticipant
|
import kr.co.vividnext.sodalive.chat.room.QChatParticipant.chatParticipant
|
||||||
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.banner.QAudioContentBanner.audioContentBanner
|
import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
||||||
import kr.co.vividnext.sodalive.event.QEvent.event
|
import kr.co.vividnext.sodalive.event.QEvent.event
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity
|
||||||
@@ -50,7 +51,8 @@ import javax.persistence.EntityManager
|
|||||||
@Repository
|
@Repository
|
||||||
class DefaultHomeRecommendationQueryRepository(
|
class DefaultHomeRecommendationQueryRepository(
|
||||||
private val queryFactory: JPAQueryFactory,
|
private val queryFactory: JPAQueryFactory,
|
||||||
private val entityManager: EntityManager
|
private val entityManager: EntityManager,
|
||||||
|
private val contentTranslationRepository: ContentTranslationRepository
|
||||||
) : HomeRecommendationQueryRepository {
|
) : HomeRecommendationQueryRepository {
|
||||||
override fun findLiveRecommendations(
|
override fun findLiveRecommendations(
|
||||||
offset: Long,
|
offset: Long,
|
||||||
@@ -367,7 +369,8 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
offset: Long,
|
offset: Long,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
includeAdultContents: Boolean
|
includeAdultContents: Boolean,
|
||||||
|
locale: String
|
||||||
): List<HomeFirstAudioContentRecord> {
|
): List<HomeFirstAudioContentRecord> {
|
||||||
val sql = """
|
val sql = """
|
||||||
with creator_debut as (
|
with creator_debut as (
|
||||||
@@ -475,7 +478,7 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val rows = query.resultList as List<Array<Any?>>
|
val rows = query.resultList as List<Array<Any?>>
|
||||||
|
|
||||||
return rows.map { row ->
|
val firstAudioContents = rows.map { row ->
|
||||||
HomeFirstAudioContentRecord(
|
HomeFirstAudioContentRecord(
|
||||||
contentId = (row[0] as Number).toLong(),
|
contentId = (row[0] as Number).toLong(),
|
||||||
creatorId = (row[1] as Number).toLong(),
|
creatorId = (row[1] as Number).toLong(),
|
||||||
@@ -489,6 +492,19 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
isOriginalSeries = row[9].toNativeBoolean()
|
isOriginalSeries = row[9].toNativeBoolean()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (firstAudioContents.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val translatedTitleByContentId = contentTranslationRepository
|
||||||
|
.findByContentIdInAndLocale(firstAudioContents.map { it.contentId }, locale)
|
||||||
|
.mapNotNull { translation ->
|
||||||
|
translation.renderedPayload.title
|
||||||
|
.takeIf { it.isNotBlank() }
|
||||||
|
?.let { translation.contentId to it }
|
||||||
|
}
|
||||||
|
.toMap()
|
||||||
|
return firstAudioContents.map { audio ->
|
||||||
|
translatedTitleByContentId[audio.contentId]?.let { audio.copy(title = it) } ?: audio
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findAiCharacterSnapshots(
|
override fun findAiCharacterSnapshots(
|
||||||
|
|||||||
+4
-2
@@ -1,6 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.recommendation.application
|
package kr.co.vividnext.sodalive.v2.recommendation.application
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.i18n.Lang
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy
|
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||||
@@ -29,7 +30,8 @@ class HomeRecommendationQueryService(
|
|||||||
private val aiCharacterSnapshotFallbackService: AiCharacterSnapshotFallbackPort? = null,
|
private val aiCharacterSnapshotFallbackService: AiCharacterSnapshotFallbackPort? = null,
|
||||||
private val cheerCreatorSnapshotFallbackService: CheerCreatorSnapshotFallbackPort? = null,
|
private val cheerCreatorSnapshotFallbackService: CheerCreatorSnapshotFallbackPort? = null,
|
||||||
private val popularCommunitySnapshotFallbackService: PopularCommunitySnapshotFallbackPort? = null,
|
private val popularCommunitySnapshotFallbackService: PopularCommunitySnapshotFallbackPort? = null,
|
||||||
private val clock: Clock = Clock.systemUTC()
|
private val clock: Clock = Clock.systemUTC(),
|
||||||
|
private val langContext: LangContext
|
||||||
) {
|
) {
|
||||||
private val snapshotWindowPolicy = RecommendationSnapshotWindowPolicy()
|
private val snapshotWindowPolicy = RecommendationSnapshotWindowPolicy()
|
||||||
|
|
||||||
@@ -76,7 +78,7 @@ class HomeRecommendationQueryService(
|
|||||||
memberId: Long? = null,
|
memberId: Long? = null,
|
||||||
includeAdultContents: Boolean = false
|
includeAdultContents: Boolean = false
|
||||||
): List<HomeFirstAudioContentRecord> {
|
): List<HomeFirstAudioContentRecord> {
|
||||||
return queryPort.findFirstAudioContents(now, offset, limit, memberId, includeAdultContents)
|
return queryPort.findFirstAudioContents(now, offset, limit, memberId, includeAdultContents, langContext.lang.code)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun findAiCharacterRecommendations(
|
fun findAiCharacterRecommendations(
|
||||||
|
|||||||
+2
-1
@@ -38,7 +38,8 @@ interface HomeRecommendationQueryPort {
|
|||||||
offset: Long = 0,
|
offset: Long = 0,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
memberId: Long? = null,
|
memberId: Long? = null,
|
||||||
includeAdultContents: Boolean = false
|
includeAdultContents: Boolean = false,
|
||||||
|
locale: String
|
||||||
): List<HomeFirstAudioContentRecord>
|
): List<HomeFirstAudioContentRecord>
|
||||||
|
|
||||||
fun findAiCharacterSnapshots(
|
fun findAiCharacterSnapshots(
|
||||||
|
|||||||
+58
@@ -2,6 +2,8 @@ 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.AudioContent
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
@@ -93,6 +95,44 @@ class ContentOverviewEndToEndTest @Autowired constructor(
|
|||||||
.andExpect(jsonPath("$.data.hasNext").value(false))
|
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("콘텐츠 전체보기 New & Hot은 같은 요청에서 en-ja-en 제목 번역을 격리한다")
|
||||||
|
fun shouldIsolateRequestedLocaleTitlesForNewAndHotAudioOverview() {
|
||||||
|
val fixture = createNewAndHotFixture("content-overview-new-hot")
|
||||||
|
saveContentTranslation(fixture.audioContentId, "en", "content-overview-new-hot-en-translated")
|
||||||
|
saveContentTranslation(fixture.audioContentId, "ja", "content-overview-new-hot-ja-translated")
|
||||||
|
|
||||||
|
expectOverviewTitle(fixture, "NEW_AND_HOT_AUDIO", "en", "content-overview-new-hot-en-translated")
|
||||||
|
expectOverviewTitle(fixture, "NEW_AND_HOT_AUDIO", "ja", "content-overview-new-hot-ja-translated")
|
||||||
|
expectOverviewTitle(fixture, "NEW_AND_HOT_AUDIO", "en", "content-overview-new-hot-en-translated")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("콘텐츠 전체보기 첫 오디오는 같은 요청에서 en-ja-en 제목 번역을 격리한다")
|
||||||
|
fun shouldIsolateRequestedLocaleTitlesForFirstAudioContentOverview() {
|
||||||
|
val fixture = createFirstAudioFixture("content-overview-first")
|
||||||
|
saveContentTranslation(fixture.audioContentId, "en", "content-overview-first-en-translated")
|
||||||
|
saveContentTranslation(fixture.audioContentId, "ja", "content-overview-first-ja-translated")
|
||||||
|
|
||||||
|
expectOverviewTitle(fixture, "FIRST_AUDIO_CONTENT", "en", "content-overview-first-en-translated")
|
||||||
|
expectOverviewTitle(fixture, "FIRST_AUDIO_CONTENT", "ja", "content-overview-first-ja-translated")
|
||||||
|
expectOverviewTitle(fixture, "FIRST_AUDIO_CONTENT", "en", "content-overview-first-en-translated")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun expectOverviewTitle(fixture: Fixture, type: String, locale: String, title: String) {
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/contents")
|
||||||
|
.param("type", type)
|
||||||
|
.param("page", "0")
|
||||||
|
.param("size", "20")
|
||||||
|
.header("Accept-Language", locale)
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.items[0].contentId").value(fixture.audioContentId))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].title").value(title))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("콘텐츠 전체보기 API는 유효하지 않은 type을 New & Hot으로 대체한다")
|
@DisplayName("콘텐츠 전체보기 API는 유효하지 않은 type을 New & Hot으로 대체한다")
|
||||||
fun shouldFallbackInvalidTypeToNewAndHotAudio() {
|
fun shouldFallbackInvalidTypeToNewAndHotAudio() {
|
||||||
@@ -197,6 +237,24 @@ class ContentOverviewEndToEndTest @Autowired constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveContentTranslation(contentId: Long, locale: String, title: String) {
|
||||||
|
transactionTemplate.execute {
|
||||||
|
val translation = ContentTranslation(
|
||||||
|
contentId = contentId,
|
||||||
|
locale = locale,
|
||||||
|
renderedPayload = ContentTranslationPayload(title = title, detail = "", tags = "")
|
||||||
|
)
|
||||||
|
entityManager.persist(translation)
|
||||||
|
entityManager.flush()
|
||||||
|
val payload = "{\"title\":\"$title\",\"detail\":\"\",\"tags\":\"\"}"
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"update content_translation set rendered_payload = '$payload' format json where id = :id"
|
||||||
|
)
|
||||||
|
.setParameter("id", translation.id)
|
||||||
|
.executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private data class Fixture(
|
private data class Fixture(
|
||||||
val viewer: Member,
|
val viewer: Member,
|
||||||
val audioContentId: Long
|
val audioContentId: Long
|
||||||
|
|||||||
+63
@@ -1,5 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.`in`.web
|
package kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.`in`.web
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
@@ -29,6 +31,25 @@ class AudioRankingControllerTest @Autowired constructor(
|
|||||||
private val mockMvc: MockMvc,
|
private val mockMvc: MockMvc,
|
||||||
private val entityManager: EntityManager
|
private val entityManager: EntityManager
|
||||||
) {
|
) {
|
||||||
|
@Test
|
||||||
|
@DisplayName("오디오 랭킹 조회는 같은 스냅샷에서 en-ja-en 제목 번역을 격리한다")
|
||||||
|
fun shouldIsolateRequestedLocaleTitlesWithoutChangingRankingResponse() {
|
||||||
|
saveSnapshot(contentId = 1L, rank = 1, aggregationStartAtUtc = PREVIOUS_START_AT, aggregationEndAtUtc = PREVIOUS_END_AT)
|
||||||
|
saveSnapshot(contentId = 2L, rank = 2, aggregationStartAtUtc = PREVIOUS_START_AT, aggregationEndAtUtc = PREVIOUS_END_AT)
|
||||||
|
saveSnapshot(contentId = 2L, rank = 1, aggregationStartAtUtc = LATEST_START_AT, aggregationEndAtUtc = LATEST_END_AT)
|
||||||
|
saveSnapshot(contentId = 3L, rank = 2, aggregationStartAtUtc = LATEST_START_AT, aggregationEndAtUtc = LATEST_END_AT)
|
||||||
|
saveContentTranslation(2L, "en", "en-title-2")
|
||||||
|
saveContentTranslation(2L, "ja", "ja-title-2")
|
||||||
|
saveContentTranslation(3L, "en", "en-title-3")
|
||||||
|
saveContentTranslation(3L, "ja", "ja-title-3")
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
expectRisingRanking("en", "en-title-2", "en-title-3")
|
||||||
|
expectRisingRanking("ja", "ja-title-2", "ja-title-3")
|
||||||
|
expectRisingRanking("en", "en-title-2", "en-title-3")
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("오디오 랭킹 조회는 비회원에게 200 OK와 기본 WEEKLY_POPULAR 랭킹을 반환한다")
|
@DisplayName("오디오 랭킹 조회는 비회원에게 200 OK와 기본 WEEKLY_POPULAR 랭킹을 반환한다")
|
||||||
fun shouldReturnWeeklyPopularRankingsForAnonymousByDefault() {
|
fun shouldReturnWeeklyPopularRankingsForAnonymousByDefault() {
|
||||||
@@ -113,6 +134,48 @@ class AudioRankingControllerTest @Autowired constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveContentTranslation(contentId: Long, locale: String, title: String) {
|
||||||
|
val translation = ContentTranslation(
|
||||||
|
contentId = contentId,
|
||||||
|
locale = locale,
|
||||||
|
renderedPayload = ContentTranslationPayload(title = title, detail = "", tags = "")
|
||||||
|
)
|
||||||
|
entityManager.persist(translation)
|
||||||
|
entityManager.flush()
|
||||||
|
val payload = "{\"title\":\"$title\",\"detail\":\"\",\"tags\":\"\"}"
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"update content_translation set rendered_payload = '$payload' format json where id = :id"
|
||||||
|
)
|
||||||
|
.setParameter("id", translation.id)
|
||||||
|
.executeUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun expectRisingRanking(locale: String, firstTitle: String, secondTitle: String) {
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/audio/rankings")
|
||||||
|
.param("type", "RISING")
|
||||||
|
.header("Accept-Language", locale)
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.showRankChange").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.type").value("RISING"))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].contentId").value(2L))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].title").value(firstTitle))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].creatorNickname").value("creator-2"))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].rank").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].rankChange").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].isNew").value(false))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].coverImageUrl").value("https://cdn.test/cover-2.png"))
|
||||||
|
.andExpect(jsonPath("$.data.items[1].contentId").value(3L))
|
||||||
|
.andExpect(jsonPath("$.data.items[1].title").value(secondTitle))
|
||||||
|
.andExpect(jsonPath("$.data.items[1].creatorNickname").value("creator-3"))
|
||||||
|
.andExpect(jsonPath("$.data.items[1].rank").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.items[1].rankChange").doesNotExist())
|
||||||
|
.andExpect(jsonPath("$.data.items[1].isNew").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.items[1].coverImageUrl").value("https://cdn.test/cover-3.png"))
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val PREVIOUS_START_AT = LocalDateTime.of(2026, 5, 25, 15, 0)
|
private val PREVIOUS_START_AT = LocalDateTime.of(2026, 5, 25, 15, 0)
|
||||||
private val PREVIOUS_END_AT = LocalDateTime.of(2026, 6, 1, 15, 0)
|
private val PREVIOUS_END_AT = LocalDateTime.of(2026, 6, 1, 15, 0)
|
||||||
|
|||||||
+72
-3
@@ -4,6 +4,8 @@ import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
|
|||||||
import kr.co.vividnext.sodalive.content.AudioContent
|
import kr.co.vividnext.sodalive.content.AudioContent
|
||||||
import kr.co.vividnext.sodalive.content.comment.AudioContentComment
|
import kr.co.vividnext.sodalive.content.comment.AudioContentComment
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
||||||
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
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
@@ -54,9 +56,11 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
|
|||||||
.andExpect(jsonPath("$.data.latestAudios[0].isOriginalSeries").value(true))
|
.andExpect(jsonPath("$.data.latestAudios[0].isOriginalSeries").value(true))
|
||||||
.andExpect(jsonPath("$.data.recommendedAudios").isArray)
|
.andExpect(jsonPath("$.data.recommendedAudios").isArray)
|
||||||
.andExpect(jsonPath("$.data.recommendedAudios[0].audioContentId").value(fixture.audioContentId))
|
.andExpect(jsonPath("$.data.recommendedAudios[0].audioContentId").value(fixture.audioContentId))
|
||||||
.andExpect(jsonPath("$.data.pointAudios.length()").value(1))
|
.andExpect(
|
||||||
.andExpect(jsonPath("$.data.pointAudios[0].audioContentId").value(fixture.paidPointAudioContentId))
|
jsonPath(
|
||||||
.andExpect(jsonPath("$.data.pointAudios[0].price").value(100))
|
"$.data.pointAudios[?(@.audioContentId == ${fixture.paidPointAudioContentId} && @.price == 100)]"
|
||||||
|
).isNotEmpty
|
||||||
|
)
|
||||||
.andExpect(jsonPath("$.data.mostCommentedAudios[0].latestComment").value("latest e2e comment"))
|
.andExpect(jsonPath("$.data.mostCommentedAudios[0].latestComment").value("latest e2e comment"))
|
||||||
.andExpect(
|
.andExpect(
|
||||||
jsonPath("$.data.mostCommentedAudios[0].latestCommentWriterProfileImageUrl")
|
jsonPath("$.data.mostCommentedAudios[0].latestCommentWriterProfileImageUrl")
|
||||||
@@ -64,6 +68,51 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("오디오 추천 API는 같은 요청에서 en-ja-en 제목 번역을 격리한다")
|
||||||
|
fun shouldIsolateRequestedLocaleTitlesForAllRecommendationAudioCards() {
|
||||||
|
val fixture = createFixture()
|
||||||
|
transactionTemplate.execute {
|
||||||
|
saveContentTranslation(fixture.audioContentId, "en", "audio-recommendation-e2e-en")
|
||||||
|
saveContentTranslation(fixture.audioContentId, "ja", "audio-recommendation-e2e-ja")
|
||||||
|
saveContentTranslation(fixture.paidPointAudioContentId, "en", "paid-point-en")
|
||||||
|
saveContentTranslation(fixture.paidPointAudioContentId, "ja", "paid-point-ja")
|
||||||
|
entityManager.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
expectRecommendationTitles(fixture, "en", "audio-recommendation-e2e-en", "paid-point-en")
|
||||||
|
expectRecommendationTitles(fixture, "ja", "audio-recommendation-e2e-ja", "paid-point-ja")
|
||||||
|
expectRecommendationTitles(fixture, "en", "audio-recommendation-e2e-en", "paid-point-en")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun expectRecommendationTitles(
|
||||||
|
fixture: Fixture,
|
||||||
|
locale: String,
|
||||||
|
audioTitle: String,
|
||||||
|
pointAudioTitle: String
|
||||||
|
) {
|
||||||
|
mockMvc.perform(get("/api/v2/audio/recommendations").header("Accept-Language", locale))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(
|
||||||
|
jsonPath(translatedTitlePath("latestAudios", fixture.audioContentId, audioTitle)).isNotEmpty
|
||||||
|
)
|
||||||
|
.andExpect(
|
||||||
|
jsonPath(translatedTitlePath("newAndHotAudios", fixture.audioContentId, audioTitle)).isNotEmpty
|
||||||
|
)
|
||||||
|
.andExpect(
|
||||||
|
jsonPath(translatedTitlePath("freeAudios", fixture.audioContentId, audioTitle)).isNotEmpty
|
||||||
|
)
|
||||||
|
.andExpect(
|
||||||
|
jsonPath(translatedTitlePath("pointAudios", fixture.paidPointAudioContentId, pointAudioTitle)).isNotEmpty
|
||||||
|
)
|
||||||
|
.andExpect(
|
||||||
|
jsonPath(translatedTitlePath("mostCommentedAudios", fixture.audioContentId, audioTitle)).isNotEmpty
|
||||||
|
)
|
||||||
|
.andExpect(
|
||||||
|
jsonPath(translatedTitlePath("recommendedAudios", fixture.audioContentId, audioTitle)).isNotEmpty
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun createFixture(): Fixture {
|
private fun createFixture(): Fixture {
|
||||||
return transactionTemplate.execute {
|
return transactionTemplate.execute {
|
||||||
val now = LocalDateTime.now().minusHours(1)
|
val now = LocalDateTime.now().minusHours(1)
|
||||||
@@ -183,6 +232,26 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveContentTranslation(contentId: Long, locale: String, title: String) {
|
||||||
|
val translation = ContentTranslation(
|
||||||
|
contentId = contentId,
|
||||||
|
locale = locale,
|
||||||
|
renderedPayload = ContentTranslationPayload(title = title, detail = "", tags = "")
|
||||||
|
)
|
||||||
|
entityManager.persist(translation)
|
||||||
|
entityManager.flush()
|
||||||
|
val payload = "{\"title\":\"$title\",\"detail\":\"\",\"tags\":\"\"}"
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"update content_translation set rendered_payload = '$payload' format json where id = :id"
|
||||||
|
)
|
||||||
|
.setParameter("id", translation.id)
|
||||||
|
.executeUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun translatedTitlePath(collection: String, contentId: Long, title: String): String {
|
||||||
|
return "$.data.$collection[?(@.audioContentId == $contentId && @.title == '$title')]"
|
||||||
|
}
|
||||||
|
|
||||||
private data class Fixture(
|
private data class Fixture(
|
||||||
val seriesId: Long,
|
val seriesId: Long,
|
||||||
val audioContentId: Long,
|
val audioContentId: Long,
|
||||||
|
|||||||
+8
-2
@@ -1,6 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.home.application
|
package kr.co.vividnext.sodalive.v2.api.home.application
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.i18n.Lang
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
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.recommendation.application.HomeRecommendationQueryService
|
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
|
||||||
@@ -26,7 +27,11 @@ import java.time.LocalDateTime
|
|||||||
|
|
||||||
class HomeRecommendationFacadeTest {
|
class HomeRecommendationFacadeTest {
|
||||||
private val queryPort = FakeHomeRecommendationQueryPort()
|
private val queryPort = FakeHomeRecommendationQueryPort()
|
||||||
private val queryService = HomeRecommendationQueryService(queryPort, EmptyRecommendationSnapshotPort())
|
private val queryService = HomeRecommendationQueryService(
|
||||||
|
queryPort,
|
||||||
|
EmptyRecommendationSnapshotPort(),
|
||||||
|
langContext = LangContext()
|
||||||
|
)
|
||||||
private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||||
private val facade = HomeRecommendationFacade(queryService, preferenceService, "https://cdn.test")
|
private val facade = HomeRecommendationFacade(queryService, preferenceService, "https://cdn.test")
|
||||||
|
|
||||||
@@ -135,7 +140,8 @@ class HomeRecommendationFacadeTest {
|
|||||||
offset: Long,
|
offset: Long,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
includeAdultContents: Boolean
|
includeAdultContents: Boolean,
|
||||||
|
locale: String
|
||||||
): List<HomeFirstAudioContentRecord> = firstAudioContents
|
): List<HomeFirstAudioContentRecord> = firstAudioContents
|
||||||
|
|
||||||
override fun findAiCharacterSnapshots(
|
override fun findAiCharacterSnapshots(
|
||||||
|
|||||||
+69
-8
@@ -9,6 +9,8 @@ import kr.co.vividnext.sodalive.content.order.OrderType
|
|||||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation
|
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation
|
||||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload
|
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
||||||
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
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
@@ -62,10 +64,28 @@ class DefaultMainContentAllQueryRepositoryTest @Autowired constructor(
|
|||||||
saveBlock(viewer, blockedCreator)
|
saveBlock(viewer, blockedCreator)
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
val visible = repository.findAudios(viewer.id, canViewAdultContent = false, now, ContentSort.LATEST, 0, 20)
|
val visible = repository.findAudios(viewer.id, canViewAdultContent = false, now, ContentSort.LATEST, 0, 20, locale = "ko")
|
||||||
val paidAudios = repository.findAudios(viewer.id, false, now, ContentSort.LATEST, 0, 20, onlyPaid = true)
|
val paidAudios = repository.findAudios(
|
||||||
val freeAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, onlyFree = true)
|
viewer.id,
|
||||||
val pointAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, onlyPointAvailable = true)
|
false,
|
||||||
|
now,
|
||||||
|
ContentSort.LATEST,
|
||||||
|
0,
|
||||||
|
20,
|
||||||
|
locale = "ko",
|
||||||
|
onlyPaid = true
|
||||||
|
)
|
||||||
|
val freeAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, locale = "ko", onlyFree = true)
|
||||||
|
val pointAudios = repository.findAudios(
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
now,
|
||||||
|
ContentSort.LATEST,
|
||||||
|
0,
|
||||||
|
20,
|
||||||
|
locale = "ko",
|
||||||
|
onlyPointAvailable = true
|
||||||
|
)
|
||||||
|
|
||||||
assertEquals(3, repository.countAudios(viewer.id, canViewAdultContent = false, now))
|
assertEquals(3, repository.countAudios(viewer.id, canViewAdultContent = false, now))
|
||||||
assertEquals(1, repository.countAudios(viewer.id, false, now, onlyPaid = true))
|
assertEquals(1, repository.countAudios(viewer.id, false, now, onlyPaid = true))
|
||||||
@@ -95,9 +115,9 @@ class DefaultMainContentAllQueryRepositoryTest @Autowired constructor(
|
|||||||
saveOrder(buyer, creator, middle, can = 1000, isActive = false)
|
saveOrder(buyer, creator, middle, can = 1000, isActive = false)
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
val priceHigh = repository.findAudios(null, false, now, ContentSort.PRICE_HIGH, 0, 20)
|
val priceHigh = repository.findAudios(null, false, now, ContentSort.PRICE_HIGH, 0, 20, locale = "ko")
|
||||||
val priceLow = repository.findAudios(null, false, now, ContentSort.PRICE_LOW, 0, 20)
|
val priceLow = repository.findAudios(null, false, now, ContentSort.PRICE_LOW, 0, 20, locale = "ko")
|
||||||
val popular = repository.findAudios(null, false, now, ContentSort.POPULAR, 0, 20)
|
val popular = repository.findAudios(null, false, now, ContentSort.POPULAR, 0, 20, locale = "ko")
|
||||||
|
|
||||||
assertEquals(listOf(high.id, middle.id, low.id, first.id), priceHigh.map { it.audioContentId })
|
assertEquals(listOf(high.id, middle.id, low.id, first.id), priceHigh.map { it.audioContentId })
|
||||||
assertEquals(listOf(low.id, first.id, middle.id, high.id), priceLow.map { it.audioContentId })
|
assertEquals(listOf(low.id, first.id, middle.id, high.id), priceLow.map { it.audioContentId })
|
||||||
@@ -118,11 +138,35 @@ class DefaultMainContentAllQueryRepositoryTest @Autowired constructor(
|
|||||||
val sameDateLowPrice = saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 100)
|
val sameDateLowPrice = saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 100)
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
val latest = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20)
|
val latest = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, locale = "ko")
|
||||||
|
|
||||||
assertEquals(listOf(sameDateLowPrice.id, sameDateHighPrice.id), latest.map { it.audioContentId })
|
assertEquals(listOf(sameDateLowPrice.id, sameDateHighPrice.id), latest.map { it.audioContentId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("오디오 제목은 locale 번역값을 사용하고 blank 번역은 원문으로 fallback한다")
|
||||||
|
fun shouldFindAudiosWithTranslatedTitleFallback() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 25, 12, 0)
|
||||||
|
val creator = saveMember("audio-translation-creator", MemberRole.CREATOR)
|
||||||
|
val theme = saveTheme("audio-translation-theme")
|
||||||
|
val fallback = saveAudioContent(creator, theme, now.minusDays(2), isAdult = false, price = 100)
|
||||||
|
val translated = saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 100)
|
||||||
|
saveContentTranslation(translated, "en", "Translated Main Audio")
|
||||||
|
saveContentTranslation(translated, "ja", "翻訳メインオーディオ")
|
||||||
|
saveContentTranslation(fallback, "en", " ")
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val enRecords = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, locale = "en")
|
||||||
|
val jaRecords = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, locale = "ja")
|
||||||
|
|
||||||
|
assertEquals(enRecords.map { it.audioContentId }, jaRecords.map { it.audioContentId })
|
||||||
|
assertEquals(listOf(translated.id, fallback.id), enRecords.map { it.audioContentId })
|
||||||
|
assertEquals("Translated Main Audio", enRecords.first().title)
|
||||||
|
assertEquals("翻訳メインオーディオ", jaRecords.first().title)
|
||||||
|
assertEquals(fallback.title, enRecords.last().title)
|
||||||
|
assertEquals(fallback.title, jaRecords.last().title)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("시리즈는 활성 creator, 성인 노출 정책, 차단 관계, 요일과 오리지널 필터를 반영한다")
|
@DisplayName("시리즈는 활성 creator, 성인 노출 정책, 차단 관계, 요일과 오리지널 필터를 반영한다")
|
||||||
fun shouldFindSeriesWithVisibilityDayOfWeekAndOriginalFilters() {
|
fun shouldFindSeriesWithVisibilityDayOfWeekAndOriginalFilters() {
|
||||||
@@ -363,6 +407,23 @@ class DefaultMainContentAllQueryRepositoryTest @Autowired constructor(
|
|||||||
return translation
|
return translation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveContentTranslation(content: AudioContent, locale: String, title: String): ContentTranslation {
|
||||||
|
val translation = ContentTranslation(
|
||||||
|
contentId = content.id!!,
|
||||||
|
locale = locale,
|
||||||
|
renderedPayload = ContentTranslationPayload(title = title, detail = "", tags = "")
|
||||||
|
)
|
||||||
|
entityManager.persist(translation)
|
||||||
|
entityManager.flush()
|
||||||
|
val payload = "{\"title\":\"$title\",\"detail\":\"\",\"tags\":\"\"}"
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"update content_translation set rendered_payload = '$payload' format json where id = :id"
|
||||||
|
)
|
||||||
|
.setParameter("id", translation.id)
|
||||||
|
.executeUpdate()
|
||||||
|
return translation
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveOrder(
|
private fun saveOrder(
|
||||||
member: Member,
|
member: Member,
|
||||||
creator: Member,
|
creator: Member,
|
||||||
|
|||||||
+3
@@ -32,6 +32,7 @@ class MainContentAllQueryServiceTest {
|
|||||||
assertEquals(MainContentAllType.AUDIO, tab.type)
|
assertEquals(MainContentAllType.AUDIO, tab.type)
|
||||||
assertEquals("audio", port.lastListKind)
|
assertEquals("audio", port.lastListKind)
|
||||||
assertEquals(ContentSort.POPULAR, port.lastSort)
|
assertEquals(ContentSort.POPULAR, port.lastSort)
|
||||||
|
assertEquals("en", port.lastLocale)
|
||||||
assertEquals(20L, port.lastOffset)
|
assertEquals(20L, port.lastOffset)
|
||||||
assertEquals(21, port.lastLimit)
|
assertEquals(21, port.lastLimit)
|
||||||
assertTrue(port.lastOnlyPaid)
|
assertTrue(port.lastOnlyPaid)
|
||||||
@@ -186,6 +187,7 @@ private class FakeMainContentAllQueryPort : MainContentAllQueryPort {
|
|||||||
sort: ContentSort,
|
sort: ContentSort,
|
||||||
offset: Long,
|
offset: Long,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
|
locale: String,
|
||||||
onlyPaid: Boolean,
|
onlyPaid: Boolean,
|
||||||
onlyFree: Boolean,
|
onlyFree: Boolean,
|
||||||
onlyPointAvailable: Boolean
|
onlyPointAvailable: Boolean
|
||||||
@@ -196,6 +198,7 @@ private class FakeMainContentAllQueryPort : MainContentAllQueryPort {
|
|||||||
lastSort = sort
|
lastSort = sort
|
||||||
lastOffset = offset
|
lastOffset = offset
|
||||||
lastLimit = limit
|
lastLimit = limit
|
||||||
|
lastLocale = locale
|
||||||
lastOnlyPaid = onlyPaid
|
lastOnlyPaid = onlyPaid
|
||||||
lastOnlyFree = onlyFree
|
lastOnlyFree = onlyFree
|
||||||
lastOnlyPointAvailable = onlyPointAvailable
|
lastOnlyPointAvailable = onlyPointAvailable
|
||||||
|
|||||||
+221
-8
@@ -1,8 +1,12 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence
|
package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||||
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
|
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
|
||||||
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord
|
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord
|
||||||
|
import org.hibernate.SessionFactory
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
@@ -10,18 +14,164 @@ import org.springframework.beans.factory.annotation.Autowired
|
|||||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||||
import org.springframework.context.annotation.Import
|
import org.springframework.context.annotation.Import
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
import javax.persistence.EntityManagerFactory
|
||||||
|
|
||||||
@DataJpaTest(
|
@DataJpaTest(
|
||||||
properties = [
|
properties = [
|
||||||
"spring.cache.type=none",
|
"spring.cache.type=none",
|
||||||
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
|
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE",
|
||||||
|
"spring.jpa.properties.hibernate.generate_statistics=true"
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@Import(QueryDslConfig::class)
|
@Import(QueryDslConfig::class)
|
||||||
class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor(
|
class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor(
|
||||||
private val repository: AudioRankingSnapshotRepository
|
private val repository: AudioRankingSnapshotRepository,
|
||||||
|
private val contentTranslationRepository: ContentTranslationRepository,
|
||||||
|
private val entityManager: EntityManager,
|
||||||
|
private val entityManagerFactory: EntityManagerFactory
|
||||||
) {
|
) {
|
||||||
private val adapter = DefaultAudioRankingSnapshotPersistenceAdapter(repository)
|
private val adapter = DefaultAudioRankingSnapshotPersistenceAdapter(repository, contentTranslationRepository)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("SQL NULL 번역 payload는 snapshot 제목으로 fallback한다")
|
||||||
|
fun shouldFallbackToSnapshotTitleWhenPersistedTranslationPayloadIsSqlNull() {
|
||||||
|
val snapshot = repository.save(snapshot(contentId = 1L, title = "snapshot-title", scoreBase = 10.0))
|
||||||
|
val translationId = saveContentTranslation(snapshot.contentId, "en", "en-title")
|
||||||
|
entityManager.createNativeQuery("update content_translation set rendered_payload = null where id = :id")
|
||||||
|
.setParameter("id", translationId)
|
||||||
|
.executeUpdate()
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val records = adapter.findLatestVisibleSnapshots(
|
||||||
|
AudioRankingType.WEEKLY_POPULAR,
|
||||||
|
LocalDateTime.of(2026, 6, 8, 23, 59),
|
||||||
|
"en"
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("snapshot-title"), records.map { it.title })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("빈 최신과 이전 스냅샷은 번역 조회 없이 빈 목록을 반환한다")
|
||||||
|
fun shouldSkipTranslationLookupForEmptyLatestAndPreviousSnapshots() {
|
||||||
|
val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics
|
||||||
|
statistics.clear()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
emptyList<AudioRankingSnapshotRecord>(),
|
||||||
|
adapter.findLatestVisibleSnapshots(
|
||||||
|
AudioRankingType.WEEKLY_POPULAR,
|
||||||
|
LocalDateTime.of(2026, 6, 8, 23, 59),
|
||||||
|
"en"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertEquals(1L, statistics.prepareStatementCount)
|
||||||
|
assertEquals(
|
||||||
|
emptyList<AudioRankingSnapshotRecord>(),
|
||||||
|
adapter.findPreviousVisibleSnapshots(
|
||||||
|
AudioRankingType.WEEKLY_POPULAR,
|
||||||
|
LocalDateTime.of(2026, 5, 31, 15, 0),
|
||||||
|
LocalDateTime.of(2026, 6, 8, 23, 59),
|
||||||
|
"en"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertEquals(2L, statistics.prepareStatementCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("랭킹 스냅샷은 요청 locale 번역을 일괄 조회하고 snapshot 제목으로 fallback한다")
|
||||||
|
fun shouldTranslateSnapshotTitlesInBatchesWithSnapshotFallback() {
|
||||||
|
val latestStartAt = LocalDateTime.of(2026, 5, 31, 15, 0)
|
||||||
|
val latestEndAt = LocalDateTime.of(2026, 6, 7, 15, 0)
|
||||||
|
val previousStartAt = latestStartAt.minusWeeks(1)
|
||||||
|
val previousEndAt = latestEndAt.minusWeeks(1)
|
||||||
|
val contentIds = (1L..5L).toList()
|
||||||
|
val latestSnapshots = contentIds.mapIndexed { index, contentId ->
|
||||||
|
snapshot(
|
||||||
|
contentId = contentId,
|
||||||
|
aggregationStartAtUtc = latestStartAt,
|
||||||
|
aggregationEndAtUtc = latestEndAt,
|
||||||
|
rank = index + 1,
|
||||||
|
scoreBase = 10.0 + index
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val previousSnapshots = contentIds.mapIndexed { index, contentId ->
|
||||||
|
snapshot(
|
||||||
|
contentId = contentId,
|
||||||
|
aggregationStartAtUtc = previousStartAt,
|
||||||
|
aggregationEndAtUtc = previousEndAt,
|
||||||
|
visibleFromAtUtc = previousEndAt.plusHours(9),
|
||||||
|
rank = index + 1,
|
||||||
|
title = "previous-audio-$contentId",
|
||||||
|
scoreBase = 20.0 + index
|
||||||
|
)
|
||||||
|
}
|
||||||
|
repository.saveAll(latestSnapshots + previousSnapshots)
|
||||||
|
saveContentTranslation(1L, "en", "en-title-1")
|
||||||
|
saveContentTranslation(1L, "ja", "ja-title-1")
|
||||||
|
saveContentTranslation(2L, "en", "")
|
||||||
|
saveContentTranslation(2L, "ja", "ja-title-2")
|
||||||
|
saveContentTranslation(3L, "en", " ")
|
||||||
|
saveContentTranslation(3L, "ja", "ja-title-3")
|
||||||
|
saveContentTranslation(4L, "ja", "ja-title-4")
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics
|
||||||
|
statistics.clear()
|
||||||
|
|
||||||
|
val nowUtc = latestEndAt.plusDays(1).plusHours(8)
|
||||||
|
val english = adapter.findLatestVisibleSnapshots(AudioRankingType.WEEKLY_POPULAR, nowUtc, "en")
|
||||||
|
val japanese = adapter.findLatestVisibleSnapshots(AudioRankingType.WEEKLY_POPULAR, nowUtc, "ja")
|
||||||
|
val englishAgain = adapter.findLatestVisibleSnapshots(AudioRankingType.WEEKLY_POPULAR, nowUtc, "en")
|
||||||
|
val previousEnglish = adapter.findPreviousVisibleSnapshots(
|
||||||
|
AudioRankingType.WEEKLY_POPULAR,
|
||||||
|
latestStartAt,
|
||||||
|
nowUtc,
|
||||||
|
"en"
|
||||||
|
)
|
||||||
|
val previousJapanese = adapter.findPreviousVisibleSnapshots(
|
||||||
|
AudioRankingType.WEEKLY_POPULAR,
|
||||||
|
latestStartAt,
|
||||||
|
nowUtc,
|
||||||
|
"ja"
|
||||||
|
)
|
||||||
|
val previousEnglishAgain = adapter.findPreviousVisibleSnapshots(
|
||||||
|
AudioRankingType.WEEKLY_POPULAR,
|
||||||
|
latestStartAt,
|
||||||
|
nowUtc,
|
||||||
|
"en"
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("en-title-1", "audio-2", "audio-3", "audio-4", "audio-5"), english.map { it.title })
|
||||||
|
assertEquals(listOf("ja-title-1", "ja-title-2", "ja-title-3", "ja-title-4", "audio-5"), japanese.map { it.title })
|
||||||
|
assertEquals(english.map { it.withoutTitle() }, japanese.map { it.withoutTitle() })
|
||||||
|
assertEquals(english.map { it.withoutTitle() }, englishAgain.map { it.withoutTitle() })
|
||||||
|
assertEquals(
|
||||||
|
listOf("en-title-1", "previous-audio-2", "previous-audio-3", "previous-audio-4", "previous-audio-5"),
|
||||||
|
previousEnglish.map { it.title }
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf("ja-title-1", "ja-title-2", "ja-title-3", "ja-title-4", "previous-audio-5"),
|
||||||
|
previousJapanese.map { it.title }
|
||||||
|
)
|
||||||
|
assertEquals(previousEnglish.map { it.withoutTitle() }, previousJapanese.map { it.withoutTitle() })
|
||||||
|
assertEquals(previousEnglish.map { it.withoutTitle() }, previousEnglishAgain.map { it.withoutTitle() })
|
||||||
|
assertEquals(12L, statistics.prepareStatementCount)
|
||||||
|
|
||||||
|
val persistedSnapshots = repository.findAll().sortedBy { it.aggregationStartAtUtc }.groupBy { it.aggregationStartAtUtc }
|
||||||
|
assertEquals(latestSnapshots.map { it.title }, persistedSnapshots.getValue(latestStartAt).map { it.title })
|
||||||
|
assertEquals(
|
||||||
|
latestSnapshots.map { it.scoreValues() },
|
||||||
|
persistedSnapshots.getValue(latestStartAt).map { it.scoreValues() }
|
||||||
|
)
|
||||||
|
assertEquals(previousSnapshots.map { it.title }, persistedSnapshots.getValue(previousStartAt).map { it.title })
|
||||||
|
assertEquals(
|
||||||
|
previousSnapshots.map { it.scoreValues() },
|
||||||
|
persistedSnapshots.getValue(previousStartAt).map { it.scoreValues() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("최신 visible 스냅샷만 랭킹 타입별 rank 순서로 조회한다")
|
@DisplayName("최신 visible 스냅샷만 랭킹 타입별 rank 순서로 조회한다")
|
||||||
@@ -59,7 +209,8 @@ class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor(
|
|||||||
|
|
||||||
val snapshots = adapter.findLatestVisibleSnapshots(
|
val snapshots = adapter.findLatestVisibleSnapshots(
|
||||||
rankingType = AudioRankingType.WEEKLY_POPULAR,
|
rankingType = AudioRankingType.WEEKLY_POPULAR,
|
||||||
nowUtc = LocalDateTime.of(2026, 6, 8, 23, 59)
|
nowUtc = LocalDateTime.of(2026, 6, 8, 23, 59),
|
||||||
|
locale = "ko"
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(listOf(3L, 2L), snapshots.map { it.contentId })
|
assertEquals(listOf(3L, 2L), snapshots.map { it.contentId })
|
||||||
@@ -92,7 +243,8 @@ class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor(
|
|||||||
|
|
||||||
val snapshots = adapter.findLatestVisibleSnapshots(
|
val snapshots = adapter.findLatestVisibleSnapshots(
|
||||||
rankingType = AudioRankingType.WEEKLY_POPULAR,
|
rankingType = AudioRankingType.WEEKLY_POPULAR,
|
||||||
nowUtc = LocalDateTime.of(2026, 6, 7, 23, 59)
|
nowUtc = LocalDateTime.of(2026, 6, 7, 23, 59),
|
||||||
|
locale = "ko"
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(listOf(1L), snapshots.map { it.contentId })
|
assertEquals(listOf(1L), snapshots.map { it.contentId })
|
||||||
@@ -153,7 +305,9 @@ class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor(
|
|||||||
aggregationEndAtUtc: LocalDateTime = LocalDateTime.of(2026, 6, 7, 15, 0),
|
aggregationEndAtUtc: LocalDateTime = LocalDateTime.of(2026, 6, 7, 15, 0),
|
||||||
visibleFromAtUtc: LocalDateTime = LocalDateTime.of(2026, 6, 8, 0, 0),
|
visibleFromAtUtc: LocalDateTime = LocalDateTime.of(2026, 6, 8, 0, 0),
|
||||||
rank: Int = 1,
|
rank: Int = 1,
|
||||||
isAdult: Boolean = false
|
isAdult: Boolean = false,
|
||||||
|
title: String = "audio-$contentId",
|
||||||
|
scoreBase: Double = 100.0
|
||||||
): AudioRankingSnapshot {
|
): AudioRankingSnapshot {
|
||||||
return AudioRankingSnapshot(
|
return AudioRankingSnapshot(
|
||||||
rankingType = rankingType,
|
rankingType = rankingType,
|
||||||
@@ -161,14 +315,31 @@ class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor(
|
|||||||
aggregationEndAtUtc = aggregationEndAtUtc,
|
aggregationEndAtUtc = aggregationEndAtUtc,
|
||||||
visibleFromAtUtc = visibleFromAtUtc,
|
visibleFromAtUtc = visibleFromAtUtc,
|
||||||
contentId = contentId,
|
contentId = contentId,
|
||||||
title = "audio-$contentId",
|
title = title,
|
||||||
creatorMemberId = 100L + contentId,
|
creatorMemberId = 100L + contentId,
|
||||||
creatorNickname = "creator-$contentId",
|
creatorNickname = "creator-$contentId",
|
||||||
coverImageUrl = "cover-$contentId.png",
|
coverImageUrl = "cover-$contentId.png",
|
||||||
releaseDate = LocalDateTime.of(2026, 6, 1, 0, 0),
|
releaseDate = LocalDateTime.of(2026, 6, 1, 0, 0),
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
rank = rank,
|
rank = rank,
|
||||||
finalScore = 100.0
|
finalScore = scoreBase,
|
||||||
|
normalizedScore = scoreBase + 0.1,
|
||||||
|
rawScore = scoreBase + 0.2,
|
||||||
|
revenueCanAmount = scoreBase.toLong() * 10,
|
||||||
|
salesCount = scoreBase.toLong() * 20,
|
||||||
|
viewCount = scoreBase.toLong() * 30,
|
||||||
|
likeCount = scoreBase.toLong() * 40,
|
||||||
|
commentCount = scoreBase.toLong() * 50,
|
||||||
|
previousSalesCount = scoreBase.toLong() * 2,
|
||||||
|
previousViewCount = scoreBase.toLong() * 3,
|
||||||
|
previousLikeCount = scoreBase.toLong() * 4,
|
||||||
|
previousCommentCount = scoreBase.toLong() * 5,
|
||||||
|
salesGrowthRate = scoreBase + 0.3,
|
||||||
|
viewGrowthRate = scoreBase + 0.4,
|
||||||
|
likeGrowthRate = scoreBase + 0.5,
|
||||||
|
commentGrowthRate = scoreBase + 0.6,
|
||||||
|
contentGrowthScore = scoreBase + 0.7,
|
||||||
|
boostMultiplier = scoreBase + 0.8
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,4 +366,46 @@ class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor(
|
|||||||
finalScore = 100.0
|
finalScore = 100.0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveContentTranslation(contentId: Long, locale: String, title: String): Long {
|
||||||
|
val translation = ContentTranslation(
|
||||||
|
contentId = contentId,
|
||||||
|
locale = locale,
|
||||||
|
renderedPayload = ContentTranslationPayload(title = title, detail = "", tags = "")
|
||||||
|
)
|
||||||
|
entityManager.persist(translation)
|
||||||
|
entityManager.flush()
|
||||||
|
val payload = "{\"title\":\"$title\",\"detail\":\"\",\"tags\":\"\"}"
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"update content_translation set rendered_payload = '$payload' format json where id = :id"
|
||||||
|
)
|
||||||
|
.setParameter("id", translation.id)
|
||||||
|
.executeUpdate()
|
||||||
|
return translation.id!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AudioRankingSnapshotRecord.withoutTitle(): AudioRankingSnapshotRecord = copy(title = "")
|
||||||
|
|
||||||
|
private fun AudioRankingSnapshot.scoreValues(): List<Number?> {
|
||||||
|
return listOf(
|
||||||
|
finalScore,
|
||||||
|
normalizedScore,
|
||||||
|
rawScore,
|
||||||
|
revenueCanAmount,
|
||||||
|
salesCount,
|
||||||
|
viewCount,
|
||||||
|
likeCount,
|
||||||
|
commentCount,
|
||||||
|
previousSalesCount,
|
||||||
|
previousViewCount,
|
||||||
|
previousLikeCount,
|
||||||
|
previousCommentCount,
|
||||||
|
salesGrowthRate,
|
||||||
|
viewGrowthRate,
|
||||||
|
likeGrowthRate,
|
||||||
|
commentGrowthRate,
|
||||||
|
contentGrowthScore,
|
||||||
|
boostMultiplier
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-4
@@ -1,5 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.content.ranking.application
|
package kr.co.vividnext.sodalive.v2.content.ranking.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
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.ranking.domain.AudioRankingType
|
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
|
||||||
@@ -9,6 +11,7 @@ import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshot
|
|||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.junit.jupiter.api.extension.ExtendWith
|
import org.junit.jupiter.api.extension.ExtendWith
|
||||||
import org.mockito.Mockito
|
import org.mockito.Mockito
|
||||||
@@ -21,6 +24,41 @@ import java.time.ZonedDateTime
|
|||||||
|
|
||||||
@ExtendWith(OutputCaptureExtension::class)
|
@ExtendWith(OutputCaptureExtension::class)
|
||||||
class AudioRankingQueryServiceTest {
|
class AudioRankingQueryServiceTest {
|
||||||
|
@Test
|
||||||
|
@DisplayName("랭킹은 요청 locale의 제목만 바꾸고 순위 응답은 유지한다")
|
||||||
|
fun shouldUseRequestedLocaleTitlesWithoutChangingRankingValues() {
|
||||||
|
val snapshotPort = FakeAudioRankingQuerySnapshotPort()
|
||||||
|
val originalLatestSnapshots = listOf(snapshot(contentId = 2L, rank = 1), snapshot(contentId = 1L, rank = 2))
|
||||||
|
snapshotPort.latestSnapshots = originalLatestSnapshots
|
||||||
|
snapshotPort.previousSnapshots = listOf(snapshot(contentId = 1L, rank = 1), snapshot(contentId = 2L, rank = 2))
|
||||||
|
snapshotPort.latestSnapshotsByLocale = mapOf(
|
||||||
|
"en" to originalLatestSnapshots.map { it.copy(title = "en-title-${it.contentId}") },
|
||||||
|
"ja" to originalLatestSnapshots.map { it.copy(title = "ja-title-${it.contentId}") }
|
||||||
|
)
|
||||||
|
val langContext = LangContext().apply { setLang(Lang.EN) }
|
||||||
|
val service = service(snapshotPort, langContext = langContext)
|
||||||
|
|
||||||
|
val english = service.getRankings(AudioRankingType.REVENUE, member = null)
|
||||||
|
langContext.setLang(Lang.JA)
|
||||||
|
val japanese = service.getRankings(AudioRankingType.REVENUE, member = null)
|
||||||
|
langContext.setLang(Lang.EN)
|
||||||
|
val englishAgain = service.getRankings(AudioRankingType.REVENUE, member = null)
|
||||||
|
|
||||||
|
assertEquals(listOf("en-title-2", "en-title-1"), english.items.map { it.title })
|
||||||
|
assertEquals(listOf("ja-title-2", "ja-title-1"), japanese.items.map { it.title })
|
||||||
|
assertEquals(listOf("en-title-2", "en-title-1"), englishAgain.items.map { it.title })
|
||||||
|
assertEquals(
|
||||||
|
english.copy(items = english.items.map { it.copy(title = "") }),
|
||||||
|
japanese.copy(items = japanese.items.map { it.copy(title = "") })
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
english.copy(items = english.items.map { it.copy(title = "") }),
|
||||||
|
englishAgain.copy(items = englishAgain.items.map { it.copy(title = "") })
|
||||||
|
)
|
||||||
|
assertEquals(listOf("en", "ja", "en"), snapshotPort.latestLocales)
|
||||||
|
assertEquals(listOf("en", "ja", "en"), snapshotPort.previousLocales)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun shouldNotWrapGetRankingsInTransactionSoFallbackRequeryUsesFreshSnapshot() {
|
fun shouldNotWrapGetRankingsInTransactionSoFallbackRequeryUsesFreshSnapshot() {
|
||||||
val method = AudioRankingQueryService::class.java.getDeclaredMethod(
|
val method = AudioRankingQueryService::class.java.getDeclaredMethod(
|
||||||
@@ -223,7 +261,8 @@ class AudioRankingQueryServiceTest {
|
|||||||
snapshotPort: FakeAudioRankingQuerySnapshotPort,
|
snapshotPort: FakeAudioRankingQuerySnapshotPort,
|
||||||
adultMember: Member? = null,
|
adultMember: Member? = null,
|
||||||
blockPort: AudioRankingBlockPort = FakeAudioRankingBlockPort(),
|
blockPort: AudioRankingBlockPort = FakeAudioRankingBlockPort(),
|
||||||
jobService: AudioRankingSnapshotJobService = Mockito.mock(AudioRankingSnapshotJobService::class.java)
|
jobService: AudioRankingSnapshotJobService = Mockito.mock(AudioRankingSnapshotJobService::class.java),
|
||||||
|
langContext: LangContext = LangContext()
|
||||||
): AudioRankingQueryService {
|
): AudioRankingQueryService {
|
||||||
val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||||
if (adultMember != null) {
|
if (adultMember != null) {
|
||||||
@@ -231,6 +270,7 @@ class AudioRankingQueryServiceTest {
|
|||||||
}
|
}
|
||||||
return AudioRankingQueryService(
|
return AudioRankingQueryService(
|
||||||
snapshotPort = snapshotPort,
|
snapshotPort = snapshotPort,
|
||||||
|
langContext = langContext,
|
||||||
memberContentPreferenceService = memberContentPreferenceService,
|
memberContentPreferenceService = memberContentPreferenceService,
|
||||||
blockPort = blockPort,
|
blockPort = blockPort,
|
||||||
jobService = jobService,
|
jobService = jobService,
|
||||||
@@ -275,25 +315,32 @@ private class FakeAudioRankingQuerySnapshotPort : AudioRankingSnapshotPort {
|
|||||||
var nowUtc: LocalDateTime? = null
|
var nowUtc: LocalDateTime? = null
|
||||||
var currentAggregationStartAtUtc: LocalDateTime? = null
|
var currentAggregationStartAtUtc: LocalDateTime? = null
|
||||||
var latestCallCount: Int = 0
|
var latestCallCount: Int = 0
|
||||||
|
var latestLocales: MutableList<String> = mutableListOf()
|
||||||
|
var previousLocales: MutableList<String> = mutableListOf()
|
||||||
|
var latestSnapshotsByLocale: Map<String, List<AudioRankingSnapshotRecord>> = emptyMap()
|
||||||
|
|
||||||
override fun findLatestVisibleSnapshots(
|
override fun findLatestVisibleSnapshots(
|
||||||
rankingType: AudioRankingType,
|
rankingType: AudioRankingType,
|
||||||
nowUtc: LocalDateTime
|
nowUtc: LocalDateTime,
|
||||||
|
locale: String
|
||||||
): List<AudioRankingSnapshotRecord> {
|
): List<AudioRankingSnapshotRecord> {
|
||||||
this.nowUtc = nowUtc
|
this.nowUtc = nowUtc
|
||||||
|
latestLocales.add(locale)
|
||||||
latestCallCount += 1
|
latestCallCount += 1
|
||||||
if (latestSnapshotsByCall.isNotEmpty()) {
|
if (latestSnapshotsByCall.isNotEmpty()) {
|
||||||
return latestSnapshotsByCall.getOrElse(latestCallCount - 1) { latestSnapshotsByCall.last() }
|
return latestSnapshotsByCall.getOrElse(latestCallCount - 1) { latestSnapshotsByCall.last() }
|
||||||
}
|
}
|
||||||
return latestSnapshots
|
return latestSnapshotsByLocale[locale] ?: latestSnapshots
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findPreviousVisibleSnapshots(
|
override fun findPreviousVisibleSnapshots(
|
||||||
rankingType: AudioRankingType,
|
rankingType: AudioRankingType,
|
||||||
currentAggregationStartAtUtc: LocalDateTime,
|
currentAggregationStartAtUtc: LocalDateTime,
|
||||||
nowUtc: LocalDateTime
|
nowUtc: LocalDateTime,
|
||||||
|
locale: String
|
||||||
): List<AudioRankingSnapshotRecord> {
|
): List<AudioRankingSnapshotRecord> {
|
||||||
this.currentAggregationStartAtUtc = currentAggregationStartAtUtc
|
this.currentAggregationStartAtUtc = currentAggregationStartAtUtc
|
||||||
|
previousLocales.add(locale)
|
||||||
return previousSnapshots
|
return previousSnapshots
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -241,13 +241,15 @@ private class FakeAudioRankingSnapshotPort : AudioRankingSnapshotPort {
|
|||||||
|
|
||||||
override fun findLatestVisibleSnapshots(
|
override fun findLatestVisibleSnapshots(
|
||||||
rankingType: AudioRankingType,
|
rankingType: AudioRankingType,
|
||||||
nowUtc: LocalDateTime
|
nowUtc: LocalDateTime,
|
||||||
|
locale: String
|
||||||
): List<AudioRankingSnapshotRecord> = snapshots
|
): List<AudioRankingSnapshotRecord> = snapshots
|
||||||
|
|
||||||
override fun findPreviousVisibleSnapshots(
|
override fun findPreviousVisibleSnapshots(
|
||||||
rankingType: AudioRankingType,
|
rankingType: AudioRankingType,
|
||||||
currentAggregationStartAtUtc: LocalDateTime,
|
currentAggregationStartAtUtc: LocalDateTime,
|
||||||
nowUtc: LocalDateTime
|
nowUtc: LocalDateTime,
|
||||||
|
locale: String
|
||||||
): List<AudioRankingSnapshotRecord> = snapshots
|
): List<AudioRankingSnapshotRecord> = snapshots
|
||||||
|
|
||||||
override fun replaceSnapshots(
|
override fun replaceSnapshots(
|
||||||
|
|||||||
+149
-8
@@ -10,6 +10,9 @@ 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.main.tab.AudioContentMainTab
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||||
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
|
||||||
import kr.co.vividnext.sodalive.i18n.Lang
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
@@ -38,9 +41,15 @@ import javax.persistence.EntityManager
|
|||||||
@Import(QueryDslConfig::class)
|
@Import(QueryDslConfig::class)
|
||||||
class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
||||||
private val entityManager: EntityManager,
|
private val entityManager: EntityManager,
|
||||||
queryFactory: JPAQueryFactory
|
queryFactory: JPAQueryFactory,
|
||||||
|
contentTranslationRepository: ContentTranslationRepository
|
||||||
) {
|
) {
|
||||||
private val repository = DefaultAudioRecommendationQueryRepository(queryFactory, entityManager, "https://cdn.test")
|
private val repository = DefaultAudioRecommendationQueryRepository(
|
||||||
|
queryFactory,
|
||||||
|
entityManager,
|
||||||
|
contentTranslationRepository,
|
||||||
|
"https://cdn.test"
|
||||||
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("배너는 콘텐츠 추천 탭과 성인 조회 가능 여부를 적용한다")
|
@DisplayName("배너는 콘텐츠 추천 탭과 성인 조회 가능 여부를 적용한다")
|
||||||
@@ -210,9 +219,27 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
}
|
}
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
val latestAudios = repository.findLatestAudios(12, viewer.id, canViewAdultContent = false, now = now)
|
val latestAudios = repository.findLatestAudios(
|
||||||
val freeAudios = repository.findFreeAudios(10, viewer.id, canViewAdultContent = false, now = now)
|
12,
|
||||||
val pointAudios = repository.findPointAudios(10, viewer.id, canViewAdultContent = false, now = now)
|
viewer.id,
|
||||||
|
canViewAdultContent = false,
|
||||||
|
now = now,
|
||||||
|
locale = Lang.KO.code
|
||||||
|
)
|
||||||
|
val freeAudios = repository.findFreeAudios(
|
||||||
|
10,
|
||||||
|
viewer.id,
|
||||||
|
canViewAdultContent = false,
|
||||||
|
now = now,
|
||||||
|
locale = Lang.KO.code
|
||||||
|
)
|
||||||
|
val pointAudios = repository.findPointAudios(
|
||||||
|
10,
|
||||||
|
viewer.id,
|
||||||
|
canViewAdultContent = false,
|
||||||
|
now = now,
|
||||||
|
locale = Lang.KO.code
|
||||||
|
)
|
||||||
|
|
||||||
assertEquals(12, latestAudios.size)
|
assertEquals(12, latestAudios.size)
|
||||||
assertEquals(listOf(latest.id, paidPoint.id), latestAudios.take(2).map { it.audioContentId })
|
assertEquals(listOf(latest.id, paidPoint.id), latestAudios.take(2).map { it.audioContentId })
|
||||||
@@ -297,7 +324,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
val commented = repository.findCommentedAudiosByIds(
|
val commented = repository.findCommentedAudiosByIds(
|
||||||
contentIds = listOf(first.id!!, second.id!!),
|
contentIds = listOf(first.id!!, second.id!!),
|
||||||
memberId = viewer.id,
|
memberId = viewer.id,
|
||||||
canViewAdultContent = false
|
canViewAdultContent = false,
|
||||||
|
locale = Lang.KO.code
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(listOf(first.id), snapshots.map { it.targetId })
|
assertEquals(listOf(first.id), snapshots.map { it.targetId })
|
||||||
@@ -308,6 +336,101 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
assertEquals("https://cdn.test/commented.png", commented.first().imageUrl)
|
assertEquals("https://cdn.test/commented.png", commented.first().imageUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("오디오 카드 공통 QueryDSL 경로는 빈 문자열, 공백, 누락, 다른 언어 번역을 원문으로 대체한다")
|
||||||
|
fun shouldTranslateAudioCardsByRequestedLocaleWithFallbackAndInputOrder() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 23, 12, 0)
|
||||||
|
val creator = saveMember("translated-card-creator", MemberRole.CREATOR)
|
||||||
|
val theme = saveTheme()
|
||||||
|
val translated = saveAudio(creator, theme, "translated-source", now.minusDays(1))
|
||||||
|
val empty = saveAudio(creator, theme, "empty-source", now.minusDays(2))
|
||||||
|
val whitespace = saveAudio(creator, theme, "whitespace-source", now.minusDays(3))
|
||||||
|
val otherLocaleOnly = saveAudio(creator, theme, "other-locale-source", now.minusDays(4))
|
||||||
|
val missing = saveAudio(creator, theme, "missing-source", now.minusDays(5))
|
||||||
|
saveContentTranslation(translated, "en", "translated-en")
|
||||||
|
saveContentTranslation(translated, "ja", "translated-ja")
|
||||||
|
saveContentTranslation(empty, "en", "")
|
||||||
|
saveContentTranslation(whitespace, "en", " ")
|
||||||
|
saveContentTranslation(otherLocaleOnly, "ja", "other-locale-ja")
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val contentIds = listOf(missing.id!!, empty.id!!, whitespace.id!!, otherLocaleOnly.id!!, translated.id!!)
|
||||||
|
val englishCards = repository.findAudioCardsByIds(contentIds, null, false, now, Lang.EN.code)
|
||||||
|
val japaneseCards = repository.findAudioCardsByIds(contentIds, null, false, now, Lang.JA.code)
|
||||||
|
|
||||||
|
assertEquals(contentIds, englishCards.map { it.audioContentId })
|
||||||
|
assertEquals(
|
||||||
|
listOf("missing-source", "empty-source", "whitespace-source", "other-locale-source", "translated-en"),
|
||||||
|
englishCards.map { it.title }
|
||||||
|
)
|
||||||
|
assertEquals(contentIds, japaneseCards.map { it.audioContentId })
|
||||||
|
assertEquals(
|
||||||
|
listOf("missing-source", "empty-source", "whitespace-source", "other-locale-ja", "translated-ja"),
|
||||||
|
japaneseCards.map { it.title }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최근 댓글 많은 오디오 native query는 locale별 제목만 바꾸고 fallback과 댓글 필드를 보존한다")
|
||||||
|
fun shouldTranslateCommentedAudiosByRequestedLocaleWithFallbackAndPreservedFields() {
|
||||||
|
val now = LocalDateTime.now().minusHours(1)
|
||||||
|
val creator = saveMember("translated-commented-creator", MemberRole.CREATOR)
|
||||||
|
val writer = saveMember("translated-commented-writer", MemberRole.USER)
|
||||||
|
val theme = saveTheme()
|
||||||
|
val translated = saveAudio(creator, theme, "commented-translated-source", now.minusMinutes(1))
|
||||||
|
val empty = saveAudio(creator, theme, "commented-empty-source", now.minusMinutes(2))
|
||||||
|
val whitespace = saveAudio(creator, theme, "commented-whitespace-source", now.minusMinutes(3))
|
||||||
|
val otherLocaleOnly = saveAudio(creator, theme, "commented-other-locale-source", now.minusMinutes(4))
|
||||||
|
val missing = saveAudio(creator, theme, "commented-missing-source", now.minusMinutes(5))
|
||||||
|
saveComment(translated, writer, "translated-comment", now)
|
||||||
|
saveComment(empty, writer, "empty-comment", now)
|
||||||
|
saveComment(whitespace, writer, "whitespace-comment", now)
|
||||||
|
saveComment(otherLocaleOnly, writer, "other-locale-comment", now)
|
||||||
|
saveComment(missing, writer, "missing-comment", now)
|
||||||
|
saveContentTranslation(translated, "en", "commented-translated-en")
|
||||||
|
saveContentTranslation(translated, "ja", "commented-translated-ja")
|
||||||
|
saveContentTranslation(empty, "en", "")
|
||||||
|
saveContentTranslation(whitespace, "en", " ")
|
||||||
|
saveContentTranslation(otherLocaleOnly, "ja", "commented-other-locale-ja")
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val contentIds = listOf(missing.id!!, empty.id!!, whitespace.id!!, otherLocaleOnly.id!!, translated.id!!)
|
||||||
|
val englishAudios = repository.findCommentedAudiosByIds(contentIds, null, false, Lang.EN.code)
|
||||||
|
val japaneseAudios = repository.findCommentedAudiosByIds(contentIds, null, false, Lang.JA.code)
|
||||||
|
|
||||||
|
assertEquals(contentIds, englishAudios.map { it.audioContentId })
|
||||||
|
assertEquals(contentIds, japaneseAudios.map { it.audioContentId })
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
"commented-missing-source",
|
||||||
|
"commented-empty-source",
|
||||||
|
"commented-whitespace-source",
|
||||||
|
"commented-other-locale-source",
|
||||||
|
"commented-translated-en"
|
||||||
|
),
|
||||||
|
englishAudios.map { it.title }
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
"commented-missing-source",
|
||||||
|
"commented-empty-source",
|
||||||
|
"commented-whitespace-source",
|
||||||
|
"commented-other-locale-ja",
|
||||||
|
"commented-translated-ja"
|
||||||
|
),
|
||||||
|
japaneseAudios.map { it.title }
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf("missing-comment", "empty-comment", "whitespace-comment", "other-locale-comment", "translated-comment"),
|
||||||
|
englishAudios.map { it.latestComment }
|
||||||
|
)
|
||||||
|
assertEquals(englishAudios.map { it.latestComment }, japaneseAudios.map { it.latestComment })
|
||||||
|
assertEquals(
|
||||||
|
englishAudios.map { it.latestCommentWriterProfileImageUrl },
|
||||||
|
japaneseAudios.map { it.latestCommentWriterProfileImageUrl }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("최근 댓글 많은 오디오 스냅샷은 같은 크리에이터 후보 중 최고 점수 1개만 반환한다")
|
@DisplayName("최근 댓글 많은 오디오 스냅샷은 같은 크리에이터 후보 중 최고 점수 1개만 반환한다")
|
||||||
fun shouldKeepOnlyHighestScoredMostCommentedSnapshotPerCreator() {
|
fun shouldKeepOnlyHighestScoredMostCommentedSnapshotPerCreator() {
|
||||||
@@ -356,7 +479,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
val commented = repository.findCommentedAudiosByIds(
|
val commented = repository.findCommentedAudiosByIds(
|
||||||
contentIds = listOf(audio.id!!),
|
contentIds = listOf(audio.id!!),
|
||||||
memberId = viewer.id,
|
memberId = viewer.id,
|
||||||
canViewAdultContent = false
|
canViewAdultContent = false,
|
||||||
|
locale = Lang.KO.code
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(1, commented.size)
|
assertEquals(1, commented.size)
|
||||||
@@ -381,7 +505,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
val commented = repository.findCommentedAudiosByIds(
|
val commented = repository.findCommentedAudiosByIds(
|
||||||
contentIds = listOf(audio.id!!),
|
contentIds = listOf(audio.id!!),
|
||||||
memberId = viewer.id,
|
memberId = viewer.id,
|
||||||
canViewAdultContent = false
|
canViewAdultContent = false,
|
||||||
|
locale = Lang.KO.code
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(1, commented.size)
|
assertEquals(1, commented.size)
|
||||||
@@ -507,6 +632,22 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
return audio
|
return audio
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveContentTranslation(content: AudioContent, locale: String, title: String) {
|
||||||
|
val translation = ContentTranslation(
|
||||||
|
contentId = content.id!!,
|
||||||
|
locale = locale,
|
||||||
|
renderedPayload = ContentTranslationPayload(title = title, detail = "", tags = "")
|
||||||
|
)
|
||||||
|
entityManager.persist(translation)
|
||||||
|
entityManager.flush()
|
||||||
|
val payload = "{\"title\":\"$title\",\"detail\":\"\",\"tags\":\"\"}"
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"update content_translation set rendered_payload = '$payload' format json where id = :id"
|
||||||
|
)
|
||||||
|
.setParameter("id", translation.id)
|
||||||
|
.executeUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveView(audio: AudioContent, viewedAt: LocalDateTime) {
|
private fun saveView(audio: AudioContent, viewedAt: LocalDateTime) {
|
||||||
entityManager.persist(
|
entityManager.persist(
|
||||||
CreatorContentViewHistory(memberId = 1L, contentId = audio.id!!, genreId = 1L, viewedAt = viewedAt)
|
CreatorContentViewHistory(memberId = 1L, contentId = audio.id!!, genreId = 1L, viewedAt = viewedAt)
|
||||||
|
|||||||
+14
-6
@@ -1,6 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.content.recommendation.application
|
package kr.co.vividnext.sodalive.v2.content.recommendation.application
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.i18n.Lang
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
@@ -19,10 +20,12 @@ class AudioRecommendationQueryServiceTest {
|
|||||||
private val queryPort = Mockito.mock(AudioRecommendationQueryPort::class.java)
|
private val queryPort = Mockito.mock(AudioRecommendationQueryPort::class.java)
|
||||||
private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||||
private val fallbackService = Mockito.mock(AudioRecommendationSnapshotFallbackService::class.java)
|
private val fallbackService = Mockito.mock(AudioRecommendationSnapshotFallbackService::class.java)
|
||||||
|
private val langContext = LangContext().apply { setLang(Lang.EN) }
|
||||||
private val service = AudioRecommendationQueryService(
|
private val service = AudioRecommendationQueryService(
|
||||||
queryPort,
|
queryPort,
|
||||||
preferenceService,
|
preferenceService,
|
||||||
fallbackService
|
fallbackService,
|
||||||
|
langContext
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -83,25 +86,29 @@ class AudioRecommendationQueryServiceTest {
|
|||||||
eqValue(AudioRecommendationQueryService.LATEST_AUDIO_LIMIT),
|
eqValue(AudioRecommendationQueryService.LATEST_AUDIO_LIMIT),
|
||||||
Mockito.isNull(),
|
Mockito.isNull(),
|
||||||
eqValue(false),
|
eqValue(false),
|
||||||
anyLocalDateTime()
|
anyLocalDateTime(),
|
||||||
|
eqValue(Lang.EN.code)
|
||||||
)
|
)
|
||||||
Mockito.verify(queryPort).findFreeAudios(
|
Mockito.verify(queryPort).findFreeAudios(
|
||||||
eqValue(AudioRecommendationQueryService.FREE_AUDIO_LIMIT),
|
eqValue(AudioRecommendationQueryService.FREE_AUDIO_LIMIT),
|
||||||
Mockito.isNull(),
|
Mockito.isNull(),
|
||||||
eqValue(false),
|
eqValue(false),
|
||||||
anyLocalDateTime()
|
anyLocalDateTime(),
|
||||||
|
eqValue(Lang.EN.code)
|
||||||
)
|
)
|
||||||
Mockito.verify(queryPort).findPointAudios(
|
Mockito.verify(queryPort).findPointAudios(
|
||||||
eqValue(AudioRecommendationQueryService.POINT_AUDIO_LIMIT),
|
eqValue(AudioRecommendationQueryService.POINT_AUDIO_LIMIT),
|
||||||
Mockito.isNull(),
|
Mockito.isNull(),
|
||||||
eqValue(false),
|
eqValue(false),
|
||||||
anyLocalDateTime()
|
anyLocalDateTime(),
|
||||||
|
eqValue(Lang.EN.code)
|
||||||
)
|
)
|
||||||
Mockito.verify(queryPort).findAudioCardsByIds(
|
Mockito.verify(queryPort).findAudioCardsByIds(
|
||||||
eqValue(listOf(1L)),
|
eqValue(listOf(1L)),
|
||||||
Mockito.isNull(),
|
Mockito.isNull(),
|
||||||
eqValue(false),
|
eqValue(false),
|
||||||
anyLocalDateTime()
|
anyLocalDateTime(),
|
||||||
|
eqValue(Lang.EN.code)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +205,8 @@ class AudioRecommendationQueryServiceTest {
|
|||||||
eqValue(listOf(3L, 4L, 5L)),
|
eqValue(listOf(3L, 4L, 5L)),
|
||||||
eqValue(member.id),
|
eqValue(member.id),
|
||||||
eqValue(true),
|
eqValue(true),
|
||||||
anyLocalDateTime()
|
anyLocalDateTime(),
|
||||||
|
eqValue(Lang.EN.code)
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = service.findNewAndHotAudios(member, offset = 20L, limit = 21)
|
val result = service.findNewAndHotAudios(member, offset = 20L, limit = 21)
|
||||||
|
|||||||
+64
@@ -10,6 +10,8 @@ import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation
|
|||||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload
|
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslation
|
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslation
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
||||||
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
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
@@ -204,6 +206,51 @@ class DefaultCreatorChannelAudioQueryRepositoryTest @Autowired constructor(
|
|||||||
assertFalse(firstPage.any { it.audioContentId == oldSelected.id && it.isFirstContent })
|
assertFalse(firstPage.any { it.audioContentId == oldSelected.id && it.isFirstContent })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("목록 오디오 제목은 요청 locale 번역을 사용하고 공백 번역은 원문으로 fallback한다")
|
||||||
|
fun shouldFindAudioContentsWithTranslatedTitleFallback() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 19, 12, 0)
|
||||||
|
val viewer = saveMember("translated-title-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("translated-title-creator", MemberRole.CREATOR)
|
||||||
|
val theme = saveTheme("수면")
|
||||||
|
val fallback = saveAudioContent(creator, now.minusDays(2), false, theme)
|
||||||
|
val translated = saveAudioContent(creator, now.minusDays(1), false, theme)
|
||||||
|
saveContentTranslation(translated, "en", "Translated Audio")
|
||||||
|
saveContentTranslation(translated, "ja", "翻訳オーディオ")
|
||||||
|
saveContentTranslation(fallback, "en", " ")
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val enRecords = repository.findAudioContents(
|
||||||
|
creator.id!!,
|
||||||
|
viewer.id!!,
|
||||||
|
null,
|
||||||
|
now,
|
||||||
|
false,
|
||||||
|
ContentSort.LATEST,
|
||||||
|
"en",
|
||||||
|
offset = 0,
|
||||||
|
limit = 10
|
||||||
|
)
|
||||||
|
val jaRecords = repository.findAudioContents(
|
||||||
|
creator.id!!,
|
||||||
|
viewer.id!!,
|
||||||
|
null,
|
||||||
|
now,
|
||||||
|
false,
|
||||||
|
ContentSort.LATEST,
|
||||||
|
"ja",
|
||||||
|
offset = 0,
|
||||||
|
limit = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(enRecords.map { it.audioContentId }, jaRecords.map { it.audioContentId })
|
||||||
|
assertEquals(listOf(translated.id, fallback.id), enRecords.map { it.audioContentId })
|
||||||
|
assertEquals("Translated Audio", enRecords.first().title)
|
||||||
|
assertEquals("翻訳オーディオ", jaRecords.first().title)
|
||||||
|
assertEquals(fallback.title, enRecords.last().title)
|
||||||
|
assertEquals(fallback.title, jaRecords.last().title)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("목록은 가격순과 인기순 can 매출 정렬을 적용한다")
|
@DisplayName("목록은 가격순과 인기순 can 매출 정렬을 적용한다")
|
||||||
fun shouldSortAudioContentsByPriceAndPopularRevenue() {
|
fun shouldSortAudioContentsByPriceAndPopularRevenue() {
|
||||||
@@ -391,6 +438,23 @@ class DefaultCreatorChannelAudioQueryRepositoryTest @Autowired constructor(
|
|||||||
return translation
|
return translation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveContentTranslation(content: AudioContent, locale: String, title: String): ContentTranslation {
|
||||||
|
val translation = ContentTranslation(
|
||||||
|
contentId = content.id!!,
|
||||||
|
locale = locale,
|
||||||
|
renderedPayload = ContentTranslationPayload(title = title, detail = "", tags = "")
|
||||||
|
)
|
||||||
|
entityManager.persist(translation)
|
||||||
|
entityManager.flush()
|
||||||
|
val payload = "{\"title\":\"$title\",\"detail\":\"\",\"tags\":\"\"}"
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"update content_translation set rendered_payload = '$payload' format json where id = :id"
|
||||||
|
)
|
||||||
|
.setParameter("id", translation.id)
|
||||||
|
.executeUpdate()
|
||||||
|
return translation
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveOrder(
|
private fun saveOrder(
|
||||||
member: Member,
|
member: Member,
|
||||||
creator: Member,
|
creator: Member,
|
||||||
|
|||||||
+116
@@ -9,7 +9,11 @@ import kr.co.vividnext.sodalive.content.ContentType
|
|||||||
import kr.co.vividnext.sodalive.content.order.Order
|
import kr.co.vividnext.sodalive.content.order.Order
|
||||||
import kr.co.vividnext.sodalive.content.order.OrderType
|
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||||
import kr.co.vividnext.sodalive.content.pin.PinContent
|
import kr.co.vividnext.sodalive.content.pin.PinContent
|
||||||
|
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation
|
||||||
|
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
||||||
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
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
|
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
|
||||||
@@ -514,6 +518,84 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
|
|||||||
assertTrue(records.last().isPointAvailable)
|
assertTrue(records.last().isPointAvailable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("홈 오디오 제목과 시리즈명은 요청 locale 번역을 사용하고 공백 번역은 원문으로 fallback한다")
|
||||||
|
fun shouldFindHomeAudioContentsWithTranslatedTitleFallback() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 12, 12, 0)
|
||||||
|
val creator = saveMember("home-audio-i18n-creator", MemberRole.CREATOR)
|
||||||
|
val first = saveAudioContent(creator, now.minusDays(3), isAdult = false)
|
||||||
|
val listed = saveAudioContent(creator, now.minusDays(2), isAdult = false)
|
||||||
|
val latest = saveAudioContent(creator, now.minusDays(1), isAdult = false)
|
||||||
|
val series = saveSeries("home-origin-series", creator, isOriginal = true)
|
||||||
|
saveSeriesContent(series, listed)
|
||||||
|
saveContentTranslation(latest, "en", "Latest Audio")
|
||||||
|
saveContentTranslation(latest, "ja", "最新オーディオ")
|
||||||
|
saveContentTranslation(listed, "en", "Listed Audio")
|
||||||
|
saveContentTranslation(first, "en", " ")
|
||||||
|
saveSeriesTranslation(series, "en", "Home Series")
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val latestRecord = repository.findLatestAudioContent(creator.id!!, now, false, locale = "en")
|
||||||
|
val records = repository.findAudioContents(
|
||||||
|
creator.id!!,
|
||||||
|
now,
|
||||||
|
latestRecord!!.audioContentId,
|
||||||
|
false,
|
||||||
|
locale = "en",
|
||||||
|
limit = 9
|
||||||
|
)
|
||||||
|
val jaLatestRecord = repository.findLatestAudioContent(creator.id!!, now, false, locale = "ja")
|
||||||
|
|
||||||
|
assertEquals("Latest Audio", latestRecord.title)
|
||||||
|
assertEquals("最新オーディオ", jaLatestRecord!!.title)
|
||||||
|
assertEquals(listOf(listed.id, first.id), records.map { it.audioContentId })
|
||||||
|
assertEquals("Listed Audio", records.first().title)
|
||||||
|
assertEquals("Home Series", records.first().seriesName)
|
||||||
|
assertEquals(first.title, records.last().title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("홈 AUDIO 스케줄과 시리즈 제목은 요청 locale 번역을 사용하고 라이브 스케줄 제목은 유지한다")
|
||||||
|
fun shouldFindHomeSchedulesAndSeriesWithTranslatedTitleFallback() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 12, 12, 0)
|
||||||
|
val creator = saveMember("home-schedule-i18n-creator", MemberRole.CREATOR)
|
||||||
|
val liveSchedule = saveLiveRoom(creator, now.plusHours(1), channelName = null, isAdult = false)
|
||||||
|
val audioSchedule = saveAudioContent(creator, now.plusHours(2), isAdult = false)
|
||||||
|
val blankAudioSchedule = saveAudioContent(creator, now.plusHours(3), isAdult = false)
|
||||||
|
val series = saveSeries("home-origin-series-title", creator, isOriginal = true)
|
||||||
|
val blankSeries = saveSeries("home-blank-series-title", creator, isOriginal = true)
|
||||||
|
saveSeriesContent(series, saveAudioContent(creator, now.minusDays(1), isAdult = false))
|
||||||
|
saveSeriesContent(blankSeries, saveAudioContent(creator, now.minusDays(2), isAdult = false))
|
||||||
|
saveContentTranslation(audioSchedule, "en", "Scheduled Audio")
|
||||||
|
saveContentTranslation(audioSchedule, "ja", "予約オーディオ")
|
||||||
|
saveContentTranslation(blankAudioSchedule, "en", " ")
|
||||||
|
saveSeriesTranslation(series, "en", "Translated Home Series")
|
||||||
|
saveSeriesTranslation(series, "ja", "翻訳ホームシリーズ")
|
||||||
|
saveSeriesTranslation(blankSeries, "en", " ")
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val schedules = repository.findSchedules(
|
||||||
|
creator.id!!,
|
||||||
|
now,
|
||||||
|
canViewAdultContent = false,
|
||||||
|
viewerId = null,
|
||||||
|
isViewerCreator = false,
|
||||||
|
effectiveViewerGender = null,
|
||||||
|
locale = "en",
|
||||||
|
limit = 10
|
||||||
|
)
|
||||||
|
val seriesRecords = repository.findSeries(creator.id!!, null, now, false, ContentType.ALL, locale = "en", limit = 8)
|
||||||
|
val jaSeriesRecords = repository.findSeries(creator.id!!, null, now, false, ContentType.ALL, locale = "ja", limit = 8)
|
||||||
|
|
||||||
|
assertEquals(listOf(liveSchedule.id, audioSchedule.id, blankAudioSchedule.id), schedules.map { it.targetId })
|
||||||
|
assertEquals(liveSchedule.title, schedules.first().title)
|
||||||
|
assertEquals("Scheduled Audio", schedules[1].title)
|
||||||
|
assertEquals(blankAudioSchedule.title, schedules.last().title)
|
||||||
|
assertEquals("Translated Home Series", seriesRecords.first { it.seriesId == series.id }.title)
|
||||||
|
assertEquals(blankSeries.title, seriesRecords.first { it.seriesId == blankSeries.id }.title)
|
||||||
|
assertEquals("翻訳ホームシリーズ", jaSeriesRecords.first { it.seriesId == series.id }.title)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("홈 오디오 목록은 최신 오디오를 제외하고 활성 고정 콘텐츠를 최근 고정순으로 먼저 조회한다")
|
@DisplayName("홈 오디오 목록은 최신 오디오를 제외하고 활성 고정 콘텐츠를 최근 고정순으로 먼저 조회한다")
|
||||||
fun shouldSortHomeAudioContentsByActivePinBeforeReleaseDate() {
|
fun shouldSortHomeAudioContentsByActivePinBeforeReleaseDate() {
|
||||||
@@ -1215,6 +1297,40 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
|
|||||||
return seriesContent
|
return seriesContent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveSeriesTranslation(series: Series, locale: String, title: String): SeriesTranslation {
|
||||||
|
val translation = SeriesTranslation(
|
||||||
|
seriesId = series.id!!,
|
||||||
|
locale = locale,
|
||||||
|
renderedPayload = SeriesTranslationPayload(title = title, introduction = "", keywords = emptyList())
|
||||||
|
)
|
||||||
|
entityManager.persist(translation)
|
||||||
|
entityManager.flush()
|
||||||
|
val payload = "{\"title\":\"$title\",\"introduction\":\"\",\"keywords\":[]}"
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"update series_translation set rendered_payload = '$payload' format json where id = :id"
|
||||||
|
)
|
||||||
|
.setParameter("id", translation.id)
|
||||||
|
.executeUpdate()
|
||||||
|
return translation
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveContentTranslation(content: AudioContent, locale: String, title: String): ContentTranslation {
|
||||||
|
val translation = ContentTranslation(
|
||||||
|
contentId = content.id!!,
|
||||||
|
locale = locale,
|
||||||
|
renderedPayload = ContentTranslationPayload(title = title, detail = "", tags = "")
|
||||||
|
)
|
||||||
|
entityManager.persist(translation)
|
||||||
|
entityManager.flush()
|
||||||
|
val payload = "{\"title\":\"$title\",\"detail\":\"\",\"tags\":\"\"}"
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"update content_translation set rendered_payload = '$payload' format json where id = :id"
|
||||||
|
)
|
||||||
|
.setParameter("id", translation.id)
|
||||||
|
.executeUpdate()
|
||||||
|
return translation
|
||||||
|
}
|
||||||
|
|
||||||
private fun savePinContent(
|
private fun savePinContent(
|
||||||
creator: Member,
|
creator: Member,
|
||||||
content: AudioContent,
|
content: AudioContent,
|
||||||
|
|||||||
+49
-13
@@ -101,6 +101,25 @@ class CreatorChannelHomeQueryServiceTest {
|
|||||||
assertEquals(false, port.currentLiveCanViewAdultContent)
|
assertEquals(false, port.currentLiveCanViewAdultContent)
|
||||||
assertEquals(false, port.schedulesCanViewAdultContent)
|
assertEquals(false, port.schedulesCanViewAdultContent)
|
||||||
assertEquals(201L, port.audioContentsLatestAudioContentId)
|
assertEquals(201L, port.audioContentsLatestAudioContentId)
|
||||||
|
assertEquals("ko", port.latestAudioLocale)
|
||||||
|
assertEquals("ko", port.audioContentsLocale)
|
||||||
|
assertEquals("ko", port.schedulesLocale)
|
||||||
|
assertEquals("ko", port.seriesLocale)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("홈 서비스는 요청 locale을 오디오, 스케줄, 시리즈 조회에 전달한다")
|
||||||
|
fun shouldPassLocaleToHomeQueries() {
|
||||||
|
val port = FakeCreatorChannelHomeQueryPort()
|
||||||
|
val service = createService(port, lang = Lang.JA)
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
|
||||||
|
service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
|
||||||
|
|
||||||
|
assertEquals("ja", port.latestAudioLocale)
|
||||||
|
assertEquals("ja", port.audioContentsLocale)
|
||||||
|
assertEquals("ja", port.schedulesLocale)
|
||||||
|
assertEquals("ja", port.seriesLocale)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -468,11 +487,12 @@ class CreatorChannelHomeQueryServiceTest {
|
|||||||
port: FakeCreatorChannelHomeQueryPort,
|
port: FakeCreatorChannelHomeQueryPort,
|
||||||
communityPort: FakeCreatorChannelCommunityQueryPort = FakeCreatorChannelCommunityQueryPort(),
|
communityPort: FakeCreatorChannelCommunityQueryPort = FakeCreatorChannelCommunityQueryPort(),
|
||||||
canViewAdultContent: Boolean = true,
|
canViewAdultContent: Boolean = true,
|
||||||
preferenceService: MemberContentPreferenceService = createPreferenceService(canViewAdultContent)
|
preferenceService: MemberContentPreferenceService = createPreferenceService(canViewAdultContent),
|
||||||
|
lang: Lang = Lang.KO
|
||||||
): CreatorChannelHomeQueryService {
|
): CreatorChannelHomeQueryService {
|
||||||
val messageSource = SodaMessageSource()
|
val messageSource = SodaMessageSource()
|
||||||
val langContext = LangContext()
|
val langContext = LangContext()
|
||||||
langContext.setLang(Lang.KO)
|
langContext.setLang(lang)
|
||||||
val audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java)
|
val audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java)
|
||||||
Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
|
Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
|
||||||
.thenAnswer { invocation -> "https://signed.test/${invocation.getArgument<String>(0)}" }
|
.thenAnswer { invocation -> "https://signed.test/${invocation.getArgument<String>(0)}" }
|
||||||
@@ -565,6 +585,10 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
|
|||||||
var schedulesEffectiveViewerGender: Gender? = null
|
var schedulesEffectiveViewerGender: Gender? = null
|
||||||
var schedulesCanViewAdultContent: Boolean? = null
|
var schedulesCanViewAdultContent: Boolean? = null
|
||||||
var audioContentsLatestAudioContentId: Long? = null
|
var audioContentsLatestAudioContentId: Long? = null
|
||||||
|
var latestAudioLocale: String? = null
|
||||||
|
var schedulesLocale: String? = null
|
||||||
|
var audioContentsLocale: String? = null
|
||||||
|
var seriesLocale: String? = null
|
||||||
var channelDonationNickname: String = "fan"
|
var channelDonationNickname: String = "fan"
|
||||||
|
|
||||||
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? = creator
|
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? = creator
|
||||||
@@ -597,8 +621,12 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
|
|||||||
creatorId: Long,
|
creatorId: Long,
|
||||||
now: LocalDateTime,
|
now: LocalDateTime,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
viewerId: Long?
|
viewerId: Long?,
|
||||||
): CreatorChannelAudioContentRecord? = audioContentRecord(201L, "audio/latest.png")
|
locale: String
|
||||||
|
): CreatorChannelAudioContentRecord? {
|
||||||
|
latestAudioLocale = locale
|
||||||
|
return audioContentRecord(201L, "audio/latest.png")
|
||||||
|
}
|
||||||
|
|
||||||
override fun findChannelDonations(
|
override fun findChannelDonations(
|
||||||
creatorId: Long,
|
creatorId: Long,
|
||||||
@@ -622,12 +650,14 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
|
|||||||
viewerId: Long?,
|
viewerId: Long?,
|
||||||
isViewerCreator: Boolean,
|
isViewerCreator: Boolean,
|
||||||
effectiveViewerGender: Gender?,
|
effectiveViewerGender: Gender?,
|
||||||
|
locale: String,
|
||||||
limit: Int
|
limit: Int
|
||||||
): List<CreatorChannelScheduleRecord> {
|
): List<CreatorChannelScheduleRecord> {
|
||||||
schedulesViewerId = viewerId
|
schedulesViewerId = viewerId
|
||||||
schedulesIsViewerCreator = isViewerCreator
|
schedulesIsViewerCreator = isViewerCreator
|
||||||
schedulesEffectiveViewerGender = effectiveViewerGender
|
schedulesEffectiveViewerGender = effectiveViewerGender
|
||||||
schedulesCanViewAdultContent = canViewAdultContent
|
schedulesCanViewAdultContent = canViewAdultContent
|
||||||
|
schedulesLocale = locale
|
||||||
return listOf(
|
return listOf(
|
||||||
scheduleRecord(401L, LocalDateTime.of(2026, 6, 13, 12, 0), CreatorActivityType.AUDIO, false),
|
scheduleRecord(401L, LocalDateTime.of(2026, 6, 13, 12, 0), CreatorActivityType.AUDIO, false),
|
||||||
scheduleRecord(402L, LocalDateTime.of(2026, 6, 13, 12, 0), CreatorActivityType.LIVE, false),
|
scheduleRecord(402L, LocalDateTime.of(2026, 6, 13, 12, 0), CreatorActivityType.LIVE, false),
|
||||||
@@ -644,9 +674,11 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
|
|||||||
latestAudioContentId: Long?,
|
latestAudioContentId: Long?,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
viewerId: Long?,
|
viewerId: Long?,
|
||||||
|
locale: String,
|
||||||
limit: Int
|
limit: Int
|
||||||
): List<CreatorChannelAudioContentRecord> {
|
): List<CreatorChannelAudioContentRecord> {
|
||||||
audioContentsLatestAudioContentId = latestAudioContentId
|
audioContentsLatestAudioContentId = latestAudioContentId
|
||||||
|
audioContentsLocale = locale
|
||||||
return listOf(
|
return listOf(
|
||||||
audioContentRecord(201L, "audio/latest.png"),
|
audioContentRecord(201L, "audio/latest.png"),
|
||||||
audioContentRecord(203L, "audio/203.png"),
|
audioContentRecord(203L, "audio/203.png"),
|
||||||
@@ -660,17 +692,21 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
|
|||||||
now: LocalDateTime,
|
now: LocalDateTime,
|
||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
|
locale: String,
|
||||||
limit: Int
|
limit: Int
|
||||||
): List<CreatorChannelSeriesRecord> = listOf(
|
): List<CreatorChannelSeriesRecord> {
|
||||||
CreatorChannelSeriesRecord(
|
seriesLocale = locale
|
||||||
seriesId = 501L,
|
return listOf(
|
||||||
title = "series",
|
CreatorChannelSeriesRecord(
|
||||||
coverImagePath = "series.png",
|
seriesId = 501L,
|
||||||
numberOfContent = 2,
|
title = "series",
|
||||||
isNew = true,
|
coverImagePath = "series.png",
|
||||||
isOriginal = false
|
numberOfContent = 2,
|
||||||
|
isNew = true,
|
||||||
|
isOriginal = false
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
|
|
||||||
override fun findFanTalkSummary(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkSummaryRecord {
|
override fun findFanTalkSummary(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkSummaryRecord {
|
||||||
return CreatorChannelFanTalkSummaryRecord(
|
return CreatorChannelFanTalkSummaryRecord(
|
||||||
|
|||||||
+132
-13
@@ -21,6 +21,9 @@ 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.main.tab.AudioContentMainTab
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||||
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
|
||||||
import kr.co.vividnext.sodalive.event.Event
|
import kr.co.vividnext.sodalive.event.Event
|
||||||
@@ -63,9 +66,10 @@ import javax.persistence.Query
|
|||||||
@Import(QueryDslConfig::class)
|
@Import(QueryDslConfig::class)
|
||||||
class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
||||||
private val entityManager: EntityManager,
|
private val entityManager: EntityManager,
|
||||||
queryFactory: JPAQueryFactory
|
queryFactory: JPAQueryFactory,
|
||||||
|
contentTranslationRepository: ContentTranslationRepository
|
||||||
) {
|
) {
|
||||||
private val repository = DefaultHomeRecommendationQueryRepository(queryFactory, entityManager)
|
private val repository = DefaultHomeRecommendationQueryRepository(queryFactory, entityManager, contentTranslationRepository)
|
||||||
private val scorePolicy = RecommendationScorePolicy()
|
private val scorePolicy = RecommendationScorePolicy()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1565,12 +1569,76 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
).forEach { (content, createdAt) -> updateCreatedAt("AudioContent", content.id!!, createdAt) }
|
).forEach { (content, createdAt) -> updateCreatedAt("AudioContent", content.id!!, createdAt) }
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
val contents = repository.findFirstAudioContents(now, limit = 10)
|
val contents = repository.findFirstAudioContents(now, limit = 10, locale = Lang.KO.code)
|
||||||
|
|
||||||
assertEquals(listOf(eligibleActive.id), contents.map { it.contentId })
|
assertEquals(listOf(eligibleActive.id), contents.map { it.contentId })
|
||||||
assertEquals(12, contents.single().price)
|
assertEquals(12, contents.single().price)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("첫 오디오 native query는 빈 문자열, 공백, 누락, 다른 언어 번역을 원문으로 대체한다")
|
||||||
|
fun shouldTranslateFirstAudioContentsByRequestedLocaleWithFallback() {
|
||||||
|
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||||
|
val translated = saveAudioContent(
|
||||||
|
saveMember("translated-first-audio", MemberRole.CREATOR),
|
||||||
|
now.minusDays(1),
|
||||||
|
isActive = true
|
||||||
|
)
|
||||||
|
val empty = saveAudioContent(
|
||||||
|
saveMember("empty-first-audio", MemberRole.CREATOR),
|
||||||
|
now.minusDays(2),
|
||||||
|
isActive = true
|
||||||
|
)
|
||||||
|
val whitespace = saveAudioContent(
|
||||||
|
saveMember("whitespace-first-audio", MemberRole.CREATOR),
|
||||||
|
now.minusDays(3),
|
||||||
|
isActive = true
|
||||||
|
)
|
||||||
|
val otherLocaleOnly = saveAudioContent(
|
||||||
|
saveMember("other-locale-first-audio", MemberRole.CREATOR),
|
||||||
|
now.minusDays(4),
|
||||||
|
isActive = true
|
||||||
|
)
|
||||||
|
val missing = saveAudioContent(
|
||||||
|
saveMember("missing-first-audio", MemberRole.CREATOR),
|
||||||
|
now.minusDays(5),
|
||||||
|
isActive = true
|
||||||
|
)
|
||||||
|
saveContentTranslation(translated, "en", "first-audio-en")
|
||||||
|
saveContentTranslation(translated, "ja", "first-audio-ja")
|
||||||
|
saveContentTranslation(empty, "en", "")
|
||||||
|
saveContentTranslation(whitespace, "en", " ")
|
||||||
|
saveContentTranslation(otherLocaleOnly, "en", "other-locale-first-audio-en")
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val englishRecords = repository.findFirstAudioContents(now, limit = 10, locale = Lang.EN.code)
|
||||||
|
val japaneseRecords = repository.findFirstAudioContents(now, limit = 10, locale = Lang.JA.code)
|
||||||
|
|
||||||
|
assertEquals(englishRecords.map { it.contentId }, japaneseRecords.map { it.contentId })
|
||||||
|
assertEquals(
|
||||||
|
"first-audio-en",
|
||||||
|
englishRecords.single { it.contentId == translated.id }.title
|
||||||
|
)
|
||||||
|
assertEquals(empty.title, englishRecords.single { it.contentId == empty.id }.title)
|
||||||
|
assertEquals(whitespace.title, englishRecords.single { it.contentId == whitespace.id }.title)
|
||||||
|
assertEquals(
|
||||||
|
"other-locale-first-audio-en",
|
||||||
|
englishRecords.single { it.contentId == otherLocaleOnly.id }.title
|
||||||
|
)
|
||||||
|
assertEquals(missing.title, englishRecords.single { it.contentId == missing.id }.title)
|
||||||
|
assertEquals(
|
||||||
|
"first-audio-ja",
|
||||||
|
japaneseRecords.single { it.contentId == translated.id }.title
|
||||||
|
)
|
||||||
|
assertEquals(empty.title, japaneseRecords.single { it.contentId == empty.id }.title)
|
||||||
|
assertEquals(whitespace.title, japaneseRecords.single { it.contentId == whitespace.id }.title)
|
||||||
|
assertEquals(
|
||||||
|
otherLocaleOnly.title,
|
||||||
|
japaneseRecords.single { it.contentId == otherLocaleOnly.id }.title
|
||||||
|
)
|
||||||
|
assertEquals(missing.title, japaneseRecords.single { it.contentId == missing.id }.title)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("첫 오디오 콘텐츠는 예약/미공개 콘텐츠를 제외하고 releaseDate 최신성 점수순으로 조회한다")
|
@DisplayName("첫 오디오 콘텐츠는 예약/미공개 콘텐츠를 제외하고 releaseDate 최신성 점수순으로 조회한다")
|
||||||
fun shouldFindFirstAudioContentsOrderedByReleaseDateRecencyScoreExcludingScheduledContents() {
|
fun shouldFindFirstAudioContentsOrderedByReleaseDateRecencyScoreExcludingScheduledContents() {
|
||||||
@@ -1585,7 +1653,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
updateCreatedAt("AudioContent", old.id!!, now.minusDays(20))
|
updateCreatedAt("AudioContent", old.id!!, now.minusDays(20))
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
val contents = repository.findFirstAudioContents(now, limit = 10)
|
val contents = repository.findFirstAudioContents(now, limit = 10, locale = Lang.KO.code)
|
||||||
|
|
||||||
assertEquals(listOf(fresh.id, old.id), contents.map { it.contentId })
|
assertEquals(listOf(fresh.id, old.id), contents.map { it.contentId })
|
||||||
}
|
}
|
||||||
@@ -1602,8 +1670,20 @@ 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 = 0L, limit = 2, includeAdultContents = false)
|
val page0 = repository.findFirstAudioContents(
|
||||||
val page1 = repository.findFirstAudioContents(now, offset = 1L, limit = 2, includeAdultContents = false)
|
now,
|
||||||
|
offset = 0L,
|
||||||
|
limit = 2,
|
||||||
|
includeAdultContents = false,
|
||||||
|
locale = Lang.KO.code
|
||||||
|
)
|
||||||
|
val page1 = repository.findFirstAudioContents(
|
||||||
|
now,
|
||||||
|
offset = 1L,
|
||||||
|
limit = 2,
|
||||||
|
includeAdultContents = false,
|
||||||
|
locale = Lang.KO.code
|
||||||
|
)
|
||||||
|
|
||||||
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 })
|
||||||
@@ -1622,7 +1702,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
updateCreatedAt("AudioContent", content.id!!, now.minusDays(1))
|
updateCreatedAt("AudioContent", content.id!!, now.minusDays(1))
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
val contents = repository.findFirstAudioContents(now, limit = 10)
|
val contents = repository.findFirstAudioContents(now, limit = 10, locale = Lang.KO.code)
|
||||||
|
|
||||||
assertEquals(false, contents.single().isAdult)
|
assertEquals(false, contents.single().isAdult)
|
||||||
assertEquals(true, contents.single().isOriginalSeries)
|
assertEquals(true, contents.single().isOriginalSeries)
|
||||||
@@ -1633,12 +1713,16 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
fun shouldMapNumericNativeBooleanFromFirstAudioContentRows() {
|
fun shouldMapNumericNativeBooleanFromFirstAudioContentRows() {
|
||||||
val mockEntityManager = Mockito.mock(EntityManager::class.java)
|
val mockEntityManager = Mockito.mock(EntityManager::class.java)
|
||||||
val mockQuery = Mockito.mock(Query::class.java)
|
val mockQuery = Mockito.mock(Query::class.java)
|
||||||
|
val mockContentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java)
|
||||||
val repository = DefaultHomeRecommendationQueryRepository(
|
val repository = DefaultHomeRecommendationQueryRepository(
|
||||||
JPAQueryFactory(mockEntityManager),
|
JPAQueryFactory(mockEntityManager),
|
||||||
mockEntityManager
|
mockEntityManager,
|
||||||
|
mockContentTranslationRepository
|
||||||
)
|
)
|
||||||
Mockito.`when`(mockEntityManager.createNativeQuery(anyString())).thenReturn(mockQuery)
|
Mockito.`when`(mockEntityManager.createNativeQuery(anyString())).thenReturn(mockQuery)
|
||||||
Mockito.`when`(mockQuery.setParameter(anyString(), any())).thenReturn(mockQuery)
|
Mockito.`when`(mockQuery.setParameter(anyString(), any())).thenReturn(mockQuery)
|
||||||
|
Mockito.`when`(mockContentTranslationRepository.findByContentIdInAndLocale(listOf(1L), Lang.KO.code))
|
||||||
|
.thenReturn(emptyList())
|
||||||
Mockito.`when`(mockQuery.resultList).thenReturn(
|
Mockito.`when`(mockQuery.resultList).thenReturn(
|
||||||
listOf(
|
listOf(
|
||||||
arrayOf(
|
arrayOf(
|
||||||
@@ -1661,7 +1745,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
offset = 0L,
|
offset = 0L,
|
||||||
limit = 1,
|
limit = 1,
|
||||||
memberId = null,
|
memberId = null,
|
||||||
includeAdultContents = false
|
includeAdultContents = false,
|
||||||
|
locale = Lang.KO.code
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(true, contents.single().isOriginalSeries)
|
assertEquals(true, contents.single().isOriginalSeries)
|
||||||
@@ -1682,7 +1767,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
saveBlock(creatorBlockedViewer, viewer)
|
saveBlock(creatorBlockedViewer, viewer)
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
val contents = repository.findFirstAudioContents(now, limit = 10, memberId = viewer.id)
|
val contents = repository.findFirstAudioContents(now, limit = 10, memberId = viewer.id, locale = Lang.KO.code)
|
||||||
|
|
||||||
assertEquals(listOf(visibleContent.id), contents.map { it.contentId })
|
assertEquals(listOf(visibleContent.id), contents.map { it.contentId })
|
||||||
}
|
}
|
||||||
@@ -1699,9 +1784,27 @@ 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 = 0L, limit = 1, includeAdultContents = false)
|
val page0 = repository.findFirstAudioContents(
|
||||||
val page1 = repository.findFirstAudioContents(now, offset = 1L, limit = 1, includeAdultContents = false)
|
now,
|
||||||
val page2 = repository.findFirstAudioContents(now, offset = 2L, limit = 1, includeAdultContents = false)
|
offset = 0L,
|
||||||
|
limit = 1,
|
||||||
|
includeAdultContents = false,
|
||||||
|
locale = Lang.KO.code
|
||||||
|
)
|
||||||
|
val page1 = repository.findFirstAudioContents(
|
||||||
|
now,
|
||||||
|
offset = 1L,
|
||||||
|
limit = 1,
|
||||||
|
includeAdultContents = false,
|
||||||
|
locale = Lang.KO.code
|
||||||
|
)
|
||||||
|
val page2 = repository.findFirstAudioContents(
|
||||||
|
now,
|
||||||
|
offset = 2L,
|
||||||
|
limit = 1,
|
||||||
|
includeAdultContents = false,
|
||||||
|
locale = Lang.KO.code
|
||||||
|
)
|
||||||
|
|
||||||
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())
|
||||||
@@ -2621,6 +2724,22 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveContentTranslation(content: AudioContent, locale: String, title: String) {
|
||||||
|
val translation = ContentTranslation(
|
||||||
|
contentId = content.id!!,
|
||||||
|
locale = locale,
|
||||||
|
renderedPayload = ContentTranslationPayload(title = title, detail = "", tags = "")
|
||||||
|
)
|
||||||
|
entityManager.persist(translation)
|
||||||
|
entityManager.flush()
|
||||||
|
val payload = "{\"title\":\"$title\",\"detail\":\"\",\"tags\":\"\"}"
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"update content_translation set rendered_payload = '$payload' format json where id = :id"
|
||||||
|
)
|
||||||
|
.setParameter("id", translation.id)
|
||||||
|
.executeUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveTheme(name: String, isActive: Boolean = true): AudioContentTheme {
|
private fun saveTheme(name: String, isActive: Boolean = true): AudioContentTheme {
|
||||||
val theme = AudioContentTheme(
|
val theme = AudioContentTheme(
|
||||||
theme = name,
|
theme = name,
|
||||||
|
|||||||
+28
-16
@@ -1,6 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.recommendation.application
|
package kr.co.vividnext.sodalive.v2.recommendation.application
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.i18n.Lang
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
|
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
|
||||||
@@ -27,7 +28,8 @@ import java.time.ZoneOffset
|
|||||||
class HomeRecommendationQueryServiceTest {
|
class HomeRecommendationQueryServiceTest {
|
||||||
private val port = FakeHomeRecommendationQueryPort()
|
private val port = FakeHomeRecommendationQueryPort()
|
||||||
private val snapshotPort = FakeHomeRecommendationSnapshotPort()
|
private val snapshotPort = FakeHomeRecommendationSnapshotPort()
|
||||||
private val service = HomeRecommendationQueryService(port, snapshotPort)
|
private val langContext = LangContext()
|
||||||
|
private val service = HomeRecommendationQueryService(port, snapshotPort, langContext = langContext)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("다시듣기 테마 콘텐츠는 AUDIO가 아니라 LIVE_REPLAY 활동으로 분류한다")
|
@DisplayName("다시듣기 테마 콘텐츠는 AUDIO가 아니라 LIVE_REPLAY 활동으로 분류한다")
|
||||||
@@ -152,6 +154,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
assertEquals(now, port.firstAudioNow)
|
assertEquals(now, port.firstAudioNow)
|
||||||
assertEquals(103L, port.firstAudioMemberId)
|
assertEquals(103L, port.firstAudioMemberId)
|
||||||
assertEquals(10, port.firstAudioLimit)
|
assertEquals(10, port.firstAudioLimit)
|
||||||
|
assertEquals(Lang.KO.code, port.firstAudioLocale)
|
||||||
assertEquals(port.firstAudioContents, contents)
|
assertEquals(port.firstAudioContents, contents)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +162,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
@DisplayName("AI 캐릭터 추천은 최신 스냅샷 20개를 기준으로 순서를 유지해 상세를 조립한다")
|
@DisplayName("AI 캐릭터 추천은 최신 스냅샷 20개를 기준으로 순서를 유지해 상세를 조립한다")
|
||||||
fun shouldFindAiCharactersFromLatestSnapshotsWithLimitAndDetails() {
|
fun shouldFindAiCharactersFromLatestSnapshotsWithLimitAndDetails() {
|
||||||
val fallback = FakeAiCharacterSnapshotFallbackPort(emptyList())
|
val fallback = FakeAiCharacterSnapshotFallbackPort(emptyList())
|
||||||
val service = HomeRecommendationQueryService(port, snapshotPort, fallback)
|
val service = HomeRecommendationQueryService(port, snapshotPort, fallback, langContext = langContext)
|
||||||
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
|
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
|
||||||
val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||||
snapshotPort.replaceSnapshots(
|
snapshotPort.replaceSnapshots(
|
||||||
@@ -261,7 +264,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
val fallback = FakeAiCharacterSnapshotFallbackPort(
|
val fallback = FakeAiCharacterSnapshotFallbackPort(
|
||||||
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 10L, 10.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
|
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 10L, 10.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
|
||||||
)
|
)
|
||||||
val service = HomeRecommendationQueryService(port, snapshotPort, fallback)
|
val service = HomeRecommendationQueryService(port, snapshotPort, fallback, langContext = langContext)
|
||||||
port.aiCharacterDetails = listOf(
|
port.aiCharacterDetails = listOf(
|
||||||
HomeAiCharacterRecommendationRecord(
|
HomeAiCharacterRecommendationRecord(
|
||||||
characterId = 10L,
|
characterId = 10L,
|
||||||
@@ -286,7 +289,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
@DisplayName("AI 캐릭터 추천은 fallback 후에도 스냅샷이 없으면 빈 배열을 반환한다")
|
@DisplayName("AI 캐릭터 추천은 fallback 후에도 스냅샷이 없으면 빈 배열을 반환한다")
|
||||||
fun shouldReturnEmptyAiCharactersWhenFallbackDoesNotCreateSnapshots() {
|
fun shouldReturnEmptyAiCharactersWhenFallbackDoesNotCreateSnapshots() {
|
||||||
val fallback = FakeAiCharacterSnapshotFallbackPort(emptyList())
|
val fallback = FakeAiCharacterSnapshotFallbackPort(emptyList())
|
||||||
val service = HomeRecommendationQueryService(port, snapshotPort, fallback)
|
val service = HomeRecommendationQueryService(port, snapshotPort, fallback, langContext = langContext)
|
||||||
|
|
||||||
val characters = service.findAiCharacterRecommendations()
|
val characters = service.findAiCharacterRecommendations()
|
||||||
|
|
||||||
@@ -302,7 +305,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
val fallback = FakeAiCharacterSnapshotFallbackPort(
|
val fallback = FakeAiCharacterSnapshotFallbackPort(
|
||||||
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
|
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
|
||||||
)
|
)
|
||||||
val service = HomeRecommendationQueryService(port, snapshotPort, fallback)
|
val service = HomeRecommendationQueryService(port, snapshotPort, fallback, langContext = langContext)
|
||||||
snapshotPort.replaceSnapshots(
|
snapshotPort.replaceSnapshots(
|
||||||
RecommendedSectionType.AI_CHARACTER,
|
RecommendedSectionType.AI_CHARACTER,
|
||||||
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
|
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
|
||||||
@@ -325,7 +328,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
val fallback = FakeAiCharacterSnapshotFallbackPort(
|
val fallback = FakeAiCharacterSnapshotFallbackPort(
|
||||||
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
|
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
|
||||||
)
|
)
|
||||||
val service = HomeRecommendationQueryService(port, snapshotPort, fallback)
|
val service = HomeRecommendationQueryService(port, snapshotPort, fallback, langContext = langContext)
|
||||||
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||||
snapshotPort.replaceSnapshots(
|
snapshotPort.replaceSnapshots(
|
||||||
RecommendedSectionType.AI_CHARACTER,
|
RecommendedSectionType.AI_CHARACTER,
|
||||||
@@ -352,7 +355,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
val fallback = FakeAiCharacterSnapshotFallbackPort(
|
val fallback = FakeAiCharacterSnapshotFallbackPort(
|
||||||
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
|
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
|
||||||
)
|
)
|
||||||
val service = HomeRecommendationQueryService(port, snapshotPort, fallback)
|
val service = HomeRecommendationQueryService(port, snapshotPort, fallback, langContext = langContext)
|
||||||
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||||
snapshotPort.replaceSnapshots(
|
snapshotPort.replaceSnapshots(
|
||||||
RecommendedSectionType.AI_CHARACTER,
|
RecommendedSectionType.AI_CHARACTER,
|
||||||
@@ -376,7 +379,8 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
port,
|
port,
|
||||||
snapshotPort,
|
snapshotPort,
|
||||||
cheerCreatorSnapshotFallbackService = fallback,
|
cheerCreatorSnapshotFallbackService = fallback,
|
||||||
clock = fixedClock()
|
clock = fixedClock(),
|
||||||
|
langContext = langContext
|
||||||
)
|
)
|
||||||
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||||
snapshotPort.replaceSnapshots(
|
snapshotPort.replaceSnapshots(
|
||||||
@@ -419,7 +423,8 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
port,
|
port,
|
||||||
snapshotPort,
|
snapshotPort,
|
||||||
cheerCreatorSnapshotFallbackService = fallback,
|
cheerCreatorSnapshotFallbackService = fallback,
|
||||||
clock = fixedClock()
|
clock = fixedClock(),
|
||||||
|
langContext = langContext
|
||||||
)
|
)
|
||||||
port.cheerCreatorDetails = listOf(
|
port.cheerCreatorDetails = listOf(
|
||||||
HomeCheerCreatorRecommendationRecord(
|
HomeCheerCreatorRecommendationRecord(
|
||||||
@@ -448,7 +453,8 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
port,
|
port,
|
||||||
snapshotPort,
|
snapshotPort,
|
||||||
cheerCreatorSnapshotFallbackService = fallback,
|
cheerCreatorSnapshotFallbackService = fallback,
|
||||||
clock = fixedClock()
|
clock = fixedClock(),
|
||||||
|
langContext = langContext
|
||||||
)
|
)
|
||||||
val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59)
|
val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59)
|
||||||
snapshotPort.replaceSnapshots(
|
snapshotPort.replaceSnapshots(
|
||||||
@@ -483,7 +489,8 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
port,
|
port,
|
||||||
snapshotPort,
|
snapshotPort,
|
||||||
cheerCreatorSnapshotFallbackService = fallback,
|
cheerCreatorSnapshotFallbackService = fallback,
|
||||||
clock = fixedClock()
|
clock = fixedClock(),
|
||||||
|
langContext = langContext
|
||||||
)
|
)
|
||||||
snapshotPort.replaceSnapshots(
|
snapshotPort.replaceSnapshots(
|
||||||
RecommendedSectionType.CHEER_CREATOR,
|
RecommendedSectionType.CHEER_CREATOR,
|
||||||
@@ -502,7 +509,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("인기 커뮤니티 추천은 대상일 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다")
|
@DisplayName("인기 커뮤니티 추천은 대상일 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다")
|
||||||
fun shouldFindPopularCommunitiesFromTargetDaySnapshotsWithLimitDetailsAndCreatorUniqueness() {
|
fun shouldFindPopularCommunitiesFromTargetDaySnapshotsWithLimitDetailsAndCreatorUniqueness() {
|
||||||
val service = HomeRecommendationQueryService(port, snapshotPort, clock = fixedClock())
|
val service = HomeRecommendationQueryService(port, snapshotPort, clock = fixedClock(), langContext = langContext)
|
||||||
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||||
snapshotPort.replaceSnapshots(
|
snapshotPort.replaceSnapshots(
|
||||||
RecommendedSectionType.POPULAR_COMMUNITY,
|
RecommendedSectionType.POPULAR_COMMUNITY,
|
||||||
@@ -576,7 +583,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("인기 커뮤니티 추천은 상위 스냅샷 중복 제거 후 뒤 후보로 기본 10개를 채운다")
|
@DisplayName("인기 커뮤니티 추천은 상위 스냅샷 중복 제거 후 뒤 후보로 기본 10개를 채운다")
|
||||||
fun shouldBackfillPopularCommunitiesAfterRemovingDuplicateCreators() {
|
fun shouldBackfillPopularCommunitiesAfterRemovingDuplicateCreators() {
|
||||||
val service = HomeRecommendationQueryService(port, snapshotPort, clock = fixedClock())
|
val service = HomeRecommendationQueryService(port, snapshotPort, clock = fixedClock(), langContext = langContext)
|
||||||
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||||
snapshotPort.replaceSnapshots(
|
snapshotPort.replaceSnapshots(
|
||||||
RecommendedSectionType.POPULAR_COMMUNITY,
|
RecommendedSectionType.POPULAR_COMMUNITY,
|
||||||
@@ -621,7 +628,8 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
port,
|
port,
|
||||||
snapshotPort,
|
snapshotPort,
|
||||||
popularCommunitySnapshotFallbackService = fallback,
|
popularCommunitySnapshotFallbackService = fallback,
|
||||||
clock = fixedClock()
|
clock = fixedClock(),
|
||||||
|
langContext = langContext
|
||||||
)
|
)
|
||||||
val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59)
|
val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59)
|
||||||
snapshotPort.replaceSnapshots(
|
snapshotPort.replaceSnapshots(
|
||||||
@@ -666,7 +674,8 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
port,
|
port,
|
||||||
snapshotPort,
|
snapshotPort,
|
||||||
popularCommunitySnapshotFallbackService = fallback,
|
popularCommunitySnapshotFallbackService = fallback,
|
||||||
clock = fixedClock()
|
clock = fixedClock(),
|
||||||
|
langContext = langContext
|
||||||
)
|
)
|
||||||
snapshotPort.replaceSnapshots(
|
snapshotPort.replaceSnapshots(
|
||||||
RecommendedSectionType.POPULAR_COMMUNITY,
|
RecommendedSectionType.POPULAR_COMMUNITY,
|
||||||
@@ -991,6 +1000,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
var firstAudioOffset: Long? = null
|
var firstAudioOffset: Long? = null
|
||||||
var firstAudioMemberId: Long? = null
|
var firstAudioMemberId: Long? = null
|
||||||
var firstAudioIncludeAdultContents: Boolean? = null
|
var firstAudioIncludeAdultContents: Boolean? = null
|
||||||
|
var firstAudioLocale: String? = null
|
||||||
var aiCharacterDetailIds: List<Long> = emptyList()
|
var aiCharacterDetailIds: List<Long> = emptyList()
|
||||||
val aiCharacterDetailIdRequests = mutableListOf<List<Long>>()
|
val aiCharacterDetailIdRequests = mutableListOf<List<Long>>()
|
||||||
var randomAiCharacterExcludeIds: List<Long>? = null
|
var randomAiCharacterExcludeIds: List<Long>? = null
|
||||||
@@ -1121,13 +1131,15 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
offset: Long,
|
offset: Long,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
includeAdultContents: Boolean
|
includeAdultContents: Boolean,
|
||||||
|
locale: String
|
||||||
): List<HomeFirstAudioContentRecord> {
|
): List<HomeFirstAudioContentRecord> {
|
||||||
firstAudioNow = now
|
firstAudioNow = now
|
||||||
firstAudioOffset = offset
|
firstAudioOffset = offset
|
||||||
firstAudioLimit = limit
|
firstAudioLimit = limit
|
||||||
firstAudioMemberId = memberId
|
firstAudioMemberId = memberId
|
||||||
firstAudioIncludeAdultContents = includeAdultContents
|
firstAudioIncludeAdultContents = includeAdultContents
|
||||||
|
firstAudioLocale = locale
|
||||||
return firstAudioContents
|
return firstAudioContents
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user