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