Compare commits

..
12 Commits
34 changed files with 1811 additions and 153 deletions
@@ -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 검증이 남아 있다.
@@ -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,
@@ -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
@@ -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
@@ -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,
@@ -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 {
@@ -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(
@@ -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,
@@ -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
) )
} }
@@ -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,
@@ -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,
@@ -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(
CreatorChannelScheduleRecord::class.java,
audioContent.releaseDate, audioContent.releaseDate,
audioContent.title, audioContent.title,
Expressions.constant(CreatorActivityType.AUDIO), contentTranslation.renderedPayload,
audioContent.id, audioContent.id,
audioContent.isAdult 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)!!
) )
} }
@@ -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,
@@ -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>
@@ -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(
@@ -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(
@@ -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(
@@ -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
@@ -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)
@@ -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,
@@ -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(
@@ -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,
@@ -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
@@ -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
)
}
} }
@@ -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
} }
@@ -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(
@@ -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)
@@ -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)
@@ -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,
@@ -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,
@@ -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,8 +692,11 @@ 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> {
seriesLocale = locale
return listOf(
CreatorChannelSeriesRecord( CreatorChannelSeriesRecord(
seriesId = 501L, seriesId = 501L,
title = "series", title = "series",
@@ -671,6 +706,7 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
isOriginal = false isOriginal = false
) )
) )
}
override fun findFanTalkSummary(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkSummaryRecord { override fun findFanTalkSummary(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkSummaryRecord {
return CreatorChannelFanTalkSummaryRecord( return CreatorChannelFanTalkSummaryRecord(
@@ -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,
@@ -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
} }