Merge pull request 'test' (#449) from test into main
Reviewed-on: #449
This commit was merged in pull request #449.
This commit is contained in:
@@ -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 검증이 남아 있다.
|
||||
@@ -0,0 +1,965 @@
|
||||
# 크리에이터 커뮤니티 게시물 본문 번역 구현 계획
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 기존 로컬 Gate 기록 유지 · `P1-R4` 구현·자동 회귀·수정 후 리뷰 완료 · 테스트 서버 수동 검증 대기 |
|
||||
| 작성일 | 2026-09-10 |
|
||||
| 요구사항 / API 기준 | [PRD](prd.md), 특히 §3 요구사항과 §5 API 계약 |
|
||||
| 기준 템플릿 | `docs/sample/sample-plan-task.md` |
|
||||
| 현재 Phase / 활성 Goal | `P1-R4` 및 P1/P2 영향 회귀 완료 / 활성 구현 Goal 없음 |
|
||||
| 다음 Goal | 테스트 브랜치 배포 후 실제 MySQL/Papago/HTTP 수동 검증 |
|
||||
|
||||
## 목표·범위·제약
|
||||
|
||||
본문 원문을 보존하면서 한국어·영어·일본어 번역을 저장하고, 상세·목록·미리보기 전체에 일관되게 제공한다.
|
||||
신규/본문 수정은 전체 목표 언어를 처리하고, 기존 게시물의 누락 번역은 상세에서 요청 언어만 예약한다.
|
||||
|
||||
기존 Kotlin, Java 17, Spring Boot 2.7.14, Gradle Wrapper, JUnit 5, QueryDSL/JPA, Papago를 사용한다.
|
||||
새 큐·스케줄러·외부 의존성·공개 API 필드를 추가하지 않는다. 원문과 번역을 분리하며 구매/차단/성인 정책을 유지한다.
|
||||
새 재사용 기능은 `v2/creator/channel/community/translation` 아래에 두고 기존 공통 번역 연결부만 확장한다.
|
||||
현재 PRD의 제외 범위는 모든 Goal에 적용한다. 신규 파일 경로는 아래 표에서 `생성`으로 구분한다.
|
||||
|
||||
로컬 검증은 Gradle 자동 테스트와 ktlint으로 한정한다. DDL·HTTP·실제 MySQL 동시성은 로컬에서 실행하지 않고,
|
||||
테스트 브랜치 배포 후 이 문서의 수동 체크리스트에서 확인한다.
|
||||
|
||||
## 현재 상태와 실행 순서
|
||||
|
||||
| Phase | 결과 | 상태 | 완료 Task | 다음 Goal |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 현재 본문에 대응하는 번역 저장·감지 기반 | `P1-R4` 포함 로컬 자동 검증 완료 | 6/6 | 테스트 서버 수동 검증 |
|
||||
| 2 | 작성·수정·상세의 비동기 연결과 폴백 | 완료 | 2/2 | 테스트 서버 수동 검증 |
|
||||
| 3 | 모든 목록·홈 적용과 전체 사용자 흐름 검증 | 로컬 Gate 완료 | 2/2 | 테스트 서버 수동 검증 |
|
||||
|
||||
실행 순서: `P1-T1 → P1-T2 → P1-GATE → P1-R1 → P1-GATE 재검증 → P1-R2 → P1-GATE 재검증 → P1-R3 → P1-GATE 재검증 → P2-T1 → P2-T2 → P2-GATE → P3-T1 → P3-T2 → P3-GATE → P3-R1 → P3-GATE 재검증 → P1-R4 → P1/P2 영향 회귀 → 수정 후 리뷰`.
|
||||
Task 또는 Gate 하나만 활성 Goal로 운용한다. 각 Goal의 시작 조건·체크박스·완료 증거·검증 기록을 모두 충족해야 완료한다.
|
||||
과거 완료 기록은 되돌리지 않고 후속 문제를 별도 수정 Task로 누적한다.
|
||||
|
||||
2026-09-10 후속 리뷰: [Phase 1](reviews/phase-1-review.md)은 수정 1건, [Phase 2](reviews/phase-2-review.md)와
|
||||
[Phase 3](reviews/phase-3-review.md)는 추가 확정 발견 사항 없음.
|
||||
당시 후속 실행 순서: `P1-R4 → P1-GATE 영향 범위 재검토 → P2 상세 경로 영향 재검토 → 기존 테스트 서버 수동 검증`.
|
||||
당시 정적 리뷰는 사용자 지시에 따라 테스트를 재실행하지 않았다. 이후 `P1-R4`의 RED/GREEN, P1/P2 영향 회귀와
|
||||
수정 후 리뷰를 완료했다. 기존 Task/Gate 기록은 유지하며 남은 단계는 테스트 서버 수동 검증이다.
|
||||
|
||||
## 설계 계약
|
||||
|
||||
### 데이터와 공통 파이프라인
|
||||
|
||||
- `CreatorCommunity.languageCode: String?`, `contentRevision: Long`을 추가한다. 기존 행은 NULL/0, 신규 행도 0부터 시작한다.
|
||||
실제 원문 문자열 변경 때만 개정 번호를 증가시키고 언어를 NULL로 초기화한다. 동시 수정 시 개정 번호가 충돌하지 않도록
|
||||
기존 소유권 조건을 유지한 행 잠금 조회를 사용한다.
|
||||
- 신규 `CreatorCommunityTranslation`에는 `creatorCommunityId`, `locale`, `content`, `sourceRevision`, `sourceHash`,
|
||||
`sourceLanguage`와 기존 BaseEntity 시각 필드를 둔다. 게시물 ID와 locale 조합을 유일하게 만든다.
|
||||
- `LanguageDetectTargetType`과 `LanguageTranslationTargetType`에 `CREATOR_COMMUNITY`를 추가한다.
|
||||
번역 필드 키는 `content` 하나다. 다른 리소스 분기는 수정하지 않는다.
|
||||
- `TranslationSource`에 커뮤니티 원문의 개정 번호를 전달할 선택 필드를 추가하고 기존 호출은 기본값으로 유지한다.
|
||||
`TranslationSourceExtractor`는 활성 커뮤니티의 본문·언어·개정 번호를 같은 스냅샷으로 추출한다.
|
||||
- materializer는 추출된 개정 번호에 대응하는 메모리만 사용한다. 저장 시 현재 행의 개정 번호·언어·활성을 다시 검사하고
|
||||
동일한 짧은 DB 트랜잭션 안에서 upsert한다. 오래된 값이 새 번역을 덮어쓰지 않도록 개정 검사와 쓰기를 직렬화한다.
|
||||
외부 Papago 호출은 해당 잠금 밖에서 기존 워커가 처리한다.
|
||||
- 기존 `translation_job` 유일 키와 메모리 키는 유지한다. 이미 완료한 원문으로 복원되는 경우에는 메모리에서
|
||||
현재 개정 번호의 조회 모델을 먼저 materialize하고, 부족한 경우에만 기존 scheduler를 호출한다.
|
||||
|
||||
### 신규 공유 서비스 경계
|
||||
|
||||
`CreatorCommunityTranslationService` 하나에 다음의 명확히 분리된 진입점을 둔다. 단순 내부 호출을 위한 별도 port는 만들지 않는다.
|
||||
|
||||
| 인터페이스 | 책임 / 호출 조건 |
|
||||
|---|---|
|
||||
| `findDisplayContents(postIds: List<Long>, locale: String): Map<Long, CreatorCommunityDisplayContent>` | 읽기 전용. 현재 게시물과 번역을 일괄 조회해 표시 본문과 유효 번역 여부를 반환한다. 감지·예약·materialize 금지. 조회 권한 판정은 호출자가 먼저 수행한다. |
|
||||
| `requestTranslations(postId: Long, targetLanguage: String? = null)` | 별도 쓰기 트랜잭션. NULL이면 원문 외 지원 언어 전체, 값이 있으면 그 언어만 처리한다. 언어가 없으면 감지 이벤트를 발행하고, 있으면 메모리 복원 후 누락 작업을 예약한다. |
|
||||
|
||||
감지 이벤트에는 커뮤니티용 `sourceRevision: Long? = null`, `targetLanguage: String? = null` 정보를 추가한다.
|
||||
기존 이벤트 발행자는 기본값으로 동작한다. 커뮤니티 핸들러는 감지 요청 당시 개정과 현재 개정을 비교한다.
|
||||
감지 결과 저장은 개정 번호·활성 상태 조건을 포함한 갱신으로 처리하고, 저장 커밋 후 공유 서비스를 호출한다.
|
||||
상세에서 넘어온 targetLanguage를 유지해야 기존 글의 첫 감지가 전체 언어 번역으로 확대되지 않는다.
|
||||
|
||||
이미 감지된 본문에는 감지를 반복하지 않고 해당 언어로 예약을 이어간다. 다중 상세 요청에서 감지 API의 동시 중복 호출을
|
||||
완전히 제거하는 신규 분산 잠금은 추가하지 않는다. 기존 감지 캐시를 재사용하고 작업 중복/유일성 경합은 기존 DB 키와
|
||||
커뮤니티 예약 경계에서 제어한다. 유일 키 경합으로 정상 상세 응답이 실패하지 않도록 통합 검증한다.
|
||||
|
||||
### 트랜잭션과 표시 순서
|
||||
|
||||
- 작성·수정 커밋 후 처리에는 기존 커밋 후 실행 패턴을 재사용한다. 감지·예약은 롤백된 원문을 소비하지 않는다.
|
||||
- 레거시 서비스와 v2 facade/query service는 읽기 전용 트랜잭션이 있으므로 상세의 감지/예약은 별도 Spring bean의
|
||||
`REQUIRES_NEW` 진입점을 통해 처리한다. 자기 호출로 트랜잭션 프록시를 우회하지 않는다.
|
||||
- 권한/노출 판정 → 현재 원문/유효 번역 선택 → 기존 유료 마스킹 → 응답 조립 순서로 적용한다.
|
||||
- 목록·채널 홈·홈 추천·팔로잉은 일괄 읽기 메서드만 호출한다. 추천 스냅샷에는 언어별 본문을 넣지 않고
|
||||
기존 후보 ID로 얻은 상세 레코드의 본문만 응답 조립 전에 치환한다.
|
||||
- 원문을 담은 관리자 편집 응답, 댓글·답글, 푸시 이벤트와 정산 조회는 그대로 둔다.
|
||||
|
||||
### Phase 1: 번역 저장과 원문 언어 감지 기반
|
||||
|
||||
#### 구현 항목
|
||||
|
||||
- [x] **Task 1.1: 개정 번호에 대응하는 본문 번역 저장·조회·메모리 복원**
|
||||
|
||||
**Goal ID / objective:** `P1-T1` — 현재 본문과 일치하는 번역만 저장·조회할 수 있다.
|
||||
|
||||
- 시작 조건: PRD `CCT-001`, `CCT-007`, `CCT-010` 확인, 사용자 구현 요청.
|
||||
- 완료 증거: 아래 파일과 local automatic gate, 원문 복원/오래된 번역 차단 검증 기록.
|
||||
실제 MySQL DDL·HTTP·동시성 확인은 테스트 브랜치 배포 후 수동 체크리스트로 이관한다.
|
||||
- 범위 밖: 언어 감지 이벤트 연결, HTTP 진입점 변경.
|
||||
- 생산 인터페이스: 설계 계약의 `findDisplayContents`, 언어 확정 상태의 `requestTranslations`, 커뮤니티 source/materializer 분기.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityRepository.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationSourceExtractor.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationReadModelMaterializer.kt` |
|
||||
| 생성 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/adapter/out/persistence/CreatorCommunityTranslation.kt` |
|
||||
| 생성 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/adapter/out/persistence/CreatorCommunityTranslationRepository.kt` |
|
||||
| 생성 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt` |
|
||||
| 생성 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/CreatorCommunityTranslationServiceTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorkerTest.kt` |
|
||||
| 생성 | `docs/20260910_크리에이터커뮤니티게시물본문번역/schema.sql` |
|
||||
|
||||
- [x] **RED:** 번역 없음/동일 언어/현재 개정 일치/이전 개정/다른 언어 결과의 표시 테스트와 본문 A→B→A 복원 테스트를 작성한다.
|
||||
materializer는 작업 실행 도중 원문 변경·비활성화 시 오래된 번역을 저장/노출하지 않는 테스트로 보호한다.
|
||||
- [x] **RED 확인:** 아래 focused 명령에서 해당 동작의 assertion 실패를 확인한다. 컴파일 오류만으로 RED를 완료하지 않는다.
|
||||
- [x] **GREEN:** 기존 메모리/워커를 연결하고 일괄 읽기, 개정 검사, 현재 메모리 복원을 최소 구현한다.
|
||||
MySQL DDL은 기존 행 NULL/0 보존, locale 유일 키, 모든 컬럼 COMMENT, 테이블 COMMENT를 포함한다.
|
||||
시각은 `TIMESTAMP`, created/updated 기본값은 `docs/agent-guides/문서유지보수.md`를 따른다.
|
||||
- [x] **GREEN 확인:** focused test에서 원문 보존·중복 키·동시 upsert·이전 결과 차단·캐시 복원 통과를 확인한다.
|
||||
- [x] **REFACTOR:** 이번 변경의 중복만 정리하고 focused test와 공통 번역 회귀를 실행한다. 자동 검증 결과와 배포 후 DDL 확인 항목을 이 Task 아래에 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobWorkerTest'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
```
|
||||
|
||||
DDL은 테스트 브랜치 배포 후 격리된 MySQL 테스트 DB에 적용해 기존 행의 언어 NULL/개정 0, 원문 유지, locale 유일 키를 확인한다.
|
||||
운영 DB 적용은 이 Task의 검증으로 실행하지 않는다.
|
||||
|
||||
#### P1-T1 검증 기록 — 2026-09-10
|
||||
|
||||
- RED: `CreatorCommunityTranslationServiceTest.shouldRequireCreatorCommunityTranslationPersistenceContract`를 먼저 작성했다.
|
||||
`./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobWorkerTest'`는
|
||||
exit 1로 실패했고, `CreatorCommunityTranslationServiceTest.kt:13`의 `languageCode` 누락 assertion이 원인이었다.
|
||||
- GREEN/REFACTOR: `CreatorCommunity`의 nullable 언어·0 시작 개정, 현재 개정/해시/언어/활성 상태를 잠금으로 재확인하는
|
||||
materializer, 메모리 우선 복원 후 기존 scheduler를 쓰는 서비스, 일괄 표시 조회와 locale 유일 번역 저장을 추가했다.
|
||||
같은 focused 명령은 exit 0, `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 동시성: H2 MySQL 모드 `@DataJpaTest`에서 두 worker를 같은 원문 메모리 재료화 지점에 동시에 진입시켜,
|
||||
게시물 `PESSIMISTIC_WRITE` 잠금과 locale 유일 키 후 번역 행 1개·오류 0개를 확인했다.
|
||||
- 공통 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.*'` — exit 0, `BUILD SUCCESSFUL`.
|
||||
`./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- DDL: `schema.sql`에 기존 행의 `language_code` NULL 및 `content_revision` 0 기본값, locale 유일 키, 전 컬럼/테이블 COMMENT,
|
||||
BaseEntity 시각 컬럼 규칙을 기록했다. 운영 DB에는 적용하지 않았다.
|
||||
- 전체 회귀/수동 HTTP/Papago/MySQL 적용 검증은 이 Task의 지정 focused/common Gradle 검증 범위를 넘어 실행하지 않았다.
|
||||
|
||||
#### P1-T1 검토 수정 — 2026-09-10
|
||||
|
||||
- 검토 확인 RED: `CreatorCommunityTranslationServiceTest.shouldRejectStaleManagedCreatorCommunityAfterConcurrentCommit`를
|
||||
실제 JPA transaction으로 추가했다. focused 명령은 exit 1, 20개 테스트 중 1개 실패였고,
|
||||
stale managed `CreatorCommunity`로 이전 번역을 저장한 `CreatorCommunityTranslationServiceTest.kt:358` assertion이 원인이었다.
|
||||
- GREEN: `EntityManager.refresh(..., PESSIMISTIC_WRITE)`로 잠긴 게시물의 최신 상태를 다시 읽고,
|
||||
번역 행도 `findByCreatorCommunityIdAndLocaleForUpdate`의 잠금 현재 읽기로 조회하게 했다. provider 호출은 기존처럼
|
||||
materializer transaction 밖의 worker에 남는다. 같은 focused 명령은 exit 0, `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 테스트 보강: 번역 없음·원문 언어 불일치·원문 해시 불일치의 원문 폴백, 실제 JPA stale persistence-context 차단,
|
||||
A→B→A 기존 번역 행 갱신, repeatable-read snapshot 뒤 동시 upsert를 확인했다.
|
||||
- H2 설정: `@AutoConfigureTestDatabase(replace = NONE)`와 `jdbc:h2:mem:creator-community-translation;MODE=MySQL`을
|
||||
사용한다. 이는 H2 호환 모드일 뿐 실제 MySQL DDL·REPEATABLE READ 검증 증거가 아니다.
|
||||
- 기록 정정: 이전 검증 기록의 H2 MySQL 모드 표기는 datasource replacement를 끄기 전에는 성립하지 않았다.
|
||||
현재 H2 설정을 바로잡았지만, 과거 결과를 실제 MySQL 결과로 소급하지 않는다.
|
||||
- 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.*'` — exit 0, `BUILD SUCCESSFUL`.
|
||||
`./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 사용자 검증 정책 변경: local acceptance는 automatic Gradle test와 ktlint으로 한정한다. 위 자동 검증이 통과했으므로
|
||||
Task 1.1과 GREEN 확인/REFACTOR 체크박스를 완료로 표시한다. Docker, 서버, HTTP, MySQL은 local에서 실행하지 않는다.
|
||||
- 배포 후 보류: `schema.sql` 적용, 기존 행 NULL/0, locale 유일 키, 실제 MySQL REPEATABLE READ 동작은
|
||||
테스트 브랜치 배포 후 아래 수동 체크리스트에서만 확인하며 아직 통과로 기록하지 않는다.
|
||||
|
||||
- [x] **Task 1.2: 미확정 원문 언어의 비동기 감지와 목표 언어 전달**
|
||||
|
||||
**Goal ID / objective:** `P1-T2` — 본문 언어가 없으면 현재 본문을 감지하고 요청 범위의 번역으로 이어진다.
|
||||
|
||||
- 시작 조건: `P1-T1` 완료.
|
||||
- 완료 증거: 한국어/영어/일본어 감지, 단일/전체 목표 언어 전달, 지연 감지·실패 테스트 통과.
|
||||
- 범위 밖: 사용자 언어 입력 필드, 다른 리소스의 감지 정책 변경.
|
||||
- 소비/생산: 기존 `LanguageDetectionCacheService.detectWithCache`를 소비하고 커뮤니티 감지 분기와 완성된 `requestTranslations`를 제공한다.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityRepository.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt` |
|
||||
| 생성 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt` |
|
||||
| 확인 | `src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt` |
|
||||
|
||||
- [x] **RED:** 언어 NULL인 본문 감지 후 `targetLanguage=ja`는 일본어만, NULL은 원문 외 두 언어로 이어지는 실패 테스트를 작성한다.
|
||||
빈 본문·미지원 원문 언어·감지 실패·감지 중 본문 수정·중복 이벤트도 포함한다.
|
||||
- [x] **RED 확인:** 아래 focused 명령으로 목표 언어 유실과 오래된 감지 결과 적용의 assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** 기존 감지 이벤트에 기본값이 있는 커뮤니티 메타데이터를 추가하고 개정 조건부 저장·커밋 후 예약을 연결한다.
|
||||
다른 리소스 이벤트와 동작은 유지한다.
|
||||
- [x] **GREEN 확인:** 외부 감지 provider 경계만 통제한 통합 테스트로 실제 커밋 후 이벤트/별도 트랜잭션을 확인한다.
|
||||
테스트를 감싼 트랜잭션이 커밋되지 않아 AFTER_COMMIT이 실행되지 않는 문제를 피한다.
|
||||
- [x] **REFACTOR:** focused test와 기존 감지 캐시/번역 scheduler 회귀 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest'
|
||||
```
|
||||
|
||||
#### P1-T2 검증 기록 — 2026-09-10
|
||||
|
||||
- RED: `CreatorCommunityLanguageDetectTest.shouldKeepLegacyDefaultsAndDeclareCreatorCommunityMetadata`를 먼저 작성했다.
|
||||
`./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'`는 exit 1,
|
||||
`CreatorCommunityLanguageDetectTest.kt:14`의 `CREATOR_COMMUNITY` 누락 assertion으로 실패했다.
|
||||
- 행위 RED: 커밋 후 단일/전체 목표 언어 전달, 빈 본문·미지원 감지값·provider 실패·stale 본문·중복 이벤트를 추가했다.
|
||||
같은 명령은 exit 1, `CreatorCommunityLanguageDetectTest.kt:85`, `:106`, `:179`의
|
||||
`CreatorCommunityTranslationService.requestTranslations(...)` `WantedButNotInvoked` assertion으로 실패했다.
|
||||
- GREEN: `LanguageDetectEvent`의 기본값 있는 커뮤니티 메타데이터, 언어 미확정 본문의 현재 content/revision 이벤트 발행,
|
||||
감지 외부 호출 전 무잠금 상태 확인과 잠금 후 active/revision/content 재확인, 저장 커밋 뒤 기존 번역 서비스 호출을 연결했다.
|
||||
감지 언어가 지원 범위 밖이거나 비어 있으면 원문·작업 상태를 유지한다.
|
||||
- AFTER_COMMIT: `@Transactional(propagation = Propagation.NOT_SUPPORTED)` 테스트와 명시적 `TransactionTemplate` 커밋으로
|
||||
이벤트를 발행했다. 테스트용 감지 provider 경계만 제어하고 실제 행 언어 저장 뒤 `requestTranslations(postId, "ja")` 또는
|
||||
`requestTranslations(postId, null)` 호출을 검증했다.
|
||||
- focused: 위 P1-T2 명령 — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 범위: local automatic 정책에 따라 Docker, HTTP, 실제 Papago, MySQL DDL/동시성은 실행하지 않았다.
|
||||
|
||||
#### Phase 1 Gate
|
||||
|
||||
- [x] **Goal `P1-GATE`: 현재 본문 기반의 감지·번역 저장 경계 판정**
|
||||
|
||||
- 시작 조건: `P1-T1`, `P1-T2` 체크박스와 검증 기록 완료.
|
||||
- 완료 증거: 아래 명령 exit 0, DDL·엔티티 정적 대조, 개정/언어 불일치 미노출 기록.
|
||||
- 범위 밖: API 연결 구현. TDD 예외 사유: 이미 작성한 동작과 DDL의 Gate 검증이며 별도 제품 코드가 아니다.
|
||||
- 대체 검증: 기존 통합 테스트를 실행하고 DB의 현재 개정/번역 개정/언어를 대조한다.
|
||||
- 확인 파일: 두 Task의 source/test 파일 및 `docs/20260910_크리에이터커뮤니티게시물본문번역/schema.sql`.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
```
|
||||
|
||||
#### P1-GATE 검증 기록 — 2026-09-10
|
||||
|
||||
- 자동 Gate: 위 명령 — exit 0, `BUILD SUCCESSFUL`. 현재 개정 번역 저장/메모리 복원/작업 예약과 커밋 후 감지 경계를 함께 회귀했다.
|
||||
- 형식: `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 보류: 사용자 정책에 따라 실제 MySQL DDL·격리 수준, HTTP, Docker, 실제 Papago 검증은 실행하지 않았다.
|
||||
테스트 브랜치 배포 후 수동 체크리스트는 모두 미완료 상태로 유지한다.
|
||||
|
||||
#### Task 1.R: P1-T2 리뷰 회귀 수정
|
||||
|
||||
**Goal ID / objective:** `P1-R1` — 감지 커밋 후 독립 트랜잭션에서 현재 본문만 요청 범위대로 번역 작업으로 예약한다.
|
||||
|
||||
- 시작 조건: `reviews/p1-t2-review.md`의 `REV-P1-T2-001~004` 확정, `P1-T2` 및 `P1-GATE` 기존 기록 확인.
|
||||
- 완료 증거: 실제 Spring 감지 listener와 scheduler를 사용하는 실패 재현 test, 최소 수정 후 focused test와 P1 Gate 재검증, 검증 기록.
|
||||
- 범위 밖: 다른 리소스 감지 정책, 공개 API, Docker·HTTP·실제 Papago·MySQL 수동 검증.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` |
|
||||
| 생성 | `docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/p1-t2-review.md` |
|
||||
|
||||
- [x] **RED:** `REQUIRES_NEW`, 실제 `translation_job` 저장, stale 본문 차단, known-language 다중 target continuation을 실제 Spring 경계로 재현한다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'`에서 7개 중 5개 assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** 감지 listener의 잠금 현재 읽기와 커밋 후 독립 번역 요청 트랜잭션을 최소 수정한다.
|
||||
- [x] **GREEN 확인:** 같은 focused test가 성공하는지 확인한다.
|
||||
- [x] **REFACTOR:** P1-T2 직접 영향 회귀, P1 Gate, ktlint을 실행하고 실제 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
#### P1-R1 진행 기록 — 2026-09-10
|
||||
|
||||
- RED: 새 `CreatorCommunityLanguageDetectTest`는 초기 Kotlin generic 추론과 `AudioContentThemeQueryRepository` test bean 누락을 바로잡은 뒤 실행했다.
|
||||
focused 명령은 exit 1, 7개 중 5개 assertion 실패였다. `REQUIRES_NEW` annotation 부재, 커밋 뒤 job 미저장,
|
||||
stale 감지 결과의 언어 저장, 이미 언어가 있는 두 target 이벤트의 job 미저장을 확인했다.
|
||||
- GREEN: `requestTranslations`를 `REQUIRES_NEW`로 분리하고, listener는 잠금 조회 뒤 `EntityManager.refresh(..., PESSIMISTIC_WRITE)`로
|
||||
현재 본문을 확인한다. 이미 언어가 있는 이벤트도 감지 없이 after-commit에서 원 target을 요청한다.
|
||||
- GREEN 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` — exit 0, `BUILD SUCCESSFUL`.
|
||||
실제 Spring listener와 scheduler를 통해 단일/전체 target job 저장, stale 본문 차단, known-language 두 target continuation을 확인했다.
|
||||
- REFACTOR/Phase Gate 재검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*' ktlintCheck bootJar`
|
||||
— exit 0, `BUILD SUCCESSFUL`. focused/공통 번역 회귀, ktlint, bootJar를 함께 확인했다.
|
||||
- 범위: 사용자 정책에 따라 Docker, HTTP, 실제 Papago, MySQL 수동 동시성 검증은 실행하지 않았다.
|
||||
|
||||
#### Task 1.R3: P1-T2 동일 게시물 작업 예약 직렬화
|
||||
|
||||
**Goal ID / objective:** `P1-R3` — 같은 게시물·target의 동시 `requestTranslations`가 누락 메모리에서도 하나의 translation job만 예약한다.
|
||||
|
||||
- 시작 조건: `reviews/p1-t2-review.md`의 `REV-P1-T2-006` 확정, `P1-R2` 완료 기록 확인.
|
||||
- 완료 증거: 실제 Spring/JPA service와 job repository의 concurrent missing-memory RED test, 최소 lock 수정 후 두 호출 정상 종료·job 1개, P1 Gate 재검증.
|
||||
- 범위 밖: 공통 scheduler 동작, 다른 resource type, 분산 lock·의존성, P2/P3, Docker·HTTP·실제 Papago·MySQL 수동 검증.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/CreatorCommunityTranslationServiceTest.kt` |
|
||||
| 수정 | `docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/p1-t2-review.md` |
|
||||
|
||||
- [x] **RED:** 같은 언어 확정 게시물·target의 두 요청을 동시에 시작해 실제 `translation_job` unique insert 경합을 재현한다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest'`에서 unique-key 오류 또는 정상 완료/job 1개 위반을 확인한다.
|
||||
- [x] **GREEN:** `REQUIRES_NEW` request transaction 시작에 active post write lock과 refresh를 추가한다.
|
||||
- [x] **GREEN 확인:** 같은 focused test가 두 호출 정상 종료와 job 1개를 확인한다.
|
||||
- [x] **REFACTOR:** P1-T2 직접 영향 회귀, P1 Gate, ktlint을 실행하고 실제 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
#### P1-R3 진행 기록 — 2026-09-10
|
||||
|
||||
- 원인: `requestTranslations`가 post lock 없이 memory lookup과 `TranslationJobScheduler`의 select-then-insert를 수행해,
|
||||
동일 post·target의 두 request transaction이 unique key에 경합할 수 있다.
|
||||
- RED: actual JPA `translation_job` 저장을 사용하는 두 `REQUIRES_NEW` transaction의 missing-job 조회 창을 동기화했다.
|
||||
focused 명령은 exit 1이었고, H2 `SQLState 23505`의 `uk_translation_job_resource_field_target_hash` unique index 충돌을 확인했다.
|
||||
- GREEN: `requestTranslations` 시작에서 active post를 `findByIdAndIsActiveTrueForUpdate`로 잠그고
|
||||
`EntityManager.refresh(..., PESSIMISTIC_WRITE)`한 뒤 source extraction, memory lookup, job 조회·저장을 진행한다.
|
||||
- GREEN 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest'`
|
||||
— exit 0, `BUILD SUCCESSFUL`; 두 호출이 정상 종료하고 job 1개만 저장됨을 확인했다.
|
||||
- REFACTOR/Phase Gate 재검증:
|
||||
`./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest'`
|
||||
— exit 0, `BUILD SUCCESSFUL`.
|
||||
`./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'`
|
||||
— exit 0, `BUILD SUCCESSFUL`.
|
||||
`./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 전체 `./gradlew test`는 private scheduling service의 직접 영향 회귀와 P1 Gate가 통과했으므로 실행하지 않았다.
|
||||
Docker, HTTP, 실제 Papago, MySQL 수동 동시성 검증도 사용자 정책에 따라 실행하지 않았다.
|
||||
|
||||
#### Task 1.R2: P1-T2 동시 감지 target 보존
|
||||
|
||||
**Goal ID / objective:** `P1-R2` — 두 언어 미확정 감지가 같은 현재 본문에서 경합해도 각 이벤트의 요청 target을 커밋 후 예약한다.
|
||||
|
||||
- 시작 조건: `reviews/p1-t2-review.md`의 `REV-P1-T2-005` 확정, `P1-R1` 완료 기록 확인.
|
||||
- 완료 증거: 두 감지가 언어 NULL에서 시작한 뒤 한 감지만 먼저 커밋하는 실제 Spring failure test, 최소 수정 후 focused test와 P1 Gate 재검증, 검증 기록.
|
||||
- 범위 밖: 다른 리소스 감지 정책, 공개 API, Docker·HTTP·실제 Papago·MySQL 수동 검증.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` |
|
||||
| 수정 | `docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/p1-t2-review.md` |
|
||||
|
||||
- [x] **RED:** 두 감지가 `languageCode=NULL`에서 시작하고 첫 감지 커밋 뒤 두 번째가 lock을 얻는 실제 `ja`/`en` target 보존 test를 작성한다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'`에서 두 번째 target job 미저장 assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** lock/refresh 뒤 현재 revision/content가 유효하고 언어만 이미 설정된 경우 after-commit에 원 target을 등록한다.
|
||||
- [x] **GREEN 확인:** 같은 focused test가 성공하는지 확인한다.
|
||||
- [x] **REFACTOR:** P1-T2 직접 영향 회귀, P1 Gate, ktlint을 실행하고 실제 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
#### P1-R2 진행 기록 — 2026-09-10
|
||||
|
||||
- 원인: `handleCreatorCommunityLanguageDetect`는 lock과 refresh 뒤 다른 감지가 저장한 languageCode를 발견하면,
|
||||
현재 revision/content가 일치해도 return하여 두 번째 이벤트의 target을 잃는다.
|
||||
- RED: `shouldPreserveSecondTargetAfterConcurrentDetectionSetsLanguage`는 두 감지가 languageCode NULL에서 시작한 뒤,
|
||||
첫 감지의 `ja` 예약·커밋 후 두 번째 감지의 `en` 진행을 순서대로 제어했다. focused 명령은 exit 1, 8개 중 1개 assertion 실패였고,
|
||||
`translation_job`의 `en` 누락이 원인이었다.
|
||||
- GREEN: lock/refresh 뒤 revision/content가 같고 languageCode만 이미 설정되어 있으면 이를 덮어쓰지 않고,
|
||||
기존 `requestTranslationsAfterCommit`에 두 번째 이벤트의 원 target을 등록한다. lock 보유 중 `REQUIRES_NEW` 호출은 하지 않는다.
|
||||
- GREEN 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` — exit 0, `BUILD SUCCESSFUL`.
|
||||
실제 listener/scheduler/repository에서 `ja`와 `en` job 저장 및 source language `ko` 유지를 확인했다.
|
||||
- REFACTOR/Phase Gate 재검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*' ktlintCheck`
|
||||
— exit 0, `BUILD SUCCESSFUL`.
|
||||
- 범위: 사용자 정책에 따라 Docker, HTTP, 실제 Papago, MySQL 수동 동시성 검증은 실행하지 않았다.
|
||||
|
||||
#### Task 1.R4: 최초 언어 감지 캐시 경합에서도 요청 target 보존
|
||||
|
||||
- [x] **Goal `P1-R4`: 동일 본문의 최초 감지 캐시 저장이 경합해도 각 이벤트의 목표 언어 번역을 예약한다**
|
||||
|
||||
- 시작 조건: [Phase 1 리뷰](reviews/phase-1-review.md)의 `REV-P1-007` 확인 및 후속 구현 요청.
|
||||
기존 `P1-R1~R3` 완료 기록을 유지한다.
|
||||
- 관련 요구사항: `CCT-002`, `CCT-005`, `CCT-010`, 기존 `P1-R2`의 요청 target 보존.
|
||||
- 완료 증거: 실제 감지 캐시 INSERT 경합의 실패 회귀 테스트 → 최소 수정 → focused 성공 →
|
||||
P1 감지/번역 및 P2 상세 영향 회귀 → 실행 결과와 리뷰 재판정 기록.
|
||||
- 범위 밖: 외부 감지 중 게시물 행 잠금, 새 분산 잠금/큐/스케줄러, 번역 지원 언어·공개 API 변경,
|
||||
감지 provider 실패를 무조건 성공으로 처리, 다른 리소스의 제품 정책 변경, 운영 DB 작업.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionResultRepository.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionResult.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt` |
|
||||
| 생성 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectionCacheConcurrencyTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt` |
|
||||
| 확인 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` |
|
||||
| 기록 | `docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/phase-1-review.md` |
|
||||
| 기록 | `docs/20260910_크리에이터커뮤니티게시물본문번역/plan-task.md` |
|
||||
|
||||
설계 기준:
|
||||
|
||||
- 캐시 miss → detector → 무조건 INSERT를 같은 키의 원자적 저장으로 바꾸고 저장된 결과로 수렴시켰다.
|
||||
MySQL `ON DUPLICATE KEY UPDATE id=id`와 scalar `SELECT ... FOR UPDATE`를 사용한다. DB 예외를 잡아 무시하지 않는다.
|
||||
- 동일 해시/provider/normalizationVersion의 승자 결과를 사용하며, 반복 읽기 격리에서도 최초 miss 스냅샷에 갇히지 않도록
|
||||
저장 후 current read를 보장한다. 기본 언어를 임의로 지정하거나 감지 결과를 최신 본문에 무조건 적용하지 않는다.
|
||||
- 이미 rollback-only가 된 같은 JPA 트랜잭션에서 예외만 잡고 target 예약을 계속하는 방식은 금지한다.
|
||||
공통 캐시 저장 수정이 다른 리소스의 기존 감지/캐시 재사용 동작을 바꾸지 않도록 회귀 검증한다.
|
||||
- 새 테스트는 실제 `LanguageDetectionCacheService`와 repository를 사용한다. `detectWithCache` 전체를 override하지 않고
|
||||
감지 HTTP 응답/provider 경계만 통제해 두 트랜잭션이 모두 캐시 miss를 읽는 순서를 제어한다.
|
||||
|
||||
- [x] **RED:** `shouldPreserveBothTargetsWhenInitialDetectionCacheWritesRace`를 추가한다.
|
||||
언어 NULL/캐시 없음의 동일 게시물에서 `ja`, `en` 감지를 동시에 시작하고 두 cache miss 이후 첫 저장/커밋,
|
||||
후발 저장 순서로 진행한다. 두 흐름의 정상 완료, 캐시 1행, `ja`/`en` 작업 모두 존재를 assertion으로 검증한다.
|
||||
- [x] **RED 확인:** 아래 focused 명령으로 후발 중복 키 예외 또는 target 누락의 의도한 실패를 확인한다.
|
||||
두 이벤트가 애초에 cache miss에 진입하지 않은 테스트를 경합 재현으로 인정하지 않는다.
|
||||
- [x] **GREEN:** 원자적 캐시 저장과 저장 결과 재사용만 최소 수정한다. 감지 후 기존 개정/본문 검사와
|
||||
커밋 후 요청 target 전달을 유지한다.
|
||||
- [x] **GREEN 확인:** 같은 focused 테스트를 통과시키고, 같은 본문의 서로 다른 게시물 생성도 캐시 1행을 공유하면서
|
||||
각 게시물의 원문 외 두 언어 작업이 보존되는지 검증한다.
|
||||
- [x] **REFACTOR:** 감지 캐시·listener·scheduler·materializer 및 실제 상세 프록시 영향 회귀를 실행한다.
|
||||
선택한 DB 저장 문법의 MySQL 동작과 H2 테스트 지원 범위를 구분하고 실제 MySQL 미검증은 기존 수동 체크리스트에 남긴다.
|
||||
관련 없는 변경 없이 아래 명령 결과와 `REV-P1-007` 수정 판정을 누적한다.
|
||||
|
||||
실행한 focused/영향 회귀 명령이다. focused의 fresh 재실행에는 `--rerun-tasks --no-parallel`을 추가했다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectionCacheConcurrencyTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*'
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
공통 캐시 동작 변경의 영향이 위 회귀로 판정되지 않으면 전체 테스트로 확장한다. 전체 테스트 생략 시 대체 명령과 근거를 기록한다.
|
||||
현재 상태: RED/GREEN·영향 회귀·수정 후 리뷰 완료. 실제 MySQL 검증은 미완료다.
|
||||
|
||||
#### P1-R4 실행 기록 · 2026-09-10
|
||||
|
||||
- RED: 위 focused 명령은 두 provider 진입을 모두 확인한 뒤 exit 1로 실패했다.
|
||||
`ExecutionException → DataIntegrityViolationException → H2 23505`로 감지 캐시 유일 키 충돌을 재현했다.
|
||||
- GREEN: native MySQL `ON DUPLICATE KEY UPDATE id=id`로 승자 행을 보존하고 scalar `SELECT ... FOR UPDATE`로
|
||||
저장 결과를 현재 읽기한다. native INSERT의 audit 시각을 명시하며 DB 예외를 잡고 계속 진행하지 않는다.
|
||||
- GREEN 확인: 실제 캐시 service/repository를 사용해 같은 게시물의 `ja`/`en` 이중 miss 및
|
||||
서로 다른 게시물의 동일 본문 경합을 검증했다. 캐시 1행으로 수렴하며 각 요청의 목표 언어 작업을 보존한다.
|
||||
- focused fresh 재실행: 위 첫 명령 + `--rerun-tasks --no-parallel`, `BUILD SUCCESSFUL` (5분 19초).
|
||||
- P1 listener/scheduler/materializer 회귀: 위 두 번째 명령, `BUILD SUCCESSFUL` (1분 09초).
|
||||
- P2 상세/API 회귀: 위 세 번째 명령, `BUILD SUCCESSFUL` (1분 42초).
|
||||
- 형식: `./gradlew ktlintCheck`, `BUILD SUCCESSFUL` (44초).
|
||||
- 수정 후 리뷰: spec `PASS` (`ses_f7657d350ffe3YUYhEHvHPuikK`), quality `APPROVED`
|
||||
(`ses_f7656994dffeR2odTLswaw6RTo`). `REV-P1-007`은 로컬 자동 검증 범위에서 수정 완료로 재판정했다.
|
||||
- 전체 `./gradlew test`는 생략했다. 공통 캐시 계약, 실제 repository 경합, 모든 직접 listener/scheduler/materializer
|
||||
테스트와 P2 상세/API 소비자가 위 대체 명령으로 통과했고, 미해결 상태로 남은 별도의 공통 경계가 없었다.
|
||||
- 범위: H2 `MODE=MySQL`은 실제 MySQL 8/InnoDB 검증이 아니다. 실제 MySQL/Papago/HTTP, Docker, 배포는
|
||||
실행하지 않았다. 상세 명령·리뷰 출처는 [후속 증거](reviews/review-evidence.md)에 누적한다.
|
||||
|
||||
### Phase 2: 작성·수정·상세에서 번역 처리
|
||||
|
||||
#### 구현 항목
|
||||
|
||||
- [x] **Task 2.1: 작성 커밋과 실제 본문 변경에 감지·번역 연결**
|
||||
|
||||
**Goal ID / objective:** `P2-T1` — 생성과 본문 수정이 현재 원문 기준의 전체 목표 언어 번역으로 이어진다.
|
||||
|
||||
- 시작 조건: `P1-GATE` 완료, `CCT-003~004`, `CCT-007` 확인.
|
||||
- 완료 증거: 신규·실제 변경·동일 본문·롤백·관리자 공용 경로·동시 수정 검증.
|
||||
- 범위 밖: 이미지·오디오 업로드, 알림 발행, 소유권 정책 변경.
|
||||
- 소비 인터페이스: `requestTranslations(postId, targetLanguage = null)`.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityRepository.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt` |
|
||||
| 확인/테스트 보강 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt` |
|
||||
| 확인/테스트 보강 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostUpdateTest.kt` |
|
||||
|
||||
- [x] **RED:** 생성 커밋 후 감지, 본문 변경 때만 언어 NULL/개정 증가, 롤백 시 작업 없음의 실패 테스트를 작성한다.
|
||||
수정 후 새 요청에서 이전 번역 미노출, 실제 언어 변경, 관리자 공용 경로도 포함한다.
|
||||
- [x] **RED 확인:** 아래 focused 명령으로 번역 연결 부재/개정 미변경의 assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** 기존 커밋 후 실행 패턴으로 공유 서비스를 연결하고 본문 문자열이 다른 경우에만 언어·개정 상태를 갱신한다.
|
||||
소유권 조건을 유지한 잠금 조회로 동시 본문 수정의 개정을 직렬화한다.
|
||||
- [x] **GREEN 확인:** 테스트를 다시 실행하고 동일 본문/본문 외 변경은 감지·번역 요청 0건인지 확인한다.
|
||||
- [x] **REFACTOR:** 공용 서비스 호출자 회귀를 확인한다. 관리자 facade의 중복 번역 연결은 추가하지 않는다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostCreateTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostUpdateTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostConcurrencyTest'
|
||||
```
|
||||
|
||||
#### P2-T1 검증 기록 — 2026-09-10
|
||||
|
||||
- RED: focused 명령은 exit 1이었다. 생성 뒤 번역 요청 누락, 본문 변경 뒤 개정/언어 상태 미갱신의 assertion 실패를
|
||||
`CreatorCommunityServiceTest.kt:333`, `AiCharacterAdminCommunityPostCreateTest.kt:93`,
|
||||
`AiCharacterAdminCommunityPostUpdateTest.kt:91`에서 확인했다.
|
||||
- GREEN: `CreatorCommunityService`는 생성과 실제 본문 변경 뒤 기존 after-commit 패턴으로
|
||||
`requestTranslations(postId, null)`을 요청한다. 본문 변경은 `languageCode`를 NULL로 초기화하고
|
||||
`contentRevision`을 증가시키며, 수정 조회는 소유권 조건의 `PESSIMISTIC_WRITE` 잠금 후 refresh한다.
|
||||
- 동시성: 실제 H2/JPA 비관적 잠금 조회를 latch로 제어한 관리자 MockMvc 요청 두 건이 모두 200으로 종료하고,
|
||||
최종 revision 2와 번역 요청 2건을 확인했다.
|
||||
- focused: 위 명령과 `LegacyCommunityPostCharacterizationTest`를 포함한 실행 — exit 0, `BUILD SUCCESSFUL`.
|
||||
생성·수정·동일 본문·롤백·관리자 공용 경로·동시 수정과 변경된 수동 생성자 호출을 함께 확인했다.
|
||||
- P1 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'`
|
||||
— exit 0, `BUILD SUCCESSFUL`.
|
||||
- 형식: `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 범위: local acceptance 정책에 따라 Docker, 서버 HTTP, 실제 Papago, MySQL 수동 동시성 검증은 실행하지 않았다.
|
||||
|
||||
#### Task 2.R: P2-T1 spec-review 실제 트랜잭션 증거
|
||||
|
||||
**Goal ID / objective:** `P2-R1` — 실제 Spring transaction에서 생성 롤백과 본문 변경 뒤 stale 번역 폴백·감지 예약을 증명한다.
|
||||
|
||||
- 시작 조건: `P2-T1` 완료, spec-review의 실제 transaction 증거 보강 요구 확인.
|
||||
- 완료 증거: proxied `CreatorCommunityService` 생성 롤백 뒤 post/job/detection 미영속과, 한글→영문 수정 커밋 뒤
|
||||
revision·stale 번역 폴백·영문 감지·새 target job을 별도 transaction에서 확인한다.
|
||||
- 범위 밖: P2-T2 상세/목록 API, production 리팩터링, Docker·HTTP·실제 Papago/MySQL 검증.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` |
|
||||
| 수정 | `docs/20260910_크리에이터커뮤니티게시물본문번역/plan-task.md` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` |
|
||||
|
||||
- [x] **TEST-FIRST:** 실제 outer transaction rollback 뒤 생성 post와 감지/번역 작업이 남지 않는 test를 작성한다.
|
||||
기존 한글 translation 행이 있는 post의 실제 영문 수정 뒤, 감지 전 stale 번역 폴백과 감지 후 언어·target job을 test한다.
|
||||
- [x] **TEST-FIRST 확인:** 첫 실행에서 async 감지 완료 전 `languageCode`를 읽은 assertion 실패를 확인하고, 기존 `await` 방식의 완료 조건으로 test 경계를 바로잡는다.
|
||||
- [x] **GREEN:** test가 production 결함을 드러내면 실패 test를 유지한 최소 수정만 적용하고, 아니면 실제 경계 증거만 보강한다.
|
||||
- [x] **GREEN 확인:** 실제 post-commit 경로가 완료될 때까지 latch/새 transaction으로 대기한 assertion을 통과시킨다.
|
||||
- [x] **REFACTOR:** P2 focused, P1 번역/감지 회귀, ktlint 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostCreateTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostUpdateTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostConcurrencyTest'
|
||||
```
|
||||
|
||||
#### P2-R1 검증 기록 — 2026-09-10
|
||||
|
||||
- test-first: `CreatorCommunityLanguageDetectTest`에 실제 `CreatorCommunityService` bean을 등록하고 AOP proxy를 확인했다.
|
||||
JPA repository, listener, translation service, scheduler는 실제 bean을 사용하며 S3·FCM 연관 의존성과 external detector만 test 경계로 제어했다.
|
||||
- 생성 rollback: 실제 outer `TransactionTemplate`에서 `createCommunityPost`를 호출하고 rollback-only로 종료했다.
|
||||
새 transaction에서 생성 post 부재, 해당 post ID의 translation read-model 및 `translation_job` 0건, detector query 0건을 확인했다.
|
||||
- 영문 수정: 기존 한글 원문과 유효한 이전 영어 translation 행을 만들고 실제 `modifyCommunityPost`를 commit했다.
|
||||
차단된 detector가 시작된 뒤 새 transaction에서 `languageCode=NULL`, revision 1, 현재 영문 원문 반환과 이전 영어 translation 행의
|
||||
미사용을 확인했다. detector 해제 뒤 `languageCode=en`, `ko`·`ja` job 예약을 대기해 확인했다.
|
||||
- 첫 실행: rollback test는 통과했고 수정 test는 async `@TransactionalEventListener` 완료 전 state를 읽어 `expected en but was null`로 실패했다.
|
||||
이는 production 결함이 아니라 outer service future가 async 감지 완료를 기다리지 않는 test synchronization 문제였으며,
|
||||
기존 조건 기반 `await`로 post-commit 완료를 기다리도록 바로잡았다. production 파일은 변경하지 않았다.
|
||||
- focused: 위 P2-R1 명령에 `--rerun-tasks`를 추가해 재실행 — exit 0, `BUILD SUCCESSFUL`.
|
||||
- P1 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'`
|
||||
에 `--rerun-tasks`를 추가해 재실행 — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 형식: `./gradlew ktlintCheck --rerun-tasks` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 범위: Docker, 서버 HTTP, detail/list API, 실제 Papago/MySQL 검증은 실행하거나 변경하지 않았다.
|
||||
|
||||
#### Task 2.R: P2-T1 quality-review 고정 상태 경쟁 회귀
|
||||
|
||||
**Goal ID / objective:** `P2-R2` — 관리자/레거시 고정 상태 변경이 최신 본문·개정·언어 상태를 덮어쓰지 않는다.
|
||||
|
||||
- 시작 조건: P2-T1 완료, quality-review의 stale flush 및 test pseudo-lock 지적 확정.
|
||||
- 완료 증거: stale admin read 뒤 동시 본문 변경을 commit하고 고정+본문 요청을 재개하는 실제 JPA race test에서
|
||||
최신 본문·revision·language와 fixed 상태를 확인한다. test는 production 소유권 조건 `PESSIMISTIC_WRITE` query를 사용한다.
|
||||
- 범위 밖: P2-T2/P3, API schema, `@DynamicUpdate`/`@Version`, dependency, Docker·HTTP·실제 MySQL 검증.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostConcurrencyTest.kt` |
|
||||
| 수정 | `docs/20260910_크리에이터커뮤니티게시물본문번역/plan-task.md` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityRepository.kt` |
|
||||
|
||||
- [x] **RED:** stale admin read 뒤 별도 transaction의 본문/개정/언어 변경을 commit하고, 재개한 fixed+content 요청이 최신 상태에서 적용돼야 한다는 실제 JPA race test를 작성한다.
|
||||
- [x] **RED 확인:** 현재 구현으로 최신 state 보존 assertion이 실패하는지 확인한다.
|
||||
- [x] **GREEN:** `updateCommunityPostFixed`가 소유권 조건 post lock과 refresh를 통해 최신 행을 얻은 뒤에만 고정 상태를 변경하도록 최소 수정한다.
|
||||
관리자 흐름의 member→post lock 순서는 유지한다.
|
||||
- [x] **GREEN 확인:** production repository lock query를 사용하는 test에서 두 번째 요청의 pre-lock/lock 도착을 latch로 제어하고 executor 종료까지 확인한다.
|
||||
- [x] **REFACTOR:** P2 focused, P1 번역/감지 회귀, ktlint, diff check 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostCreateTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostUpdateTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostConcurrencyTest'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
#### P2-R2 검증 기록 — 2026-09-10
|
||||
|
||||
- RED: `shouldApplyFixedAndContentUpdateAfterConcurrentContentCommit`는 admin facade의 활성 post read 뒤 첫 요청을 멈추고,
|
||||
별도 worker의 실제 `CreatorCommunityService.modifyCommunityPost`가 production `findByIdAndMemberIdForUpdate` 경로로
|
||||
`after concurrent` 본문·revision 1·language NULL을 commit한 뒤 mixed `isFixed=true`/`after fixed` 요청을 재개했다.
|
||||
현재 구현에서 `contentRevision` 2 assertion은 실제 1로 실패했다.
|
||||
- GREEN: `updateCommunityPostFixed`의 비잠금 조회를 소유권 조건 `findByIdAndMemberIdForUpdate`와
|
||||
`EntityManager.refresh(..., PESSIMISTIC_WRITE)`로 교체했다. facade의 기존 member lock 뒤 post lock 순서는 바꾸지 않았다.
|
||||
- 동시성 test: 기존 본문 race의 test-only JPQL lock 대체를 제거하고 두 worker start gate와 실제 production repository query를 사용한다.
|
||||
새 race는 concurrent content worker가 실제 lock 호출 직전임을 latch로 알리고 commit 완료 뒤 mixed request를 재개한다.
|
||||
모든 executor는 release 뒤 `shutdown`과 `awaitTermination`으로 종료를 확인한다.
|
||||
- focused: P2 명령에 `--rerun-tasks`를 추가해 재실행 — 67 tests, exit 0, `BUILD SUCCESSFUL`.
|
||||
- P1 회귀: P1 명령에 `--rerun-tasks`를 추가해 재실행 — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 형식: `./gradlew ktlintCheck --rerun-tasks` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- diff: `GIT_MASTER=1 git diff --check` — exit 0.
|
||||
- 범위: P2-T2/P3, API schema, `@DynamicUpdate`/`@Version`, dependency, Docker, HTTP, 실제 MySQL 검증은 변경하거나 실행하지 않았다.
|
||||
|
||||
- [x] **Task 2.2: 레거시·v2 상세의 저장 번역 표시와 누락 예약**
|
||||
|
||||
**Goal ID / objective:** `P2-T2` — 상세에서 원문을 즉시 제공하고 요청 언어의 누락 번역만 예약한다.
|
||||
|
||||
- 시작 조건: `P2-T1` 완료, PRD §5 상세 계약 확인.
|
||||
- 완료 증거: 두 상세 API의 기존 응답 구조·언어 처리·권한·별도 쓰기 트랜잭션·비동기 폴백 테스트 통과.
|
||||
- 범위 밖: 목록 연결, 댓글 API에서 번역 예약, 번역 상태/원문 필드 추가.
|
||||
- 소비 인터페이스: 권한 확인 후 `findDisplayContents`를 조회하고, 유효 번역이 없을 때만
|
||||
`requestTranslations(postId, langContext.lang.code)`를 호출한다.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/SelectCommunityPostResponse.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicy.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt` |
|
||||
| 생성 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityTranslationEndToEndTest.kt` |
|
||||
|
||||
- [x] **RED:** `Accept-Language: ja`에서 기존 언어 미확정 글의 최초 상세는 원문, 감지 후 일본어만 예약,
|
||||
완료 후 일본어 제공을 테스트한다. 같은 언어·반복 상세·차단·비활성·미구매·댓글 API도 포함한다.
|
||||
- [x] **RED 확인:** 아래 focused 명령에서 저장 번역 미사용/누락 예약 부재를 확인한다.
|
||||
- [x] **GREEN:** 기존 권한 확인 뒤 상세에서 유효 번역이 없을 때만 공유 쓰기 진입점을 호출한다. 읽기 전용 부모 트랜잭션과 분리하고
|
||||
번역 선택은 기존 마스킹 전에 DTO/레코드 `copy(content = ...)`로 반영한다. 원문 엔티티는 조회 중 변경하지 않는다.
|
||||
- [x] **GREEN 확인:** 실제 HTTP 계약 테스트와 중복 상세 경합에서 정상 응답·중복 작업 방지·원문 즉시 응답을 확인한다.
|
||||
- [x] **REFACTOR:** 같은 내부 조회를 사용하는 댓글/답글이 예약 진입점을 호출하지 않는지 회귀 검사한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityTranslationEndToEndTest' --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest'
|
||||
```
|
||||
|
||||
#### P2-T2 검증 기록 — 2026-09-10
|
||||
|
||||
- RED: 상세 경로에 번역 선택과 예약 연결이 없던 상태에서 legacy/v2 focused test의 저장 번역 미사용과 누락 예약 assertion 실패를 확인했다.
|
||||
컴파일 오류는 RED 증거로 사용하지 않았으며, 새 통합 test의 `viewerId` named argument를 실제 `memberId` 계약에 맞춰 바로잡았다.
|
||||
- GREEN: `CreatorCommunityService`와 `CreatorChannelCommunityQueryService`는 권한/차단 확인 뒤
|
||||
`findDisplayContents(...)[postId] ?: content`를 선택하고 `requestTranslations(postId, 요청 언어)`를 호출한다.
|
||||
선택한 본문은 기존 유료 마스킹 전 DTO/record `copy(content = ...)`에만 반영하며, 엔티티 원문은 변경하지 않는다.
|
||||
- 통합: `CreatorCommunityTranslationEndToEndTest`는 저장된 번역의 유료 마스킹 전 선택과, 언어 NULL 원문의 즉시 폴백 및
|
||||
`targetLanguage=ja` 단일 감지 이벤트를 실제 JPA 경계에서 확인했다.
|
||||
- HTTP 계약: legacy/v2 detail test는 번역 존재/누락, 구매자·소유자의 전체 본문, 미구매자의 기존 마스킹,
|
||||
차단·비활성 경로, 댓글·답글의 예약 비진입을 확인했다. 같은 언어 예약 없음과 동시 예약의 유일성은 기존
|
||||
`CreatorCommunityTranslationServiceTest`/통합 test로 회귀했다.
|
||||
- focused: 위 명령 — exit 0, `BUILD SUCCESSFUL`.
|
||||
- P1 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceIntegrationTest'`
|
||||
— exit 0, `BUILD SUCCESSFUL`.
|
||||
- 호출자 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest'`
|
||||
— exit 0, `BUILD SUCCESSFUL`.
|
||||
|
||||
#### Task 2.R: P2-T2 실제 상세 파이프라인 증거 보강
|
||||
|
||||
**Goal ID / objective:** `P2-R3` — 실제 Spring 프록시 상세 조회가 감지, 단일 작업 예약, 재료화와 유효 번역 재사용을 올바르게 연결한다.
|
||||
|
||||
- 시작 조건: `P2-T2` 완료, 상세 경로의 실제 transaction 증거 보강 요구 확인.
|
||||
- 완료 증거: 원문 즉시 반환, 감지 후 요청 언어 하나의 job, 반복·동시 상세의 단일 job, memory 재료화 후 번역 반환,
|
||||
유효 번역 상세의 예약 미호출을 확인한다.
|
||||
- 범위 밖: 목록/홈, 외부 감지·번역 API, Docker, live HTTP, 실제 MySQL 검증.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` |
|
||||
| 확인 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` |
|
||||
| 확인 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt` |
|
||||
|
||||
- [x] **RED:** 병렬 상세 응답 본문 검증은 `ExecutorService.submit { ... }`가 `Runnable` 오버로드를 선택해
|
||||
`Future.get()`이 `null`을 반환하며 실패하는 것을 확인한다. 이는 production 동작이 아닌 수용 테스트의 반환값 폐기 문제다.
|
||||
- [x] **GREEN:** `Callable`을 명시해 두 병렬 상세의 실제 원문 반환과 translation job 1개를 검증한다.
|
||||
- [x] **GREEN 확인:** 실제 `CreatorCommunityService`, `LanguageDetectListener`, `ResourceTranslationJobScheduler`,
|
||||
`TranslationReadModelMaterializer` 경로에서 원문 즉시 반환, `ko` job 하나, memory 재료화 후 번역 반환을 확인한다.
|
||||
legacy/v2 unit test는 유효 번역의 문자열이 원문과 같아도 `requestTranslations`를 호출하지 않음을 확인한다.
|
||||
- [x] **REFACTOR:** P2 focused, P1 번역/감지 회귀, Phase 2 Gate를 실행한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityTranslationEndToEndTest' --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceIntegrationTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*'
|
||||
```
|
||||
|
||||
#### P2-R3 검증 기록 — 2026-09-10
|
||||
|
||||
- RED: 병렬 lambda의 반환값을 확인하는 assertion은 `expected: [detail source content, detail source content] but was: [null, null]`로 실패했다.
|
||||
`submit`의 `Runnable` 오버로드가 반환값을 폐기한 것이 원인이었고, production 상세 응답이나 DB 본문 손실은 아니었다.
|
||||
- GREEN: `Callable` 명시 뒤 실제 두 상세 응답은 원문을 반환하고, 감지 완료 후 `ko` translation job은 하나만 유지됐다.
|
||||
수동으로 저장한 translation memory를 실제 materializer가 적용한 뒤 상세는 번역문을 반환했다.
|
||||
- 유효 저장 번역: legacy/v2 상세 unit test는 `CreatorCommunityDisplayContent.hasValidTranslation`으로 판정하므로,
|
||||
번역문이 원문과 같아도 `requestTranslations`를 호출하지 않는다. 실제 상세 파이프라인 test는 재료화 뒤 번역문을 반환하고
|
||||
job 수가 증가하지 않음을 확인했다.
|
||||
- focused: 첫 번째 명령 — exit 0, `BUILD SUCCESSFUL` (41초).
|
||||
- P1 회귀: 두 번째 명령 — exit 0, `BUILD SUCCESSFUL` (26초).
|
||||
- Phase 2 Gate: 세 번째 명령 — exit 0, `BUILD SUCCESSFUL` (1분 30초).
|
||||
- latch RED: `CreatorCommunityLanguageDetectTest.shouldNotCompleteNextTaskWhenPreviousTaskFinishesAfterLatchIsReassigned`에서 A를 멈춘 뒤 B가 다음 task의 latch를 준비하게 했다.
|
||||
`./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest.shouldNotCompleteNextTaskWhenPreviousTaskFinishesAfterLatchIsReassigned'`는
|
||||
exit 1, `expected: <false> but was: <true>`로 실패했다. A 종료가 `completion.get()`으로 B의 latch를 감소시킨 것이 원인이었다.
|
||||
- latch GREEN: decorator 생성 시점의 `taskCompletion`을 캡처해 해당 task 종료에만 사용하도록 했고, latch regression과 상세 파이프라인 detector는 `finally`에서 release하도록 정리했다.
|
||||
`./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` — exit 0, `BUILD SUCCESSFUL` (20초).
|
||||
- P2 재회귀: 첫 번째 명령 — exit 0, `BUILD SUCCESSFUL` (44초).
|
||||
- 형식: `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL` (17초).
|
||||
|
||||
#### Phase 2 Gate
|
||||
|
||||
- [x] **Goal `P2-GATE`: 생성·수정·기존 글 상세 흐름 판정**
|
||||
|
||||
- 시작 조건: Phase 2 두 Task 완료.
|
||||
- 완료 증거: 생성→커밋→감지→번역 저장과 기존 글 상세→요청 언어만 예약의 실제 통합 결과.
|
||||
- 범위 밖: 목록/홈 구현. TDD 예외 사유: 앞선 Task의 사용자 흐름 검증 Gate다.
|
||||
- 대체 검증: 테스트 HTTP 요청과 실제 커밋 후 DB 상태를 대조하고 롤백 케이스에 작업이 없는지 확인한다.
|
||||
- 확인 파일: Phase 2의 두 EndToEndTest, 서비스 테스트, PRD §4~5.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*'
|
||||
```
|
||||
|
||||
#### P2-GATE 검증 기록 — 2026-09-10
|
||||
|
||||
- gate: 위 명령 — exit 0, `BUILD SUCCESSFUL`. 생성·수정·기존 글 상세, 레거시 서비스/JPA 및 v2 MockMvc 계약, 관리자 공용 경로를 함께 확인했다.
|
||||
- 형식: `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- diff: `GIT_MASTER=1 git diff --check` — exit 0.
|
||||
- 범위: local automatic 정책에 따라 Docker, live server/curl, 실제 Papago, 실제 MySQL DDL·동시성은 실행하지 않았다.
|
||||
HTTP 동작은 자동 MockMvc 통합 테스트로만 검증했다.
|
||||
|
||||
### Phase 3: 목록·홈·미리보기 번역과 최종 검증
|
||||
|
||||
#### 구현 항목
|
||||
|
||||
- [x] **Task 3.1: 레거시 목록과 v2 커뮤니티·채널 홈에 저장 번역 적용**
|
||||
|
||||
**Goal ID / objective:** `P3-T1` — 커뮤니티 목록과 채널 홈이 작업 예약 없이 저장된 번역을 제공한다.
|
||||
|
||||
- 시작 조건: `P2-GATE` 완료.
|
||||
- 완료 증거: 언어별 목록·최신 목록·고정/일반 채널 홈, 유료 마스킹, 추가 N+1 없음, 감지/작업 증가 0건.
|
||||
- 범위 밖: 추천/팔로잉 소식, 정렬·페이지·고정 정책 변경.
|
||||
- 소비 인터페이스: `findDisplayContents`만 호출. 레거시 DTO와 v2 레코드를 표시 직전에 복사해 기존 마스킹을 재사용한다.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityTranslationEndToEndTest.kt` |
|
||||
|
||||
- [x] **RED:** 번역 존재/누락·이전 개정 혼합 페이지와 무료·유료·소유자·구매자 응답을 테스트한다.
|
||||
이모지 포함 유료 본문에서 기존 코드포인트 기준 미리보기를 확인한다.
|
||||
- [x] **RED 확인:** 아래 명령으로 번역 미사용 assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** 페이지별 번역 일괄 조회 후 기존 마스킹 전에 반영한다. 채널 홈은 이미 호출하는
|
||||
`findHomeCommunityPosts` 공통 경로를 통해 고정/일반 게시물을 모두 처리한다.
|
||||
- [x] **GREEN 확인:** 목록만 호출했을 때 감지 이벤트/번역 작업 생성 0건, 페이지·개수·순서 유지,
|
||||
페이지 크기에 비례한 추가 번역 SELECT 증가 없음까지 확인한다.
|
||||
- [x] **REFACTOR:** 기존 권한·홈 응답 회귀와 focused test 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest'
|
||||
```
|
||||
|
||||
##### 실행·검증 기록 — 2026-09-10
|
||||
|
||||
- 무엇을: 레거시 목록·최신 목록, v2 커뮤니티 탭, 채널 홈의 고정/일반 게시물에 저장된 표시 번역을 한 번에 조회해 본문에 복사했다.
|
||||
상세 경로의 작업 예약 정책은 변경하지 않았고, 목록/홈에서는 `findDisplayContents`만 호출한다.
|
||||
- 왜: 목록 응답은 번역 누락 시 원문으로 안전하게 폴백하면서도 감지·번역 작업을 만들지 않아야 한다.
|
||||
- RED 확인: 새 테스트 6개를 대상으로 `./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest.shouldUseStoredTranslationsOnceForLegacyCommunityListBeforePaidMasking' --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest.shouldUseStoredTranslationsForVisibleLatestCommunityPostsWithoutScheduling' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest.shouldUseStoredTranslationsOnceForCommunityTabBeforePaidMasking' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest.shouldUseStoredTranslationsForPinnedAndNormalHomeCommunityPostsWithoutScheduling' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest.shouldUseStoredTranslationsForCommunityTabWithoutScheduling' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest.shouldUseStoredTranslationsForPinnedAndNormalHomeCommunityPosts'`를 실행했다.
|
||||
exit 1의 기대된 assertion 실패로 번역 적용 전 원문이 반환됨을 확인했다.
|
||||
- GREEN/영향 범위 회귀: 위 focused 명령 — exit 0, `BUILD SUCCESSFUL` (1분 3초). 저장 번역·원문 폴백·이전 개정 무시,
|
||||
유료 미구매자/구매자/소유자 마스킹, 차단 필터 후 일괄 조회, 목록/홈 호출의 감지·번역 작업 미생성을 단위·MockMvc 통합 테스트로 확인했다.
|
||||
- 형식: `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL` (45초).
|
||||
- 전체 회귀와 수동 HTTP: 공통 번역 API 또는 모든 Phase를 변경하지 않았으므로 직접 영향받은 package/API 회귀까지만 실행했다.
|
||||
운영과 분리된 DB·Redis·S3·Papago 및 테스트 계정이 준비되지 않아 live server/curl·실제 Papago 검증은 실행하지 않았고,
|
||||
HTTP 응답은 MockMvc 통합 테스트로 검증했다.
|
||||
- 추가 실제 통합 증거: `CreatorCommunityTranslationEndToEndTest`는 실제 `CreatorCommunityTranslationService`와 H2 저장소를 사용한다.
|
||||
영속성 컨텍스트와 Hibernate 통계를 매 배치 전 초기화한 뒤 1개와 4개 ID를 각각 읽어, 게시글 `IN` 조회 1회와
|
||||
`creator_community_translation` 조회 1회의 합계인 prepared statement 2회를 동일하게 확인했다.
|
||||
따라서 페이지 크기는 번역 읽기 SELECT 수를 늘리지 않는다.
|
||||
- 추가 부작용 증거: 같은 실제 읽기 서비스를 레거시 목록의 번역 누락 게시물에 연결하고, 실제 `translation_job` 행 수의 전후 동일성과
|
||||
`ApplicationEventPublisher`·재료화기·resource scheduler의 무상호작용을 확인했다. 읽기 서비스 자체는 mock으로 대체하지 않았다.
|
||||
v2 탭의 mock 경계 테스트는 별도로 반환 50개 ID만 전달하고 51번째 `fetchLimit + 1` ID를 제외함을 확인한다.
|
||||
- TDD 예외: 이번 추가는 이미 올바른 production 동작을 직접 측정하는 증거 테스트뿐이므로 RED assertion을 만들 수 없었다.
|
||||
새 테스트는 즉시 통과했으며 production P3-T1 파일은 변경하지 않았다.
|
||||
- 재검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest'` — exit 0, `BUILD SUCCESSFUL` (1분 18초).
|
||||
`./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL` (17초).
|
||||
|
||||
- [x] **Task 3.2: 홈 추천과 팔로잉 소식 본문에 저장 번역 적용**
|
||||
|
||||
**Goal ID / objective:** `P3-T2` — 홈 추천·팔로잉 소식의 커뮤니티 본문과 파생 미리보기가 요청 언어로 표시된다.
|
||||
|
||||
- 시작 조건: `P3-T1` 완료.
|
||||
- 완료 증거: 두 API의 번역/원문 폴백·일괄 조회·작업 미생성·다른 소식 유형 불변 테스트.
|
||||
- 범위 밖: 추천 스냅샷/순위 변경, FCM·알림 이력·오디오 소식 번역.
|
||||
- 소비 인터페이스: `LangContext`의 언어와 `findDisplayContents`. 추천 상세 레코드/팔로잉 도메인의 커뮤니티 항목만 복사한다.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt` |
|
||||
|
||||
- [x] **RED:** 일본어·영어 요청의 인기 커뮤니티/팔로잉 소식, 누락·이전 개정 폴백,
|
||||
비커뮤니티 항목 유지, 조회만으로 작업이 늘지 않는 테스트를 작성한다.
|
||||
- [x] **RED 확인:** 아래 focused 명령으로 해당 응답이 원문에 머무르는 assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** 이미 필터링된 게시물 ID를 모아 공유 읽기 메서드로 처리한다. 팔로잉 서비스에 기존 `LangContext`를 전달한다.
|
||||
본문은 DB 게시물의 현재 내용에서 가져오므로 과거 소식 발행 문자열을 번역 소스로 쓰지 않는다.
|
||||
- [x] **GREEN 확인:** 언어 전환·익명 허용 추천·인증된 팔로잉 흐름을 검증하고 기존 필터/추천 순서를 대조한다.
|
||||
- [x] **REFACTOR:** 변경된 생성자 호출 테스트를 갱신하고 두 기능의 직접 영향 회귀 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest' --tests 'kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest' --tests 'kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest'
|
||||
```
|
||||
|
||||
##### 실행·검증 기록 — 2026-09-10
|
||||
|
||||
- 무엇을: 인기 커뮤니티 추천의 최종 노출 게시물과 팔로잉 최근 소식의 커뮤니티 게시물만 요청 언어의 저장 본문으로 복사했다.
|
||||
추천 중복 제거·limit와 팔로잉 필터·순서·다른 소식 유형은 변경하지 않았다.
|
||||
- RED 확인: 서비스·API 테스트 4개가 번역 연결 전 원문 본문 assertion으로 실패함을 확인했다.
|
||||
- GREEN/회귀: 관련 5개 테스트 클래스에 `--rerun-tasks`를 적용해 exit 0, `BUILD SUCCESSFUL` (2분 38초)을 확인했다.
|
||||
일본어 비회원 추천, 영어 인증 팔로잉, 현재 원문 폴백, 최종 ID 단일 배치, 비커뮤니티 불변을 검증했다.
|
||||
- 리뷰: 명세 리뷰 `APPROVED` (`ses_f7757315dffeoDl3Ef2bhCO684`), 품질 리뷰 `APPROVED` (`ses_f774b8bf4ffehbjDvQWo6yE84S`).
|
||||
- 형식/diff: `./gradlew ktlintCheck --rerun-tasks` — exit 0, `BUILD SUCCESSFUL` (26초).
|
||||
`GIT_MASTER=1 git diff --check` — exit 0.
|
||||
- 범위: Docker, live HTTP, 실제 Papago/MySQL은 실행하지 않았으며 테스트 브랜치 배포 후 수동 체크리스트에서 확인한다.
|
||||
|
||||
#### Task P3-R1: DDL 해시 컬럼 타입 정합화
|
||||
|
||||
- [x] **Goal `P3-R1`: `source_hash` DDL을 Hibernate 엔티티 매핑과 일치시킨다**
|
||||
|
||||
- 시작 조건: `P3-GATE` 코드 품질 리뷰에서 `CHAR(64)`와 `VARCHAR(64)` 불일치가 차단 이슈로 확정됨.
|
||||
- 완료 증거: `schema.sql`의 `source_hash VARCHAR(64)` 확인, `./gradlew tasks --all`, `git diff --check`, 해당 delta 코드 품질 재검토 통과.
|
||||
- 범위 밖: 엔티티 매핑 변경, 실제 MySQL DDL 적용, 비차단 로그·테스트 종료 개선.
|
||||
- RED: `rg -n 'source_hash VARCHAR\(64\)' schema.sql`이 일치 항목 없이 실패하는지 확인한다.
|
||||
- GREEN: DDL 한 줄만 `VARCHAR(64)`로 변경하고 동일 명령이 성공하는지 확인한다.
|
||||
- REFACTOR: 추가 구조 변경 없이 문서 상태와 Gate 증거만 동기화한다.
|
||||
- 수정 파일: `docs/20260910_크리에이터커뮤니티게시물본문번역/schema.sql`, `prd.md`, `plan-task.md`.
|
||||
|
||||
##### 실행·검증 기록 — 2026-09-10
|
||||
|
||||
- RED: `rg -n 'source_hash VARCHAR\(64\)' schema.sql` — 일치 항목 없이 exit 1.
|
||||
- GREEN: `source_hash`를 엔티티의 `String` + `@Column(length = 64)`와 같은 `VARCHAR(64)`로 변경했고 동일 `rg`가 11행을 반환했다.
|
||||
- 회귀: `./gradlew tasks --all` — exit 0, `BUILD SUCCESSFUL`; `GIT_MASTER=1 git diff --check` — exit 0.
|
||||
- delta 품질 재검토: `PASS` (`ses_f772f064cffeKa532hsf1Pa3GU`). 실제 MySQL 적용과 `ddl-auto: validate` 기동 확인은 수동 검증에 유지한다.
|
||||
|
||||
#### Phase 3 Gate
|
||||
|
||||
- [x] **Goal `P3-GATE`: 전체 노출 표면과 비동기 정합성 최종 판정**
|
||||
|
||||
- 시작 조건: `P3-T1`, `P3-T2` 및 이전 Gate의 증거 완료.
|
||||
- 완료 증거: 아래 자동 검증, PRD `CCT-001~010` 대조 및 5개 리뷰 레인 통과. HTTP·실제 MySQL/Papago는 배포 후 수동 체크리스트로 분리한다.
|
||||
- 범위 밖: 테스트 삭제/완화, 운영 데이터 번역/배포, 다른 기능의 결함 수정.
|
||||
- TDD 예외 사유: 기능 추가가 아닌 전체 수용 기준 검증 Gate다.
|
||||
- 대체 검증: 자동 회귀, 격리된 실행 서버의 HTTP 응답/DB 작업 수 대조, 문서와 실제 동작 대조.
|
||||
- 확인 파일: 모든 Task의 변경 파일, `prd.md`, `plan-task.md`, `schema.sql`.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.recommendation.*' --tests 'kr.co.vividnext.sodalive.v2.home.following.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*'
|
||||
./gradlew ktlintCheck
|
||||
./gradlew bootJar
|
||||
```
|
||||
|
||||
기본 최종 검증은 위 직접 영향 범위다. 이번 변경이 공통 번역 API의 기존 리소스 동작까지 바꾸거나,
|
||||
위 범위로 판정할 수 없는 실패가 발견되거나, 사용자/릴리스 기준이 전체 검증을 요구하면 `./gradlew test`를 추가한다.
|
||||
전체 테스트 생략 시 공통 번역 및 영향받은 각 기능을 위 명령으로 검증했다는 근거와 실제 결과를 기록한다.
|
||||
|
||||
##### 최종 Gate 기록 — 2026-09-10
|
||||
|
||||
- 직접 영향 테스트 — clean 후 `--no-parallel` 순차 실행, exit 0, `BUILD SUCCESSFUL` (5분 20초). 53개 클래스 493개 테스트, 실패·오류·skip 0을 XML에서 확인했다.
|
||||
- `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL` (20초). `./gradlew bootJar` — exit 0, `BUILD SUCCESSFUL` (1분 31초).
|
||||
- `GIT_MASTER=1 git diff --check` — exit 0. 최초 병렬 Gradle의 kapt 출력 경합은 clean 후 순차 성공으로 대체했다.
|
||||
- 최종 리뷰: 목표·제약 PASS, 자동 QA PASS, 보안 PASS, 문맥 PASS. 코드 품질의 DDL 타입 차단 이슈는 `P3-R1`로 수정 후 delta 재검토 PASS.
|
||||
- 비차단 관찰: 번역 예약 실패 로그 분류, 일부 경합 테스트 executor 종료 확인, 언어 감지 완료 전 반복 상세 요청의 호출 증폭은 이번 범위에서 변경하지 않았다.
|
||||
- 간접 소비자: `LiveApiService`, `ExplorerService`도 기존 목록 서비스를 통해 저장 번역을 사용하며 추가 예약은 하지 않는다.
|
||||
- 로컬 자동 Gate는 완료했다. 실제 DDL·MySQL 격리 수준·HTTP·Papago 검증은 아래 테스트 서버 수동 체크리스트에서 미완료로 유지한다.
|
||||
|
||||
### 테스트 브랜치 배포 후 수동 체크리스트
|
||||
|
||||
수동 HTTP 검증은 운영과 분리된 DB·Redis·S3/Papago 설정 및 테스트 계정이 준비된 테스트 서버에서 실행한다.
|
||||
기존 `./gradlew bootRun`을 사용하며 토큰·키·본문 전문은 기록물에서 제거한다.
|
||||
|
||||
1. `schema.sql`을 테스트 DB에 적용한다. 기존 행은 `language_code` NULL, `content_revision` 0, 원문이 유지되고,
|
||||
`creator_community_id + locale` 유일 키가 생성됐는지 확인한다.
|
||||
2. 같은 게시물·locale의 번역 요청을 동시에 보낸다. duplicate-key 오류나 API 실패 없이, 현재 원문과 일치하는 번역 행이 정확히 1개인지 확인한다.
|
||||
3. 언어 NULL인 기존 무료 게시물로 PRD §5의 모든 목록/홈 API를 조회한다. 원문 표시, 감지/번역 작업 증가 0건을 확인한다.
|
||||
4. 같은 글을 `Accept-Language: ja`로 상세 조회한다. 최초 원문 응답, 감지 후 일본어 작업만 예약,
|
||||
워커 완료 후 상세/목록/채널 홈/추천/팔로잉에서 저장 번역 사용을 확인한다.
|
||||
5. 한·영·일 원문의 신규 게시물과 실제 본문 수정을 실행한다. 다른 두 언어 생성,
|
||||
수정 직후 현재 원문 폴백, 지연된 이전 감지/번역 결과 미노출, A→B→A 메모리 복원을 확인한다.
|
||||
6. 유료 글을 미구매자/구매자/소유자로 조회하고 번역 선택 후 기존 마스킹이 적용되는지 확인한다.
|
||||
차단·성인·비활성·댓글/답글 노출과 미디어 URL 규칙은 기존 결과와 대조한다.
|
||||
7. 감지/번역 provider 실패와 반복 상세 요청을 재현한다. 원문 응답, 기존 재시도 정책, 작업 중복 방지,
|
||||
페이지 크기 증가 시 추가 번역 조회 수가 선형으로 늘지 않음을 확인한다.
|
||||
|
||||
#### P1-R4 실제 MySQL 수동 확인 · 2026-09-10 추가
|
||||
|
||||
- [ ] 테스트 서버가 실제 MySQL 8/InnoDB인지 확인하고 버전·테이블 엔진·세션 격리 수준을 기록한다.
|
||||
감지 캐시의 해시/provider/normalizationVersion 유일 키와 audit 컬럼의 타입·NULL/default 조건을 확인한다.
|
||||
native INSERT 후 생성/수정 시각이 채워지고 중복 upsert가 승자 감지값과 생성 시각을 덮어쓰지 않는지 대조한다.
|
||||
- [ ] 언어 NULL이고 해당 캐시 키가 없는 동일 한국어 게시물에 `ja`/`en` 상세 요청을 보낸다.
|
||||
테스트용 provider 제어로 두 miss와 provider 진입을 확인한 뒤 첫 감지 저장·커밋·`ja` 예약을 완료하고
|
||||
후발 감지를 해제한다. 실제 `REPEATABLE READ`에서 예외/rollback 없이 두 흐름이 끝나고 캐시 1행,
|
||||
`ja`/`en` 작업이 모두 남는지 확인한다. 단순 동시 HTTP 요청만으로 이 실행 순서를 증명했다고 보지 않는다.
|
||||
- [ ] 서로 다른 게시물을 동일 본문으로 생성하고 캐시 miss 두 건의 저장 순서를 같은 방식으로 제어한다.
|
||||
공통 캐시 1행과 승자 결과 재사용, 각 게시물의 원문 외 두 언어 작업 보존을 확인한다.
|
||||
- [ ] provider 감지 응답을 차단한 동안 별도 연결에서 해당 게시물 본문 수정을 커밋할 수 있는지 확인한다.
|
||||
수정 후 개정 증가·언어 초기화와 현재 원문 폴백을 확인하고 이전 감지를 해제한다. 오래된 결과가
|
||||
새 본문·개정·언어·번역을 덮어쓰거나 이전 원문의 작업을 새 개정에 적용하지 않는지 확인한다.
|
||||
|
||||
필요한 테스트 환경이 없으면 수동 검증은 미완료로 기록한다. MockMvc/provider 대체 테스트를 실제 Papago 검증으로 표기하지 않는다.
|
||||
실제 Papago 확인은 비민감한 테스트 문장을 사용하며 수동 검증 환경에서만 수행한다.
|
||||
|
||||
## Progress와 검증 기록
|
||||
|
||||
### 문서 작성 — 2026-09-10
|
||||
|
||||
- 상태: PRD·구현 계획 작성. 구현 Task/Gate는 모두 미실행.
|
||||
- 무엇을: 사용자 인터뷰 결정과 레거시/v2 생성·상세·목록·홈 경로를 이 계획의 6개 Task에 연결했다.
|
||||
- 왜: 구현 전 모호한 범위를 없애고 정확한 변경 위치·수용 기준·TDD 순서를 제공하기 위해서다.
|
||||
- 실행 확인: `./gradlew tasks --all` — exit 0, `BUILD SUCCESSFUL`. `test`, `bootRun`, `bootJar`, `ktlintCheck` task를 확인했다.
|
||||
- 관찰: 기존 Gradle 설정에서 Gradle 9.0 비호환 예정 deprecation 안내가 출력됐다. 이번 문서 범위에서 변경하지 않는다.
|
||||
- 미실행: 코드 구현, 신규 테스트 작성/실행, 서버 실행, Papago 호출, DDL 작성/적용, 운영 작업.
|
||||
- 테스트 생략 근거: 제품 코드를 변경하지 않는 문서 작업이다. 미래 테스트 명령을 계획에 적은 것은 테스트 통과 증거가 아니다.
|
||||
- 남은 실행 조건: 사용자의 별도 구현 요청과 각 Task의 검증 환경 확보.
|
||||
|
||||
### 문서 검증 — 2026-09-10
|
||||
|
||||
- `./gradlew tasks --all` — 문서 생성 후 재실행, exit 0, `BUILD SUCCESSFUL`.
|
||||
- 문서 경로·링크·템플릿 잔여값·공백 검사 — exit 0, `Document validation: PASS`.
|
||||
기존 파일 참조가 존재하며 미래 생성 파일 7개는 생성 항목으로 구분했다.
|
||||
- 요구사항/계획 대조 — 요구사항 10개, 구현 Task 6개, Gate 3개가 연결되어 있고 구현 완료 체크는 없다.
|
||||
- `git diff --check` — exit 0. 신규 미추적 문서의 공백은 별도 문서 검사로 확인했다.
|
||||
- `git -c core.quotePath=false status --short --untracked-files=all` — 이 작업 폴더의 `prd.md`, `plan-task.md` 두 파일만 추가됨을 확인했다.
|
||||
|
||||
### P2-R3 최종 검증 — 2026-09-10
|
||||
|
||||
- `./gradlew tasks --all` — exit 0, `BUILD SUCCESSFUL`; `test`, `ktlintCheck`, `build` task가 유효함을 확인했다.
|
||||
- `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`. P2 변경 테스트의 import 순서와 `Callable` 호출 줄바꿈을 정정했다.
|
||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest.shouldRunDetailTranslationPipelineThroughProxyListenerAndScheduler'`
|
||||
— exit 0, `BUILD SUCCESSFUL`. 실제 Spring 상세 파이프라인에서 원문 즉시 반환, 단일 job, 재료화 후 번역 반환을 확인했다.
|
||||
- `./gradlew build` — exit 0, `BUILD SUCCESSFUL` (8분 16초). 컴파일, 전체 테스트, ktlint, 패키징을 포함한다.
|
||||
- `GIT_MASTER=1 git diff --check` — exit 0. Docker, live HTTP, 실제 Papago, 실제 MySQL 검증은 실행하지 않았다.
|
||||
|
||||
### Phase별 후속 정적 리뷰 — 2026-09-10
|
||||
|
||||
- 기준: 인터뷰를 반영한 `prd.md`와 이 계획, 현재 source/test/DDL. HEAD와 작업 트리 지문은
|
||||
[리뷰 증거](reviews/review-evidence.md)에 기록했다.
|
||||
- Phase 1: 감지 캐시 최초 INSERT 경합으로 후발 target 예약이 누락되는 정적 경로 1건 확정.
|
||||
`REV-P1-007`을 신규 `P1-R4`로 추가했다. 기존 완료 기록과 이전 리뷰는 유지했다.
|
||||
- Phase 2: 생성·실제 본문 수정·상세 단일 언어 예약·권한/마스킹 기준 충족. 자체 추가 수정 사항 없음.
|
||||
Phase 1 결함의 상세 영향은 `P1-R4`에서 처리한다.
|
||||
- Phase 3: 목록·채널 홈·추천·팔로잉 저장 번역 및 비예약 기준 충족. 신규 Task 없음.
|
||||
- 실행 제약: 사용자 요청대로 테스트·컴파일·서버·HTTP·Papago·DB 재현은 실행하지 않았다.
|
||||
기존 XML 성공 기록은 과거 실행 증거로만 읽었다.
|
||||
- 검증 한계: 실제 캐시 저장 경합은 새 Task의 RED로 재현할 예정이다. 한·영·일 전체 생성→워커 통합 검증과
|
||||
실제 MySQL/Papago/HTTP는 기존 테스트 서버 수동 확인 범위로 남겼다. 구성요소 테스트 통과를 실환경 전체 흐름 통과로 확대하지 않는다.
|
||||
- 문서 검증: `./gradlew tasks --all` — exit 0(task 목록 조회만 수행, 테스트·컴파일 미실행).
|
||||
`git diff --check`와 문서 링크/공백 검사 통과. 리뷰 전후 source/test/DDL 32개 파일 지문이 같음을 확인했다.
|
||||
|
||||
### P1-R4 문서 동기화 · 2026-09-10
|
||||
|
||||
- 완료된 RED/GREEN·P1/P2 영향 회귀·spec/quality 리뷰를 P1-R4 실행 기록과 리뷰 문서 두 곳에 누적했다.
|
||||
기존 정적 리뷰 지문과 과거 기록은 당시 증거로 보존하며 실제 MySQL 수동 확인 네 항목은 미완료로 추가했다.
|
||||
- 문서 검증: `./gradlew tasks --all`, exit 0, `BUILD SUCCESSFUL` (2초). `test`, `ktlintCheck` task를 확인했다.
|
||||
`rg`로 P1-R4 Goal 및 RED/GREEN/REFACTOR 완료 체크, 실행 시간·리뷰 출처·실환경 검증 보류 표기를 대조했다.
|
||||
- 이번 작업은 지정된 계획·리뷰 문서 3개만 수정했다. PRD·source·test·schema 변경이나 제품 테스트 재실행은 하지 않았다.
|
||||
|
||||
## 변경 관리
|
||||
|
||||
기술 선택은 PRD `DEC-009~010`, 제품 결정은 `DEC-001~008`을 따른다.
|
||||
후속 요구사항은 PRD 결정 기록 → 요구사항/API 계약 → 이 계획의 파일·검증·체크박스 순서로 갱신한다.
|
||||
미래 구현에서는 각 Task 아래에 실행일·무엇/왜/어떻게·정확한 명령·결과·남은 항목을 누적한다.
|
||||
새 테스트 클래스를 만들 때는 이 문서의 focused 실행 예시가 실제 클래스와 일치하는지도 갱신한다.
|
||||
@@ -0,0 +1,176 @@
|
||||
# 크리에이터 커뮤니티 게시물 본문 번역 PRD
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 구현 및 로컬 자동 검증 완료 · 테스트 서버 수동 검증 대기 |
|
||||
| 작성일 / 최종 수정일 | 2026-09-10 |
|
||||
| 결정권자 | 요청 사용자 |
|
||||
| 산출물 범위 | 코드·자동 테스트·MySQL DDL 작성. 실제 DDL 적용·배포·Papago/HTTP 검증은 테스트 서버에서 수행한다. |
|
||||
| API 계약 | 별도 파일을 만들지 않고 이 문서의 API 계약 절에 통합 |
|
||||
| 기준 템플릿 | `docs/sample/sample-prd.md` |
|
||||
|
||||
## 1. 목표와 현재 동작
|
||||
|
||||
크리에이터 커뮤니티 게시물의 본문을 한국어·영어·일본어로 제공한다. 작성한 원문은 보존하고,
|
||||
원문 이외의 지원 언어 번역을 저장한다. 이용자는 요청 언어에 맞는 본문을 상세·목록·미리보기에서 읽는다.
|
||||
|
||||
현재 코드에서 확인한 사실:
|
||||
|
||||
| 근거 파일 | 확인한 동작 |
|
||||
|---|---|
|
||||
| `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt` | 원문 `content`는 있으나 원문 언어와 번역 저장 구조는 없다. |
|
||||
| `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` | 상세 조회에서 요청 언어의 번역을 조회한다. 번역이 없으면 해당 언어 작업을 예약하고 원문 응답을 유지한다. 원문 언어가 없는 경우에는 이 예약 분기에 들어가지 않는다. |
|
||||
| `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt` | 커밋 후 비동기 언어 감지, 언어 저장, 번역 이벤트 발행 흐름이 있다. |
|
||||
| `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/ResourceTranslationJobScheduler.kt` | 리소스의 원문 언어를 제외한 지원 언어 전체 또는 지정한 한 언어의 작업을 예약한다. |
|
||||
| `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobScheduler.kt` | 원문 언어가 `ko`, `en`, `ja`인 비어 있지 않은 텍스트만 예약한다. 동일 리소스·필드·목표 언어·원문 해시 작업은 중복 생성하지 않는다. |
|
||||
| `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorker.kt` | 저장된 작업을 처리하고 번역 메모리와 조회용 번역을 저장한다. 주기 기본값은 600000ms이며 실제 환경 설정에 따라 달라진다. |
|
||||
|
||||
이번 기능은 오디오 상세의 누락 번역 예약 방식을 따른다. 다만 기존 커뮤니티 게시물은 언어 정보 자체가 없으므로,
|
||||
상세 조회에서 비동기 언어 감지까지 연결해야 한다. 오디오 상세가 현재 언어 미확정 데이터도 감지한다고 가정하지 않는다.
|
||||
|
||||
## 2. 포함 범위와 제외 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- 본문 `content`만 번역하며 무료·유료 게시물에 같은 언어 정책을 적용한다.
|
||||
- 신규 게시물 저장 후 자동 감지·번역, 실제 본문 변경 후 언어 재감지·번역 갱신.
|
||||
- 기존 게시물은 상세 조회를 계기로 필요한 언어를 감지하고 요청 언어의 누락 번역을 예약한다.
|
||||
- 기존 커뮤니티 API와 v2 상세·목록, 채널 홈의 고정/일반 게시물, 홈 추천 인기 커뮤니티, 팔로잉 소식의 본문·미리보기.
|
||||
- 공유 생성·수정 서비스를 사용하는 AI 캐릭터 관리자 작성 경로도 같은 저장 처리를 적용한다.
|
||||
이는 별도 관리자 기능 추가가 아니라 동일 게시물의 생성·수정 누락 방지다.
|
||||
|
||||
### 제외
|
||||
|
||||
- 댓글·답글·첫 댓글, 이미지 내부 문자, 첨부 오디오 음성의 번역.
|
||||
- 기존 게시물 일괄 번역, 목록·홈·미리보기·댓글 조회에서 언어 감지 또는 번역 작업 예약.
|
||||
- 번역 수동 편집·언어 선택 입력·원문 보기 토글·번역 상태 API·실시간 완료 알림.
|
||||
- 푸시 알림 문구·알림 이력의 번역, 정산 화면과 관리자 편집용 원문 응답 변경.
|
||||
- 공통 언어 지원 범위, 다른 콘텐츠의 수정 정책, 추천 순위/스냅샷 생성 방식의 변경.
|
||||
|
||||
## 3. 확정 요구사항
|
||||
|
||||
| ID | 요구사항 | 수용 기준 | 연결 Goal |
|
||||
|---|---|---|---|
|
||||
| `CCT-001` | 원문을 보존하고 본문만 지원 언어로 번역해 저장한다. | 한국어·영어·일본어 각각을 원문으로 작성하면 다른 두 언어의 본문 번역이 저장되고 원문은 유지된다. 댓글과 첨부 파일은 바뀌지 않는다. | `P1-T1`, `P1-T2`, `P2-T1` |
|
||||
| `CCT-002` | 원문 언어가 없으면 본문으로 자동 감지한다. | 요청자의 앱 언어를 원문 언어로 추정하지 않는다. 감지 결과를 저장하고 번역으로 이어진다. | `P1-T2` |
|
||||
| `CCT-003` | 신규 게시물은 저장 성공 후 원문 이외의 지원 언어 번역을 예약한다. | 저장 롤백에는 감지/번역이 실행되지 않는다. 저장 응답이 외부 번역 완료를 기다리지 않는다. | `P2-T1` |
|
||||
| `CCT-004` | 실제 본문 수정 시 언어를 다시 감지하고 번역을 갱신한다. | 한국어에서 영어로 변경해도 새 언어와 새 본문으로 처리한다. 동일 본문 제출·이미지·고정·댓글 허용 여부만 변경하면 재번역하지 않는다. | `P2-T1` |
|
||||
| `CCT-005` | 기존 게시물은 상세 조회에서만 누락 번역을 예약한다. | 번역 미완료 상세는 원문을 반환한다. 원문 언어가 없으면 감지 후 해당 상세 요청 언어만 예약한다. 언어가 같으면 번역 작업은 없다. | `P2-T2` |
|
||||
| `CCT-006` | 상세·목록·미리보기에서 저장된 유효한 번역을 사용한다. | 레거시 목록/최신 목록, v2 커뮤니티 목록, 채널 홈, 홈 추천, 팔로잉 소식에 같은 언어의 저장 번역이 적용된다. 목록 계열만 조회하면 감지/번역 작업은 0건이다. | `P2-T2`, `P3-T1`, `P3-T2` |
|
||||
| `CCT-007` | 번역이 없거나 실패했거나 현재 본문에 맞지 않으면 현재 원문을 제공한다. | 수정 커밋 후 새로 시작한 요청에서는 수정 전 번역을 반환하지 않는다. 늦게 도착한 이전 감지/번역 결과도 새 본문을 덮어쓰지 않는다. | `P1-T1`, `P1-T2`, `P2-T1`, `P3-GATE` |
|
||||
| `CCT-008` | 기존 조회 권한과 유료 본문 제한을 번역문에도 적용한다. | 차단·성인·비활성 필터와 구매/소유자 판정은 유지한다. 번역문을 선택한 다음 기존 미리보기 제한을 적용해 미구매자에게 전체 본문이 노출되지 않는다. | `P2-T2`, `P3-T1`, `P3-T2` |
|
||||
| `CCT-009` | 기존 API 구조와 언어 결정 방식을 유지한다. | `Accept-Language`를 처리하는 `LangContext`를 사용한다. 응답 본문 문자열의 언어만 달라지고 필드·상태 코드·인증·페이지 규칙은 유지한다. | `P2-T2`, `P3-T1`, `P3-T2` |
|
||||
| `CCT-010` | 기존 감지 캐시·번역 메모리·큐·워커를 재사용한다. | 반복 상세 조회나 이전 본문으로의 복원에서 불필요한 번역 호출을 만들지 않는다. 목록 번역 조회는 게시물별 쿼리 대신 일괄 조회한다. | `P1-T1`, `P1-T2`, `P3-GATE` |
|
||||
|
||||
## 4. 처리 흐름
|
||||
|
||||
### 4.1 신규 작성과 본문 수정
|
||||
|
||||
1. 기존 검증·소유권 확인을 거쳐 원문을 저장한다. 본문 변경 시 이전 언어를 비우고 본문 개정 번호를 증가시킨다.
|
||||
2. 저장 트랜잭션 커밋 후 해당 본문의 언어를 비동기로 감지한다. 원문 정보가 이미 있으면 감지를 생략한다.
|
||||
3. 감지 결과가 현재 본문의 결과임을 확인하고 원문 언어를 저장한다.
|
||||
4. 지원 언어 중 원문을 제외한 언어를 예약한다. 기존 메모리를 재사용할 수 있으면 조회용 번역을 복원한다.
|
||||
5. 워커가 완료하면 다음 조회부터 번역문을 제공한다. 완료 전에는 현재 원문을 제공한다.
|
||||
|
||||
### 4.2 기존 게시물 상세 조회
|
||||
|
||||
1. 기존 접근 권한과 노출 정책을 확인한다.
|
||||
2. 요청 언어의 현재 본문에 대응하는 번역이 있으면 사용한다.
|
||||
3. 번역이 없으면 원문 응답을 유지하고 누락 처리만 예약한다. 언어 미확정이면 감지 요청에 상세 요청 언어를 전달한다.
|
||||
4. 감지 후 원문과 요청 언어가 다를 때 해당 언어만 예약한다. 신규 작성의 전체 언어 예약과 구분한다.
|
||||
5. 외부 감지/번역은 HTTP 응답을 기다리게 하지 않는다. 다음 조회부터 완성된 번역을 사용한다.
|
||||
|
||||
### 4.3 목록·홈·미리보기
|
||||
|
||||
권한 필터와 페이지 선정 → 페이지 내 게시물의 유효한 번역 일괄 조회 → 번역 또는 현재 원문 선택 →
|
||||
기존 유료 미리보기·표시 규칙 적용 순서로 처리한다. 이 경로는 언어 감지, 메모리에서 조회 모델 재생성,
|
||||
번역 작업 예약을 하지 않는다. 저장된 번역이 없는 기존 게시물은 상세 조회 또는 본문 수정 전까지 원문으로 남는다.
|
||||
|
||||
## 5. API 계약과 조회 표면
|
||||
|
||||
공개 필드는 추가하지 않는다. `content`와 여기서 파생되는 게시물 미리보기 문자열을 요청 언어로 반환한다.
|
||||
언어는 `LangInterceptor` → `Lang.fromAcceptLanguage` → `LangContext`를 그대로 사용한다.
|
||||
지원 언어는 `ko`, `en`, `ja`이고 헤더 누락/미지원 언어 처리는 기존 한국어 기본값을 유지한다.
|
||||
자동 번역문을 원문 `CreatorCommunity.content`에 덮어쓰지 않는다.
|
||||
|
||||
| API | 대상 | 누락 처리 예약 |
|
||||
|---|---|---|
|
||||
| `POST /creator-community`, `PUT /creator-community` | 신규 작성·실제 본문 수정 후 처리. multipart 계약 유지 | 커밋 후 전체 목표 언어 |
|
||||
| `GET /creator-community/{id}` | 레거시 상세 `content` | 요청 언어만 |
|
||||
| `GET /creator-community`, `GET /creator-community/latest` | 레거시 목록·팔로우 최신 목록 `content` | 없음 |
|
||||
| `GET /api/v2/creator-channels/community-posts/{postId}` | v2 상세 게시물 `content` | 요청 언어만 |
|
||||
| `GET /api/v2/creator-channels/{creatorId}/community` | 커뮤니티 탭 게시물 `content` | 없음 |
|
||||
| `GET /api/v2/creator-channels/{creatorId}/home` | 고정/일반 커뮤니티 항목 `content` | 없음 |
|
||||
| `GET /api/v2/home/recommendations` | 인기 커뮤니티 항목 본문·파생 미리보기 | 없음 |
|
||||
| `GET /api/v2/home/following` | `recentNews`의 커뮤니티 게시물 본문 | 없음 |
|
||||
|
||||
관리자 게시물 조회는 편집 원문을 유지한다. 같은 작성 서비스를 호출하는 관리자 생성·수정은 `CCT-003~004`를 따른다.
|
||||
댓글 전용 조회와 구매 응답은 번역 예약 진입점으로 추가하지 않는다.
|
||||
|
||||
## 6. 데이터·비동기 정합성 설계 기준
|
||||
|
||||
다음 기술 설계는 현재 구현과 DDL에 반영됐다. 실제 MySQL 적용 여부는 테스트 서버 수동 검증에서 확인한다.
|
||||
|
||||
| 저장 대상 | 계획 |
|
||||
|---|---|
|
||||
| `creator_community` | nullable `language_code`, 본문 변경에만 증가하는 `content_revision`을 추가한다. 기존 행은 언어 NULL, 개정 번호 0으로 시작한다. |
|
||||
| `creator_community_translation` | 게시물 ID, locale, 번역 본문, 번역 기준 개정 번호, 원문 해시·언어를 저장한다. `(creator_community_id, locale)` 유일성을 보장한다. |
|
||||
| 공통 감지/번역 저장소 | 기존 감지 캐시와 번역 메모리의 정규화·키, `translation_job` 처리 흐름을 유지한다. 커뮤니티 대상 enum과 원문 추출/조회 모델 저장 분기를 추가한다. |
|
||||
|
||||
- 감지 이벤트에 본문 개정 번호와 필요한 경우 목표 언어를 전달한다. 본문이 변경되었거나 비활성화되면 이전 결과를 적용하지 않는다.
|
||||
- 번역문은 현재 본문의 개정 번호·원문 언어가 일치할 때만 조회한다. 오래된 작업의 결과에 현재 개정 번호를 임의로 붙이지 않는다.
|
||||
- 감지 결과 저장과 번역 upsert의 개정 확인은 DB 갱신과 원자적으로 처리한다. 외부 API 호출 동안 행 잠금을 유지하지 않는다.
|
||||
- `updatedAt`은 고정·이미지 등 수정에도 변하므로 본문 개정 판단에 사용하지 않는다.
|
||||
- 원문 A → B → A 복원 시 기존 COMPLETED 작업 때문에 번역이 영구 누락되지 않도록 현재 메모리로 조회 모델을 먼저 복원한다.
|
||||
- 기존 정규화는 공백·개행을 합친다. 원문 표시의 개행은 원문 그대로 보존하고, 번역 결과는 기존 번역기의 출력 정책을 따른다.
|
||||
|
||||
## 7. 예외·권한·성능
|
||||
|
||||
| 상황 | 처리 |
|
||||
|---|---|
|
||||
| 빈 본문 또는 공백만 있는 본문 | 기존 저장 검증은 바꾸지 않는다. 감지·번역은 생략하고 원문을 반환한다. |
|
||||
| 감지 실패 또는 지원하지 않는 원문 언어 | 원문 유지. 지원하지 않는 언어의 번역 작업은 기존 scheduler 정책에 따라 생성하지 않는다. 언어 미확정 게시물의 다음 상세에서 감지를 다시 시도할 수 있다. |
|
||||
| 번역 실패 | 원문 유지. 기존 워커의 재시도/최종 FAILED 정책을 재사용한다. 반복 상세 조회가 최종 실패 작업을 무한 재생성하지 않는다. |
|
||||
| 번역 도중 본문 변경·비활성화 | 오래된 결과를 현재 게시물의 번역으로 노출하지 않는다. 삭제/비활성 게시물은 기존 조회 정책대로 숨긴다. |
|
||||
| 유료 게시물 미구매 | 선택된 번역 또는 원문에 기존 코드포인트 기준 제한을 적용한다. 기존 15자 기준·짧은 본문 절반·말줄임 처리는 유지한다. |
|
||||
| 목록에 여러 게시물 | 페이지 단위 번역 일괄 조회. 기존 정렬·개수·페이지·추천 후보를 변경하지 않고 추가 N+1을 만들지 않는다. |
|
||||
|
||||
외부 감지/번역 실패는 작성·조회 성공을 번역 완료 여부에 종속시키지 않는다. 인증 실패나 DB 장애까지 성공으로 숨기지는 않는다.
|
||||
본문·자격증명·유료 콘텐츠 전문을 새 로그에 남기지 않는다. 기존 감지/번역 저장소의 텍스트 저장 정책은 재사용한다.
|
||||
번역 완료 시간 SLA나 워커 주기 조정은 이번 요청에 포함하지 않는다.
|
||||
|
||||
## 8. 성공 기준과 추적성
|
||||
|
||||
| 수용 시나리오 | 완료 증거 |
|
||||
|---|---|
|
||||
| 한·영·일 원문으로 신규 작성 후 다른 두 언어 제공 | `P2-GATE`의 생성→감지→작업→저장 통합 검증 |
|
||||
| 언어 없는 기존 글: 목록은 원문/작업 0건, 상세는 원문/감지 후 요청 언어 예약 | `P2-T2` 통합 테스트와 배포 후 수동 HTTP 검증 |
|
||||
| 한국어 본문을 영어로 수정하고 이전 비동기 작업을 늦게 완료 | 현재 원문 폴백과 오래된 결과 차단 테스트 |
|
||||
| 상세 번역 저장 후 목록·채널 홈·추천·팔로잉 모두 요청 언어 제공 | `P3-T1~T2`, `P3-GATE`의 표면별 검증 |
|
||||
| 미구매자는 번역 미리보기만, 구매자·소유자는 기존 권한에 따른 본문 제공 | 레거시/v2 권한·코드포인트 경계 회귀 검증 |
|
||||
| 언어 감지/번역 실패, 반복 조회, 본문 복원 | 원문 유지·작업 중복 방지·번역 메모리 재사용 검증 |
|
||||
|
||||
구현 완료는 위 자동 검증 증거가 기록된 뒤에 판정했다. 실제 MySQL·Papago·HTTP 검증은 테스트 서버 수동 검증으로 분리한다.
|
||||
|
||||
## 9. 인터뷰 결과와 Decision Log
|
||||
|
||||
최종 모호성 점수: **0.04**. 차원별 명확성: Goal 1.00, Scope 1.00, Constraints 0.95, Success 0.95, Context 0.85.
|
||||
이는 누락 확인용 판단 지표이며, 코드 검증이나 구현 완료를 의미하지 않는다.
|
||||
열린 제품 질문은 없다. 테스트 환경 자격증명과 DDL 적용 절차는 테스트 서버 배포 단계에서 확인할 실행 조건이다.
|
||||
|
||||
| ID | 상태 | 결정 | 근거 / 영향 |
|
||||
|---|---|---|---|
|
||||
| `DEC-001` | 확정 | 본문만 번역, 댓글 제외 | 사용자 인터뷰 / `CCT-001` |
|
||||
| `DEC-002` | 확정 | 기존 글은 오디오 상세처럼 누락 번역 예약 | 사용자 제안 및 후속 상세 한정 답변 / `CCT-005` |
|
||||
| `DEC-003` | 확정 | 목록 본문·미리보기도 저장된 번역 적용 | 사용자 답변 / `CCT-006` |
|
||||
| `DEC-004` | 확정 | 언어 정보가 없으면 자동 감지 | 사용자 답변 / `CCT-002` |
|
||||
| `DEC-005` | 확정 | 본문 수정 시 언어 재감지·번역 갱신 | 사용자 yes / `CCT-004` |
|
||||
| `DEC-006` | 확정 | 목록에서는 예약하지 않고 상세에서만 예약 | 사용자 답변 / `CCT-005~006` |
|
||||
| `DEC-007` | 확정 | 새 번역 전에는 수정된 원문 표시 | 사용자 yes / `CCT-007` |
|
||||
| `DEC-008` | 확정 | 채널 홈·홈 추천·팔로잉 소식 포함 | 사용자 yes / `CCT-006` |
|
||||
| `DEC-009` | 기술 설계 | 기존 API 구조·권한 유지, 언어별 저장 번역을 본문 필드에 적용 | 저장소 규칙 및 최소 변경 원칙 / `CCT-008~009` |
|
||||
| `DEC-010` | 기술 설계 | 본문 개정 번호와 기존 캐시·큐 재사용 | 수정 직후 원문 표시와 비동기 경합 방지 / `CCT-007`, `CCT-010` |
|
||||
|
||||
결정 날짜는 모두 2026-09-10이다. 범위 변경 시 이 결정 기록과 요구사항을 먼저 갱신하고 `plan-task.md`를 동기화한다.
|
||||
@@ -0,0 +1,118 @@
|
||||
# P1-T2 리뷰 보고서
|
||||
|
||||
## 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 1 / `P1-T2`, `P1-GATE` |
|
||||
| 기준 working tree | P1-T2 구현 및 실제 Spring regression test 추가 상태 |
|
||||
| 리뷰 일자 | 2026-09-10 |
|
||||
| 리뷰어 | automated review |
|
||||
| 기준 문서 | `prd.md`, `plan-task.md` |
|
||||
| 리뷰 상태 | 추가 수정 및 회귀 검증 완료 |
|
||||
|
||||
## 범위
|
||||
|
||||
- 코드: `LanguageDetectEvent.kt`, `CreatorCommunityTranslationService.kt`
|
||||
- 테스트: `CreatorCommunityLanguageDetectTest.kt`
|
||||
- 제외: 다른 리소스 감지, 공개 API, Docker·HTTP·실제 Papago·MySQL 수동 검증
|
||||
|
||||
## 확정 발견 사항
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 후속 goal |
|
||||
|---|---|---|---|---|
|
||||
| `REV-P1-T2-001` | Blocker | 수정 완료 | 커밋 후 번역 예약이 독립 트랜잭션으로 커밋되지 않는다 | `P1-R1` |
|
||||
| `REV-P1-T2-002` | High | 수정 완료 | 잠금 조회가 stale managed 게시물 상태를 다시 읽지 않는다 | `P1-R1` |
|
||||
| `REV-P1-T2-003` | High | 수정 완료 | 이미 언어가 있는 이벤트가 요청 target을 버린다 | `P1-R1` |
|
||||
| `REV-P1-T2-004` | High | 수정 완료 | 이전 mock 검증은 실제 `translation_job` 저장을 증명하지 않는다 | `P1-R1` |
|
||||
| `REV-P1-T2-005` | High | 수정 완료 | 동시 언어 미확정 감지의 두 번째 target이 lock 뒤 유실된다 | `P1-R2` |
|
||||
| `REV-P1-T2-006` | Blocker | 수정 완료 | 동시 missing-memory 예약이 translation job unique key에 경합한다 | `P1-R3` |
|
||||
|
||||
## 근거와 판정
|
||||
|
||||
### REV-P1-T2-001
|
||||
|
||||
- 코드: `CreatorCommunityTranslationService.requestTranslations`는 기본 `@Transactional`이고, listener는 after-commit callback에서 이를 호출한다.
|
||||
- 재현: `shouldExposeProxiedRequiresNewTranslationRequestEntryPoint`는 `Propagation.REQUIRES_NEW`가 아닌 annotation으로 실패했다.
|
||||
- 영향: 감지 언어 저장 뒤에도 job 예약이 실제로 커밋되지 않아 번역 흐름이 진행되지 않는다.
|
||||
- 조치: 공유 서비스의 public 요청 진입점을 `REQUIRES_NEW`로 선언하고 실제 job 저장 test로 검증한다.
|
||||
|
||||
### REV-P1-T2-002
|
||||
|
||||
- 코드: listener는 `findByIdAndIsActiveTrueForUpdate` 뒤 persistence context의 entity를 refresh하지 않는다.
|
||||
- 재현: `shouldRejectStalePostAfterBlockedDetection`는 감지 동안 본문을 변경한 뒤에도 이전 언어가 저장되어 실패했다.
|
||||
- 영향: 오래된 감지 결과가 새 본문 언어로 저장될 수 있다.
|
||||
- 조치: 잠금 획득 뒤 최신 entity 상태를 refresh하고 revision/content를 재검사한다.
|
||||
|
||||
### REV-P1-T2-003
|
||||
|
||||
- 코드: listener는 이미 languageCode가 설정된 커뮤니티 이벤트를 감지 전 return한다.
|
||||
- 재현: `shouldContinueBothRequestedTargetsWhenLanguageIsAlreadyKnown`는 `ja`, `en` target job이 저장되지 않아 실패했다.
|
||||
- 영향: 상세 요청별 번역 예약 범위가 사라진다.
|
||||
- 조치: 현재 본문 검증 뒤 known-language 이벤트도 after-commit에서 원 target으로 shared service를 호출한다.
|
||||
|
||||
### REV-P1-T2-004
|
||||
|
||||
- 코드/테스트: 기존 테스트는 `CreatorCommunityTranslationService` mock의 호출만 검증했다.
|
||||
- 재현: 실제 Spring listener와 scheduler를 연결한 `CreatorCommunityLanguageDetectTest`에서 저장된 `translation_job` target을 확인하자 job이 없었다.
|
||||
- 영향: mock interaction만 통과해도 사용자 흐름의 persistence 실패를 놓친다.
|
||||
- 조치: 감지 provider만 제어하고 scheduler/repository는 실제 bean으로 사용한다.
|
||||
|
||||
### REV-P1-T2-005
|
||||
|
||||
- 코드: lock과 refresh 뒤 languageCode가 이미 있으면 revision/content 재검증 전에 return한다.
|
||||
- 재현: 같은 언어 미확정 본문으로 두 감지를 시작해 첫 감지를 먼저 커밋한 뒤 두 번째 감지를 진행하면 두 번째 target job이 없다.
|
||||
- 영향: 동시 상세 요청이 서로 다른 언어를 요청할 때 한 요청의 번역 예약이 유실된다.
|
||||
- 조치: 현재 revision/content가 유효하면 이미 저장된 언어를 보존하고, 두 번째 이벤트의 원 target을 after-commit에 등록한다.
|
||||
|
||||
### REV-P1-T2-006
|
||||
|
||||
- 코드: `requestTranslations`는 post lock 없이 materializer의 missing-memory 반환 뒤 scheduler의 job 존재 조회와 insert를 실행한다.
|
||||
- 재현: 같은 언어 확정 post와 target으로 두 request transaction을 동시에 시작하면 두 transaction이 missing job을 보고 insert를 시도할 수 있다.
|
||||
- 영향: job unique key 예외가 호출자 transaction까지 전파돼 상세 예약 흐름이 실패할 수 있다.
|
||||
- 조치: request transaction 시작에서 active post write lock과 refresh를 수행해 source extraction, memory lookup, job 존재 조회와 insert를 직렬화한다.
|
||||
|
||||
## 실행 증거
|
||||
|
||||
| 명령 | 결과 | 핵심 증거 |
|
||||
|---|---|---|
|
||||
| `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` | 실패 | 초기 test bean 보정 후 7개 중 5개 assertion 실패 |
|
||||
|
||||
## 결론
|
||||
|
||||
`REV-P1-T2-001~006`은 `plan-task.md`의 `P1-R1~R3` 회귀 수정 goal로 반영했고 모두 수정·검증을 완료했다.
|
||||
|
||||
## 수정 후 검증 기록
|
||||
|
||||
### 1차 수정 검증 — 2026-09-10
|
||||
|
||||
- 무엇을: `REQUIRES_NEW` 번역 요청, stale entity refresh, known-language target continuation과 실제 `translation_job` 검증을 추가했다.
|
||||
- 어떻게:
|
||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*' ktlintCheck bootJar` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 남은 항목: 사용자 정책상 Docker, HTTP, 실제 Papago, MySQL 수동 동시성 검증은 실행하지 않았다.
|
||||
|
||||
### 2차 수정 검증 — 2026-09-10
|
||||
|
||||
- 대상: `REV-P1-T2-005`.
|
||||
- RED: `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` — exit 1, 8개 중 1개 실패.
|
||||
두 감지가 언어 NULL에서 시작한 뒤 첫 감지의 `ja` job 저장 후 두 번째 `en` job이 누락됐다.
|
||||
- GREEN: lock과 refresh 뒤 revision/content가 유효하면 이미 저장된 languageCode를 유지하고,
|
||||
원 target을 after-commit에 등록하도록 수정했다.
|
||||
- 검증:
|
||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*' ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 남은 항목: Docker, HTTP, 실제 Papago, MySQL 수동 동시성 검증은 사용자 정책상 실행하지 않았다.
|
||||
|
||||
### 3차 수정 검증 — 2026-09-10
|
||||
|
||||
- 대상: `REV-P1-T2-006`.
|
||||
- RED: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest'`
|
||||
— exit 1. 실제 `translation_job` insert가 H2 `SQLState 23505`,
|
||||
`uk_translation_job_resource_field_target_hash` unique index 충돌로 실패했다.
|
||||
- GREEN: `requestTranslations` transaction 시작에서 active post write lock과 `PESSIMISTIC_WRITE` refresh를 수행하도록 수정했다.
|
||||
- 검증:
|
||||
- 같은 focused test — exit 0, `BUILD SUCCESSFUL`; 두 호출 정상 종료와 job 1개 저장을 확인했다.
|
||||
- P1 직접 영향 회귀와 P1 Gate — exit 0, `BUILD SUCCESSFUL`.
|
||||
- `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 남은 항목: Docker, HTTP, 실제 Papago, MySQL 수동 동시성 검증은 사용자 정책상 실행하지 않았다.
|
||||
@@ -0,0 +1,134 @@
|
||||
# Phase 1 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 대상 | Phase 1 `P1-T1`, `P1-T2`, `P1-R1~R4`, `P1-GATE` |
|
||||
| 기준 | HEAD `50409e41c0c469529572f5d77033c3cb23d67d22` + 현재 작업 트리 |
|
||||
| 작업 트리 지문 | `3bbb1d90869484073c3af681118a13f5f03c3ccecbe84de16f1568338acd66b7` |
|
||||
| 일자 / 리뷰어 | 2026-09-10 / Codex 및 목표·품질·보안·QA 증거 리뷰어 |
|
||||
| 기준 문서 | [PRD](../prd.md), [구현 계획](../plan-task.md), `docs/sample/sample-review.md` |
|
||||
| 상태 | `P1-R4` 수정·자동 회귀·후속 리뷰 완료 · 실제 MySQL/Papago/HTTP 수동 검증 대기 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
§2~7의 정적 근거와 지문은 최초 리뷰 당시 기록이다. 수정 후 코드를 같은 지문으로 검증했다고 보지 않는다.
|
||||
현재 판정은 §8과 2026-09-10 후속 재판정에 기록하며, 아래 최초 리뷰의 실행 제약은 그대로 보존한다.
|
||||
|
||||
원문 언어 감지, 번역 저장·재사용, 본문 개정 정합성, 동시 요청의 목표 언어 보존을 기준 문서와 대조한다.
|
||||
코드·테스트 소스·기존 검증 기록만 확인한다. 사용자 지시에 따라 테스트/컴파일/서버/HTTP/DB 실행은 하지 않는다.
|
||||
현재 테스트 통과는 사용자 보고 및 기존 기록으로 구분하며 신규 실행 성공으로 표현하지 않는다.
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
확정 요구사항과 실제 코드 경로가 일치하면 충족으로 판정한다. 결함 후보는 발생 순서·예외 전달·기존 테스트의 경계를
|
||||
대조한 후 확정/오탐/보류로 분리한다. 이번 확정 항목은 제한된 동시 요청 조건에서 발생하고 재조회로 복구할 수 있어
|
||||
**Medium**으로 판정한다. 단순 외부 감지 호출 중복 자체는 계획에서 허용한 범위이므로 결함으로 세지 않는다.
|
||||
|
||||
## 4. 검토 근거와 충족 항목
|
||||
|
||||
아래 source 경로는 `src/main/kotlin/kr/co/vividnext/sodalive/` 기준이다.
|
||||
|
||||
| 요구사항 / 경계 | 코드 근거 | 판정 |
|
||||
|---|---|---|
|
||||
| `CCT-001` 원문과 번역 분리 | `v2/creator/channel/community/translation/adapter/out/persistence/CreatorCommunityTranslation.kt:17`, `schema.sql` | 충족 |
|
||||
| `CCT-007` 현재 개정·언어·해시 일치 | `v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt:95`, `i18n/translation/TranslationReadModelMaterializer.kt:194` | 충족 |
|
||||
| 이전 감지 결과 배제 | `content/LanguageDetectEvent.kt:383`, 감지 전 확인 및 잠금/refresh 후 재검사 | 충족 |
|
||||
| `CCT-010` A→B→A 메모리 복원 | `CreatorCommunityTranslationService.requestTranslations`에서 materialize 후 누락 예약 | 충족 |
|
||||
| 같은 게시물 작업/번역 행 저장 직렬화 | 요청 진입점의 post lock, materializer의 post/translation lock | 충족 |
|
||||
| 언어 미확정 상태의 실제 캐시 INSERT 경합 | 최초 근거: `content/LanguageDetectionCacheService.kt:21`, `:29` | 당시 수정 필요, `P1-R4` 자동 검증으로 수정 확인 (§9) |
|
||||
|
||||
읽은 테스트에는 `CreatorCommunityTranslationServiceTest`의
|
||||
`shouldReturnOnlyCurrentTranslationFromBatchedRead`, `shouldFallbackToOriginalWhenTranslationIsMissingOrDoesNotMatchCurrentSource`,
|
||||
`shouldRematerializeAtoBtoAIntoExistingTranslationRow`, `shouldSerializeConcurrentMissingMemoryTranslationJobScheduling`,
|
||||
`shouldUpdateExistingTranslationAfterRepeatableReadSnapshotWaitsForFirstWriter`가 포함된다.
|
||||
기존 P1 리뷰의 수정 사항을 신규 결함으로 중복 등록하지 않았다.
|
||||
|
||||
실행한 검증은 `git diff`, `rg`, `sed`, `nl`을 통한 정적 대조와 파일 SHA256 계산이다.
|
||||
기존 테스트 XML과 리뷰 레인 출처는 [증거 기록](review-evidence.md)에 있다. 새 테스트나 런타임 재현은 실행하지 않았다.
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 Goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P1-007` | Medium | 수정 완료 · `P1-R4` 자동 증거 (§9), 최초 정적 확정 이력 유지 | 최초 감지 캐시 저장 경합으로 후발 목표 언어의 예약 유실 | `P1-T2`, `P1-R2` | `P1-R4` 완료, 실제 MySQL 수동 확인 |
|
||||
|
||||
## 6. REV-P1-007 상세
|
||||
|
||||
- 관련 요구사항: `CCT-005`, `CCT-010`, 계획 `P1-R2`의 각 요청 target 보존 목표.
|
||||
- 관련 계약: PRD §4.2 상세 요청 언어만 예약, §6 기존 감지 캐시 재사용.
|
||||
- 근거 파일:
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt:21`: 캐시를 먼저 조회한다.
|
||||
- 같은 파일 `:28`, `:29`: 외부 감지 후 같은 키에 무조건 `save`한다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionResult.kt:15`: 해시/provider/version 유일 키.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/common/BaseEntity.kt:19`: IDENTITY 저장 식별자.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt:398`: 캐시 예외가 후속 post lock보다 먼저 전파된다.
|
||||
- 같은 파일 `:411`, `:417`: 목표 언어 후속 예약은 감지 성공 이후에만 등록된다.
|
||||
|
||||
### 정적 검증 순서
|
||||
|
||||
이 순서는 코드를 따라 확인한 결함 조건이며, 이번 턴에서 실제로 실행한 재현 결과가 아니다.
|
||||
|
||||
1. 원문 언어 NULL이고 감지 캐시가 없는 동일 한국어 게시물에 일본어·영어 상세 요청을 보낸다.
|
||||
2. 두 감지 트랜잭션이 모두 언어 NULL과 캐시 miss를 읽은 뒤 각 detector 호출을 진행한다.
|
||||
3. 첫 detector가 반환하면 캐시 INSERT → 게시물 언어 저장 → 커밋 → 일본어 작업 예약이 진행된다.
|
||||
4. 뒤늦게 반환한 detector는 캐시를 다시 읽거나 원자적으로 합류하지 않고 같은 유일 키를 INSERT한다.
|
||||
5. 중복 키 예외로 두 번째 감지 트랜잭션이 종료되어 영어 목표를 예약하는 콜백에 도달하지 못한다.
|
||||
|
||||
기대 결과는 양쪽 목표 언어의 작업 보존이다. 코드 경로상 결과는 후발 목표의 작업 누락이다.
|
||||
다음 영어 상세 조회로 다시 예약할 수 있으므로 영구적인 데이터 손실이라고 주장하지 않는다.
|
||||
서로 다른 게시물을 같은 본문으로 동시에 신규 작성할 때도 동일한 전역 캐시 키 경합이 발생할 수 있다.
|
||||
|
||||
### 기존 테스트가 통과하는 이유
|
||||
|
||||
`src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt:335`의
|
||||
`shouldPreserveSecondTargetAfterConcurrentDetectionSetsLanguage`는 `ControlledLanguageDetectionCacheService`를 사용한다.
|
||||
같은 파일 `:743`의 `detectWithCache` override는 실제 캐시 SELECT/INSERT 없이 제어된 언어를 반환한다.
|
||||
따라서 이 테스트의 목표 보존 성공은 실제 캐시 유일 키 저장 경합까지 보장하지 않는다.
|
||||
|
||||
### 권장 조치와 판정
|
||||
|
||||
동일 캐시 키 저장을 원자적으로 처리해 이미 저장한 결과로 수렴시키고, 후발 감지 트랜잭션이 rollback-only가 되지 않게 한다.
|
||||
이미 실패한 트랜잭션 내부에서 예외만 잡고 계속 진행하는 방식은 사용하지 않는다.
|
||||
외부 API 호출 동안 게시물 행 잠금을 잡지 않는 현재 기준을 유지한다.
|
||||
실제 캐시 service/repository를 사용하고 detector만 통제하는 두 cache miss 경합 회귀 테스트를 추가한다.
|
||||
|
||||
2026-09-10 — 품질 리뷰, 목표 리뷰, 루트 리뷰에서 위 정적 경로를 교차 확인하여 확정했다.
|
||||
실행 재현은 사용자 지시에 따라 수행하지 않았으며 `P1-R4`의 RED 단계로 남긴다.
|
||||
|
||||
## 7. 계획 전환
|
||||
|
||||
`plan-task.md`의 Phase 1에 신규 `P1-R4`를 추가한다. 기존 `P1-R2`와 `P1-GATE` 완료 체크 및 검증 기록은 유지한다.
|
||||
새 목표: 실제 최초 감지 캐시 경합에서도 두 요청 target을 보존하고 해당 회귀를 방지한다.
|
||||
상세 검증 명령·파일·RED/GREEN/REFACTOR·재검토 조건은 신규 Task에 기록한다.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| Phase 범위와 기존 수정 확인 | 충족 |
|
||||
| 후보 판정 | 확정 1건. afterCommit 후 재잠금 교착 후보는 DB 커밋 후 잠금 해제로 오탐 제외 |
|
||||
| 확정 항목 계획 반영 | 최초 `P1-R4` 신규 등록, 이후 구현·자동 검증·후속 리뷰 완료 |
|
||||
| 실행 범위 명시 | 최초 리뷰는 테스트 미실행. 이후 `P1-R4` 로컬 자동 검증 증거를 §9에 별도 누적 |
|
||||
|
||||
**최초 결론 기록: 수정 Goal 필요.** 당시 최초 감지 캐시 경합 수정과 수정 후 검증이 필요했다.
|
||||
**현재 최종 결론: `REV-P1-007`은 `P1-R4` 로컬 자동 검증 범위에서 수정 완료.**
|
||||
실제 MySQL/Papago/HTTP 검증은 기존 배포 후 체크리스트에서 미완료로 유지한다.
|
||||
|
||||
## 9. P1-R4 수정 후 재판정 · 2026-09-10
|
||||
|
||||
- RED는 실제 캐시 service/repository와 제어된 provider를 사용했다. 두 provider 진입 후 focused 명령이
|
||||
exit 1로 실패했고 `ExecutionException → DataIntegrityViolationException → H2 23505` 캐시 유일 키 충돌을 확인했다.
|
||||
- GREEN은 native MySQL `ON DUPLICATE KEY UPDATE id=id`와 scalar `SELECT ... FOR UPDATE`로 승자 결과에
|
||||
수렴시킨다. audit 시각을 명시하고 DB 예외를 잡아 같은 트랜잭션에서 계속하는 방식은 사용하지 않는다.
|
||||
- 같은 게시물 `ja`/`en` 이중 miss 및 다른 게시물의 동일 본문 경합에서 캐시 1행과 각 target 작업 보존을 확인했다.
|
||||
focused fresh 재실행은 `--rerun-tasks --no-parallel`로 `BUILD SUCCESSFUL` (5분 19초)이었다.
|
||||
- P1 listener/scheduler/materializer 회귀 (1분 09초), P2 상세/API 회귀 (1분 42초), `ktlintCheck` (44초)는
|
||||
모두 `BUILD SUCCESSFUL`이다. 정확한 명령은 [후속 증거](review-evidence.md)에 기록한다.
|
||||
- spec `PASS`: `ses_f7657d350ffe3YUYhEHvHPuikK`. quality `APPROVED`: `ses_f7656994dffeR2odTLswaw6RTo`.
|
||||
- `REV-P1-007`의 실제 캐시 경합 테스트 공백은 위 자동 증거로 보완됐다. H2 `MODE=MySQL`은 MySQL 8/InnoDB의
|
||||
실제 격리·잠금·DDL 증거가 아니다. 실제 MySQL의 순서 제어 경합, audit 컬럼, 감지 중 수정 가능 여부와 stale 차단,
|
||||
실제 Papago/HTTP는 [수동 체크리스트](../plan-task.md)에서 확인해야 한다.
|
||||
- 전체 `./gradlew test`는 생략했다. 공통 캐시 계약·실제 repository 경합·모든 직접 listener/scheduler/materializer
|
||||
테스트·P2 상세/API 소비자가 통과했고 별도의 미해결 공통 경계가 없다는 영향 범위 판단에 따른다.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Phase 2 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 대상 | Phase 2 `P2-T1`, `P2-T2`, `P2-R1~R3`, `P2-GATE` |
|
||||
| 기준 | HEAD `50409e41c0c469529572f5d77033c3cb23d67d22` + 현재 작업 트리 |
|
||||
| 작업 트리 지문 | `3bbb1d90869484073c3af681118a13f5f03c3ccecbe84de16f1568338acd66b7` |
|
||||
| 일자 / 리뷰어 | 2026-09-10 / Codex 및 목표·품질·보안·QA 증거 리뷰어 |
|
||||
| 기준 문서 | [PRD](../prd.md), [구현 계획](../plan-task.md), `docs/sample/sample-review.md` |
|
||||
| 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
생성 커밋 후 번역, 실제 본문 수정 시 재감지, 상세의 요청 언어 예약, 기존 권한/응답 보존을 점검한다.
|
||||
관리자 공용 mutation 경로와 고정 상태 경합의 기존 수정도 확인한다. 사용자 지시에 따라 테스트/컴파일/HTTP 실행은 하지 않는다.
|
||||
소스·테스트·과거 검증 기록을 기준으로 판단하며 새로운 기능을 요구하거나 미실행을 결함으로 만들지 않는다.
|
||||
|
||||
## 3. 판정 기준과 검토 근거
|
||||
|
||||
source 경로는 `src/main/kotlin/kr/co/vividnext/sodalive/`, test 경로는 같은 패키지의 `src/test/kotlin/` 기준이다.
|
||||
|
||||
| 기준 | 근거 | 판정 |
|
||||
|---|---|---|
|
||||
| `CCT-003` 생성 커밋 후 예약 | `explorer/profile/creatorCommunity/CreatorCommunityService.kt:152`, `:175`, `:192` | 충족. 커밋 후 공유 쓰기 진입점을 호출하고 외부 감지는 비동기 listener에서 실행 |
|
||||
| `CCT-004` 실제 본문 변경만 재감지 | 같은 파일 `:214`, `:221` | 충족. 소유권 잠금 조회/refresh 후 문자열 비교, 언어 NULL, 개정 증가 |
|
||||
| 본문 외 고정 상태 변경의 정합성 | 같은 파일 `:264`의 잠금/refresh 및 관리자 공용 경로 | 기존 `P2-R2` 수정 반영 확인 |
|
||||
| `CCT-005` 상세만 요청 언어 예약 | 같은 파일 `:398`, `v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt:128` | 충족. 유효 번역이 없을 때 요청 locale만 전달 |
|
||||
| `CCT-007` 최신 원문 폴백 | 공유 조회 결과를 DTO에 복사하며 유효하지 않은 번역은 사용하지 않음 | 충족 |
|
||||
| `CCT-008` 권한·유료 미리보기 | 레거시 차단 확인 `CreatorCommunityService.kt:390`, v2 접근 검사 `CreatorChannelCommunityQueryService.kt:121`, 기존 마스킹 `:253` | 충족. 번역 선택 후 마스킹 |
|
||||
| `CCT-009` API/언어 규칙 | 기존 `LangContext`를 사용하고 공개 DTO에는 `content`만 복사 | 충족 |
|
||||
|
||||
관리자 생성/수정은 `v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt:47`, `:75`에서
|
||||
기존 공용 서비스를 호출한다. 관리용 조회는 원문을 유지한다. 댓글/답글/구매를 번역 예약 진입점으로 추가하지 않았다.
|
||||
|
||||
확인한 테스트:
|
||||
|
||||
- `content/CreatorCommunityLanguageDetectTest.kt:192` — `shouldRunDetailTranslationPipelineThroughProxyListenerAndScheduler`.
|
||||
- 같은 파일 `:433` — `shouldLeaveNoPostOrTranslationArtifactsWhenProxiedCreateRollsBack`.
|
||||
- 같은 파일 `:461` — `shouldHideStaleTranslationAndScheduleNewTargetsAfterCommittedEnglishModification`.
|
||||
- `explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` — 동일 본문/본문 외 수정의 비예약, 유료·소유자·구매자·차단 조건.
|
||||
- `v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostConcurrencyTest.kt` — 관리자 공용 mutation과 최신 본문 보존.
|
||||
|
||||
## 4. 실행 및 증거 한계
|
||||
|
||||
`git diff`, `rg`, `sed`와 테스트 소스 읽기로 호출·트랜잭션·권한 경계를 확인했다.
|
||||
기존 XML 성공 기록은 [증거 기록](review-evidence.md)에 분리했다. 테스트/컴파일/서버/외부 호출은 실행하지 않았다.
|
||||
|
||||
`shouldRunDetailTranslationPipelineThroughProxyListenerAndScheduler`는 실제 프록시·listener·scheduler를 연결하지만
|
||||
번역 메모리를 직접 저장한 뒤 materializer를 호출한다. 워커 테스트는 별도의 mock 기반 구성요소 검증이다.
|
||||
따라서 한·영·일 각각의 신규 작성부터 실제 provider/워커/번역 행 저장까지 하나로 연결한 실환경 검증을 완료했다고 해석하지 않는다.
|
||||
이는 새 제품 결함으로 확정하지 않고 기존 테스트 서버 수동 체크리스트의 실행 범위로 유지한다.
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
**Phase 2 자체 구현의 추가 확정 발견 사항 없음.**
|
||||
|
||||
Phase 1의 `REV-P1-007`은 상세를 통한 최초 언어 감지에도 영향을 준다. 같은 원인을 Phase 2 결함으로 중복 등록하지 않고
|
||||
[Phase 1 리뷰](phase-1-review.md)의 `P1-R4`에서 수정한다. 이 의존성까지 포함한 전체 번역 흐름을 무조건 PASS로 표현하지 않는다.
|
||||
|
||||
## 6. 후보 판정
|
||||
|
||||
| 후보 | 판정 / 근거 |
|
||||
|---|---|
|
||||
| afterCommit의 REQUIRES_NEW 진입이 이전 post lock과 교착 | 오탐. DB commit 후 콜백이므로 이전 트랜잭션의 DB 잠금은 해제된 상태 |
|
||||
| 관리자 별도 생성/수정에 번역 연결 누락 | 오탐. 공용 레거시 서비스를 호출하므로 중복 연결 불필요 |
|
||||
| 테스트 재실행이 없으므로 구현 실패 | 해당 없음. 사용자 지시이며 정적 리뷰의 실행 한계로만 기록 |
|
||||
|
||||
## 7. 계획 전환
|
||||
|
||||
**전환 항목 없음.** 자체 수정 Task를 억지로 추가하지 않는다. Phase 1 `P1-R4`의 직접 영향 회귀에 상세 파이프라인을 포함한다.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| Phase 2 자체 요구사항 확인 | 충족 |
|
||||
| 추가 확정 결함 | 없음 |
|
||||
| 다른 Phase 의존 결함 기록 | `REV-P1-007` / `P1-R4` |
|
||||
| 새 수정 Task | 해당 없음 |
|
||||
| 검증 범위 명시 | 충족. 소스·기존 테스트/기록 대조, 직접 실행 없음 |
|
||||
|
||||
**최종 결론: Phase 2 자체 기준 충족, 추가 확정 발견 사항 없음.**
|
||||
남은 항목은 Phase 1 수정의 영향 재검토와 기존 테스트 서버 수동 확인이다. 이번 리뷰에서 수정한 제품 코드는 없다.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Phase 3 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 대상 | Phase 3 `P3-T1`, `P3-T2`, `P3-R1`, `P3-GATE` |
|
||||
| 기준 | HEAD `50409e41c0c469529572f5d77033c3cb23d67d22` + 현재 작업 트리 |
|
||||
| 작업 트리 지문 | `3bbb1d90869484073c3af681118a13f5f03c3ccecbe84de16f1568338acd66b7` |
|
||||
| 일자 / 리뷰어 | 2026-09-10 / Codex 및 문맥·목표·보안·QA 증거 리뷰어 |
|
||||
| 기준 문서 | [PRD](../prd.md), [구현 계획](../plan-task.md), `docs/sample/sample-review.md` |
|
||||
| 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
`CCT-006~010`의 목록·미리보기·홈 표면, 저장 번역만 읽는 경계, 권한·페이지·공개 계약 유지를 확인한다.
|
||||
기존 최종 Gate의 실제 실행 미완료 범위도 대조한다. 사용자 지시에 따라 테스트·컴파일·서버·HTTP는 실행하지 않는다.
|
||||
|
||||
## 3. 판정 기준과 코드 대조
|
||||
|
||||
아래 경로는 `src/main/kotlin/kr/co/vividnext/sodalive/` 기준이다.
|
||||
|
||||
| 표면 / 기준 | 코드 근거 | 판정 |
|
||||
|---|---|---|
|
||||
| 레거시 일반·최신 목록 | `explorer/profile/creatorCommunity/CreatorCommunityService.kt:305`, `:359`, `:641`, `:679` | 충족. 표시할 ID를 모아 번역 조회 후 본문만 복사 |
|
||||
| v2 탭 | `v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt:74` | 충족. 반환 페이지 확정 후 번역 조회, 다음 페이지 확인용 추가 행 제외 |
|
||||
| 채널 홈 고정·일반 | `v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt:104`, `:133` → 공용 `findHomeCommunityPosts` | 충족. 두 그룹 모두 같은 읽기 연결 |
|
||||
| 홈 추천 | `v2/recommendation/application/HomeRecommendationQueryService.kt:158` | 충족. 후보 정렬·중복 제거·limit 이후 최종 ID만 처리 |
|
||||
| 팔로잉 소식 | `v2/home/following/application/HomeFollowingQueryService.kt:32`, `:60` | 충족. 커뮤니티 유형만 변경하고 다른 소식 유지 |
|
||||
| 목록 계열 예약 금지 | `v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt:33` | 충족. `findDisplayContents`는 읽기 전용이며 감지·예약·materialize 호출 없음 |
|
||||
| 공개 스키마 | DTO/도메인 `copy(content = ...)` | 충족. 내부 `CreatorCommunityDisplayContent`를 공개 응답으로 추가하지 않음 |
|
||||
| DDL 타입 정합성 | `schema.sql:11`, `CreatorCommunityTranslation.kt`의 sourceHash 매핑 | 기존 `P3-R1`의 VARCHAR(64) 수정 반영 확인 |
|
||||
|
||||
`LiveApiService`와 `ExplorerService`의 간접 목록 호출도 기존 서비스에 연결되어 저장 번역을 사용한다.
|
||||
팔로잉 본문은 `DefaultHomeFollowingQueryRepository.kt:160`, `:251`에서 현재 게시물 내용을 읽으므로
|
||||
소식 발행 당시 저장한 미리보기를 번역 원문으로 사용하지 않는다.
|
||||
|
||||
## 4. 테스트·기록 대조
|
||||
|
||||
test 경로는 `src/test/kotlin/kr/co/vividnext/sodalive/` 기준이다.
|
||||
|
||||
| 검증 대상 | 읽은 기존 테스트 |
|
||||
|---|---|
|
||||
| 배치 크기 1/4에서 SELECT 2회 유지 | `explorer/profile/creatorCommunity/CreatorCommunityTranslationEndToEndTest.kt:66`, `shouldReadTranslationsWithConstantSelectCountAcrossBatchSizes` |
|
||||
| 목록 누락 번역의 작업/이벤트 미생성 | 같은 파일 `:98`, `shouldNotPublishOrScheduleWhenLegacyListReadsMissingTranslation` |
|
||||
| 레거시 필터·마스킹·일괄 조회 | `explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt:579` 이후 관련 테스트 |
|
||||
| v2 페이지 추가 행 제외·홈 두 그룹 | `v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt:180` 이후 관련 테스트 |
|
||||
| 채널 홈 저장 번역 | `v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt:89`, `shouldUseStoredTranslationsForPinnedAndNormalHomeCommunityPosts` |
|
||||
| 추천 최종 배치·팔로잉 커뮤니티 유형만 처리 | `v2/recommendation/application/HomeRecommendationQueryServiceTest.kt:705`, `v2/home/following/application/HomeFollowingQueryServiceTest.kt:123` |
|
||||
|
||||
`git diff`, `rg`, `sed`로 호출부와 테스트를 대조했다. 기존 XML과 레인 결과는 [증거 기록](review-evidence.md)에 있다.
|
||||
이 표는 테스트를 새로 실행했다는 의미가 아니다. 기존 계획의 로컬 성공 기록과 실제 MySQL/Papago/HTTP 미완료 표기는
|
||||
서로 구분되어 있으며, 후자를 완료한 것으로 평가하지 않는다.
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
**확정 발견 사항 없음.** 목록·채널 홈·추천·팔로잉의 저장 번역 적용은 인터뷰에서 확정한 범위에 부합한다.
|
||||
|
||||
## 6. 후보 판정
|
||||
|
||||
| 후보 | 판정 / 근거 |
|
||||
|---|---|
|
||||
| 추천·팔로잉 본문 복사가 유료 전체 본문을 노출 | 오탐. `DefaultHomeRecommendationQueryRepository.kt:893`, `DefaultHomeFollowingQueryRepository.kt:461`의 기존 무료 조건 유지 |
|
||||
| 레거시/v2 마스킹 전에 원문 길이를 사용 | 오탐. 번역 본문을 복사한 뒤 `SelectCommunityPostResponse.kt:44`, `CreatorChannelCommunityQueryService.kt:253`의 기존 마스킹 실행 |
|
||||
| 번역 배치 조회가 작성자별 N+1을 추가 | 오탐. `member` LAZY이며 읽기에서 접근하지 않는다. 서로 다른 작성자의 배치 크기 테스트가 기존에 존재 |
|
||||
| 목록 번역 누락을 처리하기 위해 자동 작업 예약 | 오탐. 공용 읽기 메서드만 사용하고 예약 진입점을 호출하지 않음 |
|
||||
|
||||
## 7. 계획 전환
|
||||
|
||||
**전환 항목 없음.** 추가 수정 Task는 만들지 않는다. Phase 1 결함으로 번역이 아직 생성되지 않은 경우에는
|
||||
이 Phase의 요구대로 원문을 표시한다. 예약 결함은 `P1-R4`에서 처리한다.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| 모든 합의된 목록·미리보기 표면 확인 | 충족 |
|
||||
| 확정 결함 | 없음 |
|
||||
| 후보 판정 완료 | 충족, 위 후보는 오탐 |
|
||||
| 확정 항목 계획 반영 | 해당 없음 |
|
||||
| 실행 범위·기존 미완료 검증 구분 | 충족 |
|
||||
|
||||
**최종 결론: Phase 3 기준 충족, 확정 발견 사항 없음.**
|
||||
실제 DDL·MySQL 동시성·HTTP·Papago 확인은 기존 수동 체크리스트대로 남긴다. 이번 리뷰에서 제품 코드를 수정하지 않았다.
|
||||
@@ -0,0 +1,121 @@
|
||||
# 2026-09-10 Phase별 정적 리뷰 증거
|
||||
|
||||
## 대상과 검증 범위
|
||||
|
||||
아래 지문부터 리뷰 산출물 검증까지는 최초 정적 리뷰 당시 기록이다. `P1-R4` 이후 변경에는 이 지문과 PASS를
|
||||
소급 적용하지 않는다. 현재 최종 판정과 후속 자동 증거는 문서 끝에 별도로 누적한다.
|
||||
|
||||
- 기준 HEAD: `50409e41c0c469529572f5d77033c3cb23d67d22`.
|
||||
- 리뷰 대상은 HEAD 자체가 아닌 현재 수정/미추적 source·test와 `schema.sql` 32개 파일이다.
|
||||
- 위 파일의 경로순 `경로 + NUL + SHA256 + LF`를 결합한 SHA256:
|
||||
`3bbb1d90869484073c3af681118a13f5f03c3ccecbe84de16f1568338acd66b7`.
|
||||
- 사용자 지시: 컴파일·테스트는 이미 통과했으므로 테스트를 직접 실행하지 않는다.
|
||||
- 이번 판정은 소스·테스트·기존 기록의 정적 대조다. 테스트/컴파일/서버/HTTP/Papago/MySQL 실행 판정이 아니다.
|
||||
- 이전 리뷰의 PASS를 재사용하지 않고 현재 작업 트리를 다시 읽었다. 아래 PASS는 정적 리뷰 범위에만 적용한다.
|
||||
|
||||
## 레인 기록
|
||||
|
||||
모든 기록의 기준은 위 전체 HEAD와 작업 트리 지문이다. 소스가 바뀌면 이 결과를 그대로 재사용하지 않는다.
|
||||
|
||||
| 레인 | 판정 | 근거 / 결과 출처 |
|
||||
|---|---|---|
|
||||
| 보안·권한 `security_review` | PASS | 현재 본문 정합성, 번역 선택 후 유료 마스킹, 추천/팔로잉 무료 필터, 관리자 원문 보존 확인. `/root/security_review` 최종 보고, 2026-09-10. |
|
||||
| 목표·제약 `goal_review` | 수정 필요 | Phase 1 캐시 경합으로 요청 target 유실을 독립 확인. Phase 2·3 추가 결함 없음. `/root/goal_review` 최종 보고, 2026-09-10. |
|
||||
| 코드 품질·동시성 `code_review` | 수정 필요 | 감지 캐시 최초 INSERT 경합으로 후발 target 예약 유실. `/root/code_review` 최종 보고, 2026-09-10. Phase 1 보고서에 상세 근거를 기록한다. |
|
||||
| 기존 QA 증거 `qa_evidence_review` | 정적 증거 확인 완료 | 기존 테스트 소스/XML 성공 기록을 확인했다. 실제 캐시 INSERT 경합과 한·영·일 전체 파이프라인의 직접 통합 증거 한계는 별도로 남긴다. `/root/qa_evidence_review` 최종 보고, 2026-09-10. |
|
||||
| 문맥·통합 `context_review` | PASS — Phase 3 범위 | 목록/홈 및 간접 소비자, 공개 스키마·무료 필터·읽기 전용 경계 확인. `/root/context_review` 최종 보고, 2026-09-10. Phase 1·2 전체 판정으로 확대하지 않는다. |
|
||||
|
||||
동적 QA/debugging 감사는 사용자 지시에 따라 재실행하지 않았다. 이를 PASS나 신규 실패로 분류하지 않는다.
|
||||
|
||||
## 기존 실행 증거 표본
|
||||
|
||||
아래는 `build/test-results/test/`의 기존 XML을 읽은 결과이며 현재 턴에서 실행한 테스트가 아니다.
|
||||
|
||||
| XML 파일명 | 테스트 수 | 실패 / 오류 / skip | 기록 timestamp |
|
||||
|---|---:|---|---|
|
||||
| `TEST-kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest.xml` | 12 | 0 / 0 / 0 | 2026-09-10T02:43:16 |
|
||||
| `TEST-kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest.xml` | 16 | 0 / 0 / 0 | 2026-09-10T02:50:05 |
|
||||
| `TEST-kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityTranslationEndToEndTest.xml` | 4 | 0 / 0 / 0 | 2026-09-10T02:44:03 |
|
||||
| `TEST-kr.co.vividnext.sodalive.i18n.translation.TranslationJobWorkerTest.xml` | 7 | 0 / 0 / 0 | 2026-09-10T02:44:03 |
|
||||
| `TEST-kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest.xml` | 15 | 0 / 0 / 0 | 2026-09-10T02:48:44 |
|
||||
| `TEST-kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest.xml` | 2 | 0 / 0 / 0 | 2026-09-10T02:49:09 |
|
||||
|
||||
XML에 source 지문이 포함되어 있다고 가정하지 않는다. 사용자 보고와 계획의 과거 성공 기록을 보조하는 자료로만 사용한다.
|
||||
|
||||
## 최종 정적 판정
|
||||
|
||||
- [Phase 1](phase-1-review.md): 확정 수정 사항 1건 → `P1-R4`.
|
||||
- [Phase 2](phase-2-review.md): 자체 구현 범위 충족, 추가 확정 발견 사항 없음. Phase 1 결함의 상세 예약 영향은 남는다.
|
||||
- [Phase 3](phase-3-review.md): 저장 번역 표시·목록 비예약 범위 충족, 추가 확정 발견 사항 없음.
|
||||
- 이번 리뷰는 테스트·제품 코드·DDL을 변경하지 않았다. Phase 1 수정과 실제 실행 검증은 후속 작업이다.
|
||||
|
||||
## 리뷰 산출물 검증
|
||||
|
||||
- `./gradlew tasks --all` — exit 0, `BUILD SUCCESSFUL`. 저장소 문서 가이드에 따른 task 목록 확인만 수행했다.
|
||||
이 명령으로 컴파일이나 테스트를 실행하지 않았다.
|
||||
- `git diff --check` — exit 0. 새 미추적 리뷰 문서는 별도 공백/개행 검사로 확인했다.
|
||||
- 문서 링크·공백·템플릿 잔여값 검사 — 5개 문서 PASS. `P1-R4`는 Phase 1 안에 한 번만 미완료 Goal로 등록됐다.
|
||||
- 리뷰 전후 source/test/DDL 32개 파일 SHA256 대조 — 동일 지문으로 PASS. 제품 코드·테스트·DDL 수정 없음.
|
||||
|
||||
## P1-R4 수정 후 증거 · 2026-09-10
|
||||
|
||||
이 절은 완료된 구현의 RED/GREEN·자동 회귀·리뷰 결과를 전달받아 동기화한 기록이다.
|
||||
이번 문서 작업에서 제품 테스트를 다시 실행한 결과가 아니며, 위 최초 정적 리뷰의 지문을 새 코드의 지문으로 재사용하지 않는다.
|
||||
|
||||
### 구현과 RED/GREEN
|
||||
|
||||
- RED: 아래 focused 명령은 두 provider 진입 이후 exit 1로 실패했다.
|
||||
`ExecutionException → DataIntegrityViolationException → H2 23505`로 감지 캐시 유일 키 충돌을 확인했다.
|
||||
- GREEN: native MySQL `ON DUPLICATE KEY UPDATE id=id`와 scalar `SELECT ... FOR UPDATE`로 승자 행을
|
||||
현재 읽기한다. native INSERT에 audit 시각을 명시하며 DB 예외를 잡고 후속 예약을 계속하지 않는다.
|
||||
- 실제 캐시 service/repository를 통한 같은 게시물 `ja`/`en` 이중 miss와 다른 게시물의 동일 본문 경합을 검증했다.
|
||||
캐시 1행으로 수렴하고 각 요청의 목표 언어 작업을 보존한다.
|
||||
|
||||
### 자동 검증 명령과 결과
|
||||
|
||||
RED focused 명령 (exit 1, 위 유일 키 충돌):
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectionCacheConcurrencyTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest'
|
||||
```
|
||||
|
||||
GREEN focused fresh 재실행, `BUILD SUCCESSFUL` (5분 19초):
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectionCacheConcurrencyTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --rerun-tasks --no-parallel
|
||||
```
|
||||
|
||||
P1 listener/scheduler/materializer 영향 회귀, `BUILD SUCCESSFUL` (1분 09초):
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
```
|
||||
|
||||
P2 상세/API 소비자 회귀, `BUILD SUCCESSFUL` (1분 42초):
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*'
|
||||
```
|
||||
|
||||
형식 검사, `BUILD SUCCESSFUL` (44초):
|
||||
|
||||
```bash
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
전체 `./gradlew test`는 생략했다. 공통 캐시 계약, 실제 repository 경합, 모든 직접 listener/scheduler/materializer
|
||||
테스트와 P2 상세/API 소비자가 위 명령으로 통과했고 별도로 미해결된 공통 경계가 없었다.
|
||||
과거 전체 빌드 성공을 이번 변경의 전체 테스트 실행으로 간주하지 않는다.
|
||||
|
||||
### 수정 후 리뷰와 현재 최종 판정
|
||||
|
||||
| 리뷰 | 판정 | 출처 |
|
||||
|---|---|---|
|
||||
| spec | PASS | `ses_f7657d350ffe3YUYhEHvHPuikK` |
|
||||
| quality | APPROVED | `ses_f7656994dffeR2odTLswaw6RTo` |
|
||||
|
||||
**현재 최종 판정: 기존 Phase 1 결함 `REV-P1-007`은 `P1-R4` 로컬 자동 검증 범위에서 수정 완료다.**
|
||||
H2 `MODE=MySQL` 결과는 실제 MySQL 8/InnoDB의 유일 키·격리 수준·audit 컬럼·잠금 동작 검증이 아니다.
|
||||
테스트 서버의 순서 제어 이중 miss, 다른 게시물의 공유 캐시 수렴, provider 차단 중 본문 수정과 stale 결과 차단은
|
||||
[계획의 수동 체크리스트](../plan-task.md)에 미완료로 남긴다. 실제 MySQL/Papago/live HTTP, Docker, 배포는
|
||||
검증하지 않았으며 기존 정적 리뷰의 실환경 검증 한계도 유지한다.
|
||||
@@ -0,0 +1,19 @@
|
||||
ALTER TABLE creator_community
|
||||
ADD COLUMN language_code VARCHAR(10) NULL COMMENT '본문 원문 언어 코드',
|
||||
ADD COLUMN content_revision BIGINT NOT NULL DEFAULT 0 COMMENT '본문 개정 번호';
|
||||
|
||||
CREATE TABLE creator_community_translation (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '식별자',
|
||||
creator_community_id BIGINT NOT NULL COMMENT '크리에이터 커뮤니티 게시물 식별자',
|
||||
locale VARCHAR(10) NOT NULL COMMENT '표시 대상 언어 코드',
|
||||
content TEXT NOT NULL COMMENT '번역된 본문',
|
||||
source_revision BIGINT NOT NULL COMMENT '번역 기준 본문 개정 번호',
|
||||
source_hash VARCHAR(64) NOT NULL COMMENT '번역 기준 정규화 원문 해시',
|
||||
source_language VARCHAR(10) NOT NULL COMMENT '번역 기준 원문 언어 코드',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT uk_creator_community_translation_post_locale UNIQUE (creator_community_id, locale),
|
||||
CONSTRAINT fk_creator_community_translation_post
|
||||
FOREIGN KEY (creator_community_id) REFERENCES creator_community (id)
|
||||
) COMMENT = '크리에이터 커뮤니티 게시물 본문 번역';
|
||||
@@ -7,8 +7,11 @@ import kr.co.vividnext.sodalive.content.category.CategoryRepository
|
||||
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
|
||||
import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
@@ -22,8 +25,12 @@ import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.event.TransactionPhase
|
||||
import org.springframework.transaction.event.TransactionalEventListener
|
||||
import org.springframework.transaction.support.TransactionSynchronization
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
import org.springframework.util.LinkedMultiValueMap
|
||||
import org.springframework.web.client.RestTemplate
|
||||
import javax.persistence.EntityManager
|
||||
import javax.persistence.LockModeType
|
||||
|
||||
/**
|
||||
* 텍스트 기반 데이터(콘텐츠, 댓글 등)에 대해 파파고 언어 감지를 요청하기 위한 이벤트.
|
||||
@@ -37,13 +44,16 @@ enum class LanguageDetectTargetType {
|
||||
SERIES,
|
||||
ORIGINAL_WORK,
|
||||
|
||||
CREATOR_CONTENT_CATEGORY
|
||||
CREATOR_CONTENT_CATEGORY,
|
||||
CREATOR_COMMUNITY
|
||||
}
|
||||
|
||||
class LanguageDetectEvent(
|
||||
val id: Long,
|
||||
val query: String,
|
||||
val targetType: LanguageDetectTargetType = LanguageDetectTargetType.CONTENT
|
||||
val targetType: LanguageDetectTargetType = LanguageDetectTargetType.CONTENT,
|
||||
val sourceRevision: Long? = null,
|
||||
val targetLanguage: String? = null
|
||||
)
|
||||
|
||||
data class PapagoLanguageDetectResponse(
|
||||
@@ -60,7 +70,10 @@ class LanguageDetectListener(
|
||||
private val seriesRepository: ContentSeriesRepository,
|
||||
private val originalWorkRepository: OriginalWorkRepository,
|
||||
private val categoryRepository: CategoryRepository,
|
||||
private val creatorCommunityRepository: CreatorCommunityRepository,
|
||||
private val languageDetectionCacheService: LanguageDetectionCacheService,
|
||||
private val creatorCommunityTranslationService: CreatorCommunityTranslationService,
|
||||
private val entityManager: EntityManager,
|
||||
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
|
||||
@@ -95,6 +108,7 @@ class LanguageDetectListener(
|
||||
LanguageDetectTargetType.SERIES -> handleSeriesLanguageDetect(event)
|
||||
LanguageDetectTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageDetect(event)
|
||||
LanguageDetectTargetType.CREATOR_CONTENT_CATEGORY -> handleCreatorContentCategoryLanguageDetect(event)
|
||||
LanguageDetectTargetType.CREATOR_COMMUNITY -> handleCreatorCommunityLanguageDetect(event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,6 +380,53 @@ class LanguageDetectListener(
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleCreatorCommunityLanguageDetect(event: LanguageDetectEvent) {
|
||||
val sourceRevision = event.sourceRevision ?: return
|
||||
val creatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrue(event.id) ?: return
|
||||
if (
|
||||
creatorCommunity.contentRevision != sourceRevision ||
|
||||
creatorCommunity.content != event.query
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!creatorCommunity.languageCode.isNullOrBlank()) {
|
||||
requestTranslationsAfterCommit(event)
|
||||
return
|
||||
}
|
||||
|
||||
val languageCode = detectLanguageCode(event, event.id) ?: return
|
||||
if (languageCode !in getTranslatableLanguageCodes(null)) return
|
||||
|
||||
val currentCreatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(event.id) ?: return
|
||||
entityManager.refresh(currentCreatorCommunity, LockModeType.PESSIMISTIC_WRITE)
|
||||
if (
|
||||
currentCreatorCommunity.contentRevision != sourceRevision ||
|
||||
currentCreatorCommunity.content != event.query
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentCreatorCommunity.languageCode.isNullOrBlank()) {
|
||||
requestTranslationsAfterCommit(event)
|
||||
return
|
||||
}
|
||||
|
||||
currentCreatorCommunity.languageCode = languageCode
|
||||
creatorCommunityRepository.save(currentCreatorCommunity)
|
||||
requestTranslationsAfterCommit(event)
|
||||
}
|
||||
|
||||
private fun requestTranslationsAfterCommit(event: LanguageDetectEvent) {
|
||||
TransactionSynchronizationManager.registerSynchronization(
|
||||
object : TransactionSynchronization {
|
||||
override fun afterCommit() {
|
||||
creatorCommunityTranslationService.requestTranslations(event.id, event.targetLanguage)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun detectLanguageCode(event: LanguageDetectEvent, targetIdForLog: Long): String? {
|
||||
return languageDetectionCacheService.detectWithCache(event.query) {
|
||||
requestPapagoLanguageCode(event.query, targetIdForLog)
|
||||
|
||||
@@ -25,18 +25,22 @@ class LanguageDetectionCacheService(
|
||||
)
|
||||
if (cached != null) return cached.detectedLanguage
|
||||
|
||||
val detectedLanguage = detector()?.takeIf { it.isNotBlank() } ?: return null
|
||||
languageDetectionResultRepository.save(
|
||||
LanguageDetectionResult(
|
||||
val detectedLanguage = detector()?.takeIf { it.isNotBlank() }?.lowercase() ?: return null
|
||||
languageDetectionResultRepository.insertIfAbsent(
|
||||
sourceHash = sourceHash,
|
||||
sourceTextSample = normalizedQuery.take(MAX_SAMPLE_LENGTH),
|
||||
detectedLanguage = detectedLanguage,
|
||||
provider = provider,
|
||||
confidence = null,
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
return requireNotNull(
|
||||
languageDetectionResultRepository.findDetectedLanguageForUpdate(
|
||||
sourceHash = sourceHash,
|
||||
sourceTextSample = normalizedQuery.take(MAX_SAMPLE_LENGTH),
|
||||
detectedLanguage = detectedLanguage.lowercase(),
|
||||
provider = provider,
|
||||
confidence = null,
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
)
|
||||
return detectedLanguage.lowercase()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package kr.co.vividnext.sodalive.content
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Modifying
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
|
||||
interface LanguageDetectionResultRepository : JpaRepository<LanguageDetectionResult, Long> {
|
||||
fun findBySourceHashAndProviderAndNormalizationVersion(
|
||||
@@ -8,4 +11,55 @@ interface LanguageDetectionResultRepository : JpaRepository<LanguageDetectionRes
|
||||
provider: String,
|
||||
normalizationVersion: String
|
||||
): LanguageDetectionResult?
|
||||
|
||||
@Modifying
|
||||
@Query(
|
||||
value = """
|
||||
INSERT INTO language_detection_result (
|
||||
source_hash,
|
||||
source_text_sample,
|
||||
detected_language,
|
||||
provider,
|
||||
confidence,
|
||||
normalization_version,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
:sourceHash,
|
||||
:sourceTextSample,
|
||||
:detectedLanguage,
|
||||
:provider,
|
||||
:confidence,
|
||||
:normalizationVersion,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
) ON DUPLICATE KEY UPDATE id = id
|
||||
""",
|
||||
nativeQuery = true
|
||||
)
|
||||
fun insertIfAbsent(
|
||||
@Param("sourceHash") sourceHash: String,
|
||||
@Param("sourceTextSample") sourceTextSample: String,
|
||||
@Param("detectedLanguage") detectedLanguage: String,
|
||||
@Param("provider") provider: String,
|
||||
@Param("confidence") confidence: Double?,
|
||||
@Param("normalizationVersion") normalizationVersion: String
|
||||
): Int
|
||||
|
||||
@Query(
|
||||
value = """
|
||||
SELECT detected_language
|
||||
FROM language_detection_result
|
||||
WHERE source_hash = :sourceHash
|
||||
AND provider = :provider
|
||||
AND normalization_version = :normalizationVersion
|
||||
FOR UPDATE
|
||||
""",
|
||||
nativeQuery = true
|
||||
)
|
||||
fun findDetectedLanguageForUpdate(
|
||||
@Param("sourceHash") sourceHash: String,
|
||||
@Param("provider") provider: String,
|
||||
@Param("normalizationVersion") normalizationVersion: String
|
||||
): String?
|
||||
}
|
||||
|
||||
+5
-1
@@ -24,7 +24,11 @@ class CreatorCommunity(
|
||||
var isActive: Boolean = true,
|
||||
var isFixed: Boolean = false,
|
||||
@Column(nullable = true)
|
||||
var fixedAt: LocalDateTime? = null
|
||||
var fixedAt: LocalDateTime? = null,
|
||||
@Column(name = "language_code", nullable = true, length = 10)
|
||||
var languageCode: String? = null,
|
||||
@Column(name = "content_revision", nullable = false)
|
||||
var contentRevision: Long = 0
|
||||
) : BaseEntity() {
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "member_id", nullable = false)
|
||||
|
||||
+17
@@ -7,12 +7,29 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommun
|
||||
import kr.co.vividnext.sodalive.member.QMember.member
|
||||
import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Lock
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import javax.persistence.LockModeType
|
||||
|
||||
interface CreatorCommunityRepository : JpaRepository<CreatorCommunity, Long>, CreatorCommunityQueryRepository {
|
||||
fun countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(memberId: Long): Long
|
||||
|
||||
fun findByIdAndIsActiveTrue(postId: Long): CreatorCommunity?
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select c from CreatorCommunity c where c.id = :postId and c.isActive = true")
|
||||
fun findByIdAndIsActiveTrueForUpdate(@Param("postId") postId: Long): CreatorCommunity?
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select c from CreatorCommunity c where c.id = :postId and c.member.id = :memberId")
|
||||
fun findByIdAndMemberIdForUpdate(
|
||||
@Param("postId") postId: Long,
|
||||
@Param("memberId") memberId: Long
|
||||
): CreatorCommunity?
|
||||
}
|
||||
|
||||
interface CreatorCommunityQueryRepository {
|
||||
|
||||
+37
-10
@@ -27,6 +27,7 @@ import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
import kr.co.vividnext.sodalive.utils.validateImage
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
@@ -39,6 +40,8 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import javax.persistence.EntityManager
|
||||
import javax.persistence.LockModeType
|
||||
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
@@ -58,6 +61,8 @@ class CreatorCommunityService(
|
||||
private val messageSource: SodaMessageSource,
|
||||
private val langContext: LangContext,
|
||||
private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService,
|
||||
private val creatorCommunityTranslationService: CreatorCommunityTranslationService,
|
||||
private val entityManager: EntityManager,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val imageBucket: String,
|
||||
@@ -144,6 +149,7 @@ class CreatorCommunityService(
|
||||
)
|
||||
)
|
||||
publishCommunityPostCreatedAfterCommit(post, member)
|
||||
requestTranslationsAfterCommit(post.id!!)
|
||||
}
|
||||
|
||||
private fun publishCommunityPostCreatedAfterCommit(post: CreatorCommunity, member: Member) {
|
||||
@@ -166,6 +172,12 @@ class CreatorCommunityService(
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestTranslationsAfterCommit(postId: Long) {
|
||||
afterCommit {
|
||||
creatorCommunityTranslationService.requestTranslations(postId, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CreatorCommunity.newsContentPreview(): String {
|
||||
if (price <= 0) {
|
||||
return content
|
||||
@@ -199,13 +211,17 @@ class CreatorCommunityService(
|
||||
fun modifyCommunityPost(postImage: MultipartFile?, requestString: String, member: Member) {
|
||||
val request = objectMapper.readValue(requestString, ModifyCommunityPostRequest::class.java)
|
||||
|
||||
val post = repository.findByIdAndMemberId(id = request.creatorCommunityId, memberId = member.id!!)
|
||||
val post = repository.findByIdAndMemberIdForUpdate(postId = request.creatorCommunityId, memberId = member.id!!)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
entityManager.refresh(post, LockModeType.PESSIMISTIC_WRITE)
|
||||
|
||||
postImage?.let { validateImage(it, post.price > 0) }
|
||||
|
||||
if (request.content != null) {
|
||||
if (request.content != null && request.content != post.content) {
|
||||
post.content = request.content
|
||||
post.languageCode = null
|
||||
post.contentRevision += 1
|
||||
requestTranslationsAfterCommit(post.id!!)
|
||||
}
|
||||
|
||||
if (request.isCommentAvailable != null) {
|
||||
@@ -246,8 +262,9 @@ class CreatorCommunityService(
|
||||
|
||||
@Transactional
|
||||
fun updateCommunityPostFixed(request: UpdateCommunityPostFixedRequest, member: Member) {
|
||||
val post = repository.findByIdAndMemberId(id = request.postId, memberId = member.id!!)
|
||||
val post = repository.findByIdAndMemberIdForUpdate(postId = request.postId, memberId = member.id!!)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
entityManager.refresh(post, LockModeType.PESSIMISTIC_WRITE)
|
||||
|
||||
if (request.isFixed) {
|
||||
if (!post.isFixed) {
|
||||
@@ -285,6 +302,8 @@ class CreatorCommunityService(
|
||||
limit = limit,
|
||||
isAdult = isAdult
|
||||
)
|
||||
val displayContents = creatorCommunityTranslationService
|
||||
.findDisplayContents(postList.map { it.id }, langContext.lang.code)
|
||||
|
||||
return postList
|
||||
.map {
|
||||
@@ -337,7 +356,7 @@ class CreatorCommunityService(
|
||||
null
|
||||
}
|
||||
|
||||
it.toCommunityPostListResponse(
|
||||
it.copy(content = displayContents[it.id]?.content ?: it.content).toCommunityPostListResponse(
|
||||
imageHost = imageHost,
|
||||
audioUrl = audioUrl,
|
||||
date = it.date.getTimeAgoString(),
|
||||
@@ -376,6 +395,12 @@ class CreatorCommunityService(
|
||||
throw SodaException(message = String.format(messageTemplate, post.creatorNickname))
|
||||
}
|
||||
|
||||
val displayContent = creatorCommunityTranslationService
|
||||
.findDisplayContents(listOf(post.id), langContext.lang.code)[post.id]
|
||||
if (displayContent?.hasValidTranslation != true) {
|
||||
creatorCommunityTranslationService.requestTranslations(post.id, langContext.lang.code)
|
||||
}
|
||||
|
||||
val isLike = likeRepository.findByPostIdAndMemberId(postId = post.id, memberId = memberId)?.isActive ?: false
|
||||
val likeCount = likeRepository.totalCountCommunityPostLikeByPostId(post.id)
|
||||
val commentCount = if (post.isCommentAvailable) {
|
||||
@@ -417,7 +442,7 @@ class CreatorCommunityService(
|
||||
null
|
||||
}
|
||||
|
||||
return post.toCommunityPostListResponse(
|
||||
return post.copy(content = displayContent?.content ?: post.content).toCommunityPostListResponse(
|
||||
imageHost = imageHost,
|
||||
audioUrl = audioUrl,
|
||||
date = post.date.getTimeAgoString(),
|
||||
@@ -610,11 +635,13 @@ class CreatorCommunityService(
|
||||
isAdult: Boolean
|
||||
): List<GetCommunityPostListResponse> {
|
||||
val postList = repository.getLatestPostListFromCreatorsYouFollow(memberId = memberId, isAdult = isAdult)
|
||||
val visiblePostList = postList.filter {
|
||||
!isBlockedBetweenMembers(memberId = memberId, creatorId = it.creatorId)
|
||||
}
|
||||
val displayContents = creatorCommunityTranslationService
|
||||
.findDisplayContents(visiblePostList.map { it.id }, langContext.lang.code)
|
||||
|
||||
return postList
|
||||
.filter {
|
||||
!isBlockedBetweenMembers(memberId = memberId, creatorId = it.creatorId)
|
||||
}
|
||||
return visiblePostList
|
||||
.map {
|
||||
val isLike =
|
||||
likeRepository.findByPostIdAndMemberId(postId = it.id, memberId = memberId)?.isActive ?: false
|
||||
@@ -649,7 +676,7 @@ class CreatorCommunityService(
|
||||
|
||||
val existOrdered = useCanRepository.isExistCommunityPostOrdered(postId = it.id, memberId = memberId)
|
||||
|
||||
it.toCommunityPostListResponse(
|
||||
it.copy(content = displayContents[it.id]?.content ?: it.content).toCommunityPostListResponse(
|
||||
imageHost = imageHost,
|
||||
audioUrl = null,
|
||||
date = it.date.getTimeAgoString(),
|
||||
|
||||
+2
-1
@@ -18,7 +18,8 @@ enum class LanguageTranslationTargetType {
|
||||
|
||||
ORIGINAL_WORK,
|
||||
|
||||
CREATOR_CONTENT_CATEGORY
|
||||
CREATOR_CONTENT_CATEGORY,
|
||||
CREATOR_COMMUNITY
|
||||
}
|
||||
|
||||
class LanguageTranslationEvent(
|
||||
|
||||
+53
-1
@@ -18,8 +18,13 @@ import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslatio
|
||||
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.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslationRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import javax.persistence.EntityManager
|
||||
import javax.persistence.LockModeType
|
||||
|
||||
@Service
|
||||
class TranslationReadModelMaterializer(
|
||||
@@ -31,7 +36,10 @@ class TranslationReadModelMaterializer(
|
||||
private val seriesTranslationRepository: SeriesTranslationRepository,
|
||||
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
|
||||
private val originalWorkTranslationRepository: OriginalWorkTranslationRepository,
|
||||
private val categoryTranslationRepository: CategoryTranslationRepository
|
||||
private val categoryTranslationRepository: CategoryTranslationRepository,
|
||||
private val creatorCommunityRepository: CreatorCommunityRepository,
|
||||
private val creatorCommunityTranslationRepository: CreatorCommunityTranslationRepository,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@Transactional
|
||||
fun materialize(resourceType: LanguageTranslationTargetType, resourceId: Long, targetLanguage: String): Boolean {
|
||||
@@ -46,6 +54,9 @@ class TranslationReadModelMaterializer(
|
||||
LanguageTranslationTargetType.SERIES_GENRE -> upsertSeriesGenre(resourceId, targetLanguage, translations)
|
||||
LanguageTranslationTargetType.ORIGINAL_WORK -> upsertOriginalWork(resourceId, targetLanguage, translations)
|
||||
LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY -> upsertCategory(resourceId, targetLanguage, translations)
|
||||
LanguageTranslationTargetType.CREATOR_COMMUNITY -> {
|
||||
return upsertCreatorCommunity(source, targetLanguage.lowercase(), translations)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -180,6 +191,47 @@ class TranslationReadModelMaterializer(
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertCreatorCommunity(
|
||||
source: TranslationSource,
|
||||
targetLanguage: String,
|
||||
translations: Map<String, String>
|
||||
): Boolean {
|
||||
val sourceRevision = source.sourceRevision ?: return false
|
||||
val sourceHash = SourceTextNormalizer.hash(source.fields.single().sourceText)
|
||||
val creatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(source.resourceId) ?: return false
|
||||
entityManager.refresh(creatorCommunity, LockModeType.PESSIMISTIC_WRITE)
|
||||
if (
|
||||
!creatorCommunity.isActive ||
|
||||
creatorCommunity.contentRevision != sourceRevision ||
|
||||
creatorCommunity.languageCode?.lowercase() != source.sourceLanguage.lowercase() ||
|
||||
SourceTextNormalizer.hash(creatorCommunity.content) != sourceHash
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
val existing = creatorCommunityTranslationRepository
|
||||
.findByCreatorCommunityIdAndLocaleForUpdate(source.resourceId, targetLanguage)
|
||||
if (existing == null) {
|
||||
creatorCommunityTranslationRepository.save(
|
||||
CreatorCommunityTranslation(
|
||||
creatorCommunityId = source.resourceId,
|
||||
locale = targetLanguage,
|
||||
content = translations["content"].orEmpty(),
|
||||
sourceRevision = sourceRevision,
|
||||
sourceHash = sourceHash,
|
||||
sourceLanguage = source.sourceLanguage.lowercase()
|
||||
)
|
||||
)
|
||||
} else {
|
||||
existing.content = translations["content"].orEmpty()
|
||||
existing.sourceRevision = sourceRevision
|
||||
existing.sourceHash = sourceHash
|
||||
existing.sourceLanguage = source.sourceLanguage.lowercase()
|
||||
creatorCommunityTranslationRepository.save(existing)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_PROVIDER = "papago"
|
||||
}
|
||||
|
||||
+18
-2
@@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
|
||||
import kr.co.vividnext.sodalive.content.AudioContentRepository
|
||||
import kr.co.vividnext.sodalive.content.category.CategoryRepository
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@@ -19,7 +20,8 @@ data class TranslationSource(
|
||||
val resourceType: LanguageTranslationTargetType,
|
||||
val resourceId: Long,
|
||||
val sourceLanguage: String,
|
||||
val fields: List<TranslationSourceField>
|
||||
val fields: List<TranslationSourceField>,
|
||||
val sourceRevision: Long? = null
|
||||
)
|
||||
|
||||
@Component
|
||||
@@ -30,7 +32,8 @@ class TranslationSourceExtractor(
|
||||
private val seriesRepository: AdminContentSeriesRepository,
|
||||
private val seriesGenreRepository: AdminContentSeriesGenreRepository,
|
||||
private val originalWorkRepository: OriginalWorkRepository,
|
||||
private val categoryRepository: CategoryRepository
|
||||
private val categoryRepository: CategoryRepository,
|
||||
private val creatorCommunityRepository: CreatorCommunityRepository
|
||||
) {
|
||||
fun extract(resourceType: LanguageTranslationTargetType, resourceId: Long): TranslationSource? {
|
||||
return when (resourceType) {
|
||||
@@ -41,6 +44,7 @@ class TranslationSourceExtractor(
|
||||
LanguageTranslationTargetType.SERIES_GENRE -> extractSeriesGenre(resourceId)
|
||||
LanguageTranslationTargetType.ORIGINAL_WORK -> extractOriginalWork(resourceId)
|
||||
LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY -> extractCategory(resourceId)
|
||||
LanguageTranslationTargetType.CREATOR_COMMUNITY -> extractCreatorCommunity(resourceId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,4 +156,16 @@ class TranslationSourceExtractor(
|
||||
fields = listOf(TranslationSourceField("category", category.title))
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractCreatorCommunity(resourceId: Long): TranslationSource? {
|
||||
val creatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrue(resourceId) ?: return null
|
||||
val sourceLanguage = creatorCommunity.languageCode?.takeIf { it.isNotBlank() } ?: return null
|
||||
return TranslationSource(
|
||||
resourceType = LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
||||
resourceId = resourceId,
|
||||
sourceLanguage = sourceLanguage,
|
||||
fields = listOf(TranslationSourceField("content", creatorCommunity.content)),
|
||||
sourceRevision = creatorCommunity.contentRevision
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+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,
|
||||
|
||||
+23
-4
@@ -21,6 +21,7 @@ import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorCha
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -36,6 +37,7 @@ class CreatorChannelCommunityQueryService(
|
||||
private val audioContentCloudFront: AudioContentCloudFront,
|
||||
private val messageSource: SodaMessageSource,
|
||||
private val langContext: LangContext,
|
||||
private val creatorCommunityTranslationService: CreatorCommunityTranslationService,
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val cloudFrontHost: String
|
||||
) {
|
||||
@@ -69,6 +71,9 @@ class CreatorChannelCommunityQueryService(
|
||||
offset = communityPage.offset,
|
||||
limit = communityPage.fetchLimit
|
||||
)
|
||||
val communityPosts = queryPolicy.limitItems(fetchedPosts, communityPage)
|
||||
val displayContents = creatorCommunityTranslationService
|
||||
.findDisplayContents(communityPosts.map { it.postId }, langContext.lang.code)
|
||||
|
||||
return CreatorChannelCommunityTab(
|
||||
communityPostCount = queryPort.countCommunityPosts(
|
||||
@@ -76,7 +81,9 @@ class CreatorChannelCommunityQueryService(
|
||||
viewerId = viewerId,
|
||||
canViewAdultContent = canViewAdultContent
|
||||
),
|
||||
communityPosts = queryPolicy.limitItems(fetchedPosts, communityPage).map { it.toDomain(viewerId) },
|
||||
communityPosts = communityPosts.map {
|
||||
it.copy(content = displayContents[it.postId]?.content ?: it.content).toDomain(viewerId)
|
||||
},
|
||||
page = communityPage,
|
||||
hasNext = queryPolicy.hasNext(fetchedPosts, communityPage)
|
||||
)
|
||||
@@ -89,7 +96,7 @@ class CreatorChannelCommunityQueryService(
|
||||
canViewAdultContent: Boolean,
|
||||
limit: Int
|
||||
): List<CreatorChannelCommunityPost> {
|
||||
return queryPortProvider.getObject()
|
||||
val posts = queryPortProvider.getObject()
|
||||
.findHomeCommunityPosts(
|
||||
creatorId = creatorId,
|
||||
viewerId = viewerId,
|
||||
@@ -97,7 +104,12 @@ class CreatorChannelCommunityQueryService(
|
||||
canViewAdultContent = canViewAdultContent,
|
||||
limit = limit
|
||||
)
|
||||
.map { it.toDomain(viewerId) }
|
||||
val displayContents = creatorCommunityTranslationService
|
||||
.findDisplayContents(posts.map { it.postId }, langContext.lang.code)
|
||||
|
||||
return posts.map {
|
||||
it.copy(content = displayContents[it.postId]?.content ?: it.content).toDomain(viewerId)
|
||||
}
|
||||
}
|
||||
|
||||
fun getCommunityPostDetail(
|
||||
@@ -113,8 +125,15 @@ class CreatorChannelCommunityQueryService(
|
||||
|
||||
validateNotBlocked(queryPort, viewerId, postRecord.creatorId)
|
||||
|
||||
val locale = langContext.lang.code
|
||||
val displayContent = creatorCommunityTranslationService
|
||||
.findDisplayContents(listOf(postRecord.postId), locale)[postRecord.postId]
|
||||
if (displayContent?.hasValidTranslation != true) {
|
||||
creatorCommunityTranslationService.requestTranslations(postRecord.postId, locale)
|
||||
}
|
||||
|
||||
return CreatorChannelCommunityPostDetail(
|
||||
post = postRecord.toDomain(viewerId),
|
||||
post = postRecord.copy(content = displayContent?.content ?: postRecord.content).toDomain(viewerId),
|
||||
comments = getCommunityComments(postId, viewer, page = 0, size = 20, now = now)
|
||||
)
|
||||
}
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "creator_community_translation",
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(
|
||||
name = "uk_creator_community_translation_post_locale",
|
||||
columnNames = ["creator_community_id", "locale"]
|
||||
)
|
||||
]
|
||||
)
|
||||
class CreatorCommunityTranslation(
|
||||
@Column(name = "creator_community_id", nullable = false)
|
||||
val creatorCommunityId: Long,
|
||||
|
||||
@Column(nullable = false, length = 10)
|
||||
val locale: String,
|
||||
|
||||
@Column(nullable = false, columnDefinition = "text")
|
||||
var content: String,
|
||||
|
||||
@Column(name = "source_revision", nullable = false)
|
||||
var sourceRevision: Long,
|
||||
|
||||
@Column(name = "source_hash", nullable = false, length = 64)
|
||||
var sourceHash: String,
|
||||
|
||||
@Column(name = "source_language", nullable = false, length = 10)
|
||||
var sourceLanguage: String
|
||||
) : BaseEntity()
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Lock
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import javax.persistence.LockModeType
|
||||
|
||||
interface CreatorCommunityTranslationRepository : JpaRepository<CreatorCommunityTranslation, Long> {
|
||||
fun findByCreatorCommunityIdAndLocale(creatorCommunityId: Long, locale: String): CreatorCommunityTranslation?
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query(
|
||||
"select t from CreatorCommunityTranslation t " +
|
||||
"where t.creatorCommunityId = :creatorCommunityId and t.locale = :locale"
|
||||
)
|
||||
fun findByCreatorCommunityIdAndLocaleForUpdate(
|
||||
@Param("creatorCommunityId") creatorCommunityId: Long,
|
||||
@Param("locale") locale: String
|
||||
): CreatorCommunityTranslation?
|
||||
|
||||
fun findByCreatorCommunityIdInAndLocale(
|
||||
creatorCommunityIds: Collection<Long>,
|
||||
locale: String
|
||||
): List<CreatorCommunityTranslation>
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application
|
||||
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationReadModelMaterializer
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceExtractor
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslationRepository
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import javax.persistence.EntityManager
|
||||
import javax.persistence.LockModeType
|
||||
|
||||
@Service
|
||||
class CreatorCommunityTranslationService(
|
||||
private val creatorCommunityRepository: CreatorCommunityRepository,
|
||||
private val creatorCommunityTranslationRepository: CreatorCommunityTranslationRepository,
|
||||
private val sourceExtractor: TranslationSourceExtractor,
|
||||
private val materializer: TranslationReadModelMaterializer,
|
||||
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun findDisplayContents(postIds: List<Long>, locale: String): Map<Long, CreatorCommunityDisplayContent> {
|
||||
if (postIds.isEmpty()) return emptyMap()
|
||||
|
||||
val normalizedLocale = locale.lowercase()
|
||||
val posts = creatorCommunityRepository.findAllById(postIds).filter { it.isActive }
|
||||
val translations = creatorCommunityTranslationRepository
|
||||
.findByCreatorCommunityIdInAndLocale(posts.mapNotNull { it.id }, normalizedLocale)
|
||||
.associateBy { it.creatorCommunityId }
|
||||
|
||||
return posts.associate { post ->
|
||||
post.id!! to translations[post.id].displayContentOr(post, normalizedLocale)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
fun requestTranslations(postId: Long, targetLanguage: String? = null) {
|
||||
val creatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(postId) ?: return
|
||||
entityManager.refresh(creatorCommunity, LockModeType.PESSIMISTIC_WRITE)
|
||||
|
||||
val source = sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId)
|
||||
if (source == null) {
|
||||
requestLanguageDetection(postId, targetLanguage)
|
||||
return
|
||||
}
|
||||
|
||||
val sourceLanguage = source.sourceLanguage.lowercase()
|
||||
val targetLanguages = targetLanguage?.let { setOf(it.lowercase()) }
|
||||
?: getTranslatableLanguageCodes(sourceLanguage)
|
||||
|
||||
targetLanguages
|
||||
.filter { it != sourceLanguage }
|
||||
.forEach { language ->
|
||||
if (!materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, language)) {
|
||||
resourceTranslationJobScheduler.scheduleResourceTranslation(
|
||||
resourceType = LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
||||
resourceId = postId,
|
||||
targetLanguage = language
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestLanguageDetection(postId: Long, targetLanguage: String?) {
|
||||
val creatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrue(postId) ?: return
|
||||
if (!creatorCommunity.languageCode.isNullOrBlank() || creatorCommunity.content.isBlank()) return
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageDetectEvent(
|
||||
id = postId,
|
||||
query = creatorCommunity.content,
|
||||
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||
sourceRevision = creatorCommunity.contentRevision,
|
||||
targetLanguage = targetLanguage
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun CreatorCommunityTranslation?.displayContentOr(
|
||||
creatorCommunity: CreatorCommunity,
|
||||
locale: String
|
||||
): CreatorCommunityDisplayContent {
|
||||
val sourceLanguage = creatorCommunity.languageCode?.lowercase()
|
||||
return if (
|
||||
this != null &&
|
||||
sourceLanguage != null &&
|
||||
sourceLanguage != locale &&
|
||||
sourceRevision == creatorCommunity.contentRevision &&
|
||||
sourceLanguage == this.sourceLanguage.lowercase() &&
|
||||
sourceHash == SourceTextNormalizer.hash(creatorCommunity.content)
|
||||
) {
|
||||
CreatorCommunityDisplayContent(content = content, hasValidTranslation = true)
|
||||
} else {
|
||||
CreatorCommunityDisplayContent(content = creatorCommunity.content, hasValidTranslation = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class CreatorCommunityDisplayContent(
|
||||
val content: String,
|
||||
val hasValidTranslation: Boolean
|
||||
)
|
||||
+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>
|
||||
|
||||
|
||||
+22
-1
@@ -1,9 +1,12 @@
|
||||
package kr.co.vividnext.sodalive.v2.home.following.application
|
||||
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.member.Gender
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowing
|
||||
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingQueryPort
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -16,6 +19,8 @@ import java.time.LocalDateTime
|
||||
class HomeFollowingQueryService(
|
||||
private val queryPort: HomeFollowingQueryPort,
|
||||
private val memberContentPreferenceService: MemberContentPreferenceService,
|
||||
private val langContext: LangContext,
|
||||
private val creatorCommunityTranslationService: CreatorCommunityTranslationService,
|
||||
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(Clock.systemUTC()) }
|
||||
) {
|
||||
fun findHomeFollowing(member: Member): HomeFollowing {
|
||||
@@ -24,6 +29,15 @@ class HomeFollowingQueryService(
|
||||
val effectiveViewerGender = member.effectiveGender()
|
||||
val isViewerCreator = member.role == MemberRole.CREATOR
|
||||
val now = nowProvider()
|
||||
val recentNews = queryPort.findRecentNews(memberId, canViewAdultContent, now, RECENT_NEWS_LIMIT)
|
||||
val communityPostIds = recentNews.mapNotNull { news ->
|
||||
if (news.type == FollowingNewsType.COMMUNITY_POST) news.communityPost?.postId else null
|
||||
}
|
||||
val displayContents = if (communityPostIds.isEmpty()) {
|
||||
emptyMap()
|
||||
} else {
|
||||
creatorCommunityTranslationService.findDisplayContents(communityPostIds, langContext.lang.code)
|
||||
}
|
||||
|
||||
return HomeFollowing(
|
||||
followingCreators = queryPort.findFollowingCreators(memberId, FOLLOWING_CREATORS_LIMIT),
|
||||
@@ -43,7 +57,14 @@ class HomeFollowingQueryService(
|
||||
now,
|
||||
MONTHLY_SCHEDULES_LIMIT
|
||||
),
|
||||
recentNews = queryPort.findRecentNews(memberId, canViewAdultContent, now, RECENT_NEWS_LIMIT)
|
||||
recentNews = recentNews.map { news ->
|
||||
if (news.type != FollowingNewsType.COMMUNITY_POST) return@map news
|
||||
|
||||
val communityPost = news.communityPost ?: return@map news
|
||||
displayContents[communityPost.postId]
|
||||
?.let { displayContent -> news.copy(communityPost = communityPost.copy(content = displayContent.content)) }
|
||||
?: news
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+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(
|
||||
|
||||
+18
-3
@@ -1,7 +1,9 @@
|
||||
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.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
|
||||
@@ -29,7 +31,9 @@ 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 creatorCommunityTranslationService: CreatorCommunityTranslationService
|
||||
) {
|
||||
private val snapshotWindowPolicy = RecommendationSnapshotWindowPolicy()
|
||||
|
||||
@@ -76,7 +80,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(
|
||||
@@ -152,9 +156,20 @@ class HomeRecommendationQueryService(
|
||||
.associateBy { it.communityId }
|
||||
val selectedCreatorIds = mutableSetOf<Long>()
|
||||
|
||||
return snapshots.mapNotNull { snapshot ->
|
||||
val recommendations = snapshots.mapNotNull { snapshot ->
|
||||
detailsById[snapshot.targetId]?.takeIf { selectedCreatorIds.add(it.creatorId) }
|
||||
}.take(limit)
|
||||
if (recommendations.isEmpty()) return emptyList()
|
||||
|
||||
val displayContents = creatorCommunityTranslationService.findDisplayContents(
|
||||
recommendations.map { it.communityId },
|
||||
langContext.lang.code
|
||||
)
|
||||
return recommendations.map { recommendation ->
|
||||
displayContents[recommendation.communityId]
|
||||
?.let { displayContent -> recommendation.copy(content = displayContent.content) }
|
||||
?: recommendation
|
||||
}
|
||||
}
|
||||
|
||||
private fun findPopularCommunitySnapshotsWithFallback(): List<RecommendationSnapshotRecord> {
|
||||
|
||||
+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(
|
||||
|
||||
+813
@@ -0,0 +1,813 @@
|
||||
package kr.co.vividnext.sodalive.content
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||
import kr.co.vividnext.sodalive.can.use.UseCanRepository
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityCommentRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLikeRepository
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobRepository
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationMemory
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationMemoryRepository
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationReadModelMaterializer
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceExtractor
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslationRepository
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.aop.support.AopUtils
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.boot.test.context.TestConfiguration
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.context.annotation.Primary
|
||||
import org.springframework.scheduling.annotation.EnableAsync
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
|
||||
import org.springframework.test.util.AopTestUtils
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.Callable
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@DataJpaTest(
|
||||
properties = [
|
||||
"spring.cache.type=none",
|
||||
"spring.datasource.url=" +
|
||||
"jdbc:h2:mem:creator-community-language-detect;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE",
|
||||
"cloud.naver.papago-client-id=test-client-id",
|
||||
"cloud.naver.papago-client-secret=test-client-secret"
|
||||
]
|
||||
)
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import(
|
||||
QueryDslConfig::class,
|
||||
AudioContentThemeQueryRepository::class,
|
||||
LanguageDetectListener::class,
|
||||
CreatorCommunityTranslationService::class,
|
||||
TranslationSourceExtractor::class,
|
||||
TranslationReadModelMaterializer::class,
|
||||
ResourceTranslationJobScheduler::class,
|
||||
TranslationJobScheduler::class,
|
||||
CreatorCommunityLanguageDetectTest.AsyncConfiguration::class
|
||||
)
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
class CreatorCommunityLanguageDetectTest @Autowired constructor(
|
||||
private val creatorCommunityRepository: CreatorCommunityRepository,
|
||||
private val memberRepository: MemberRepository,
|
||||
private val translationJobRepository: TranslationJobRepository,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
private val languageDetectListener: LanguageDetectListener,
|
||||
private val creatorCommunityTranslationService: CreatorCommunityTranslationService,
|
||||
private val creatorCommunityTranslationRepository: CreatorCommunityTranslationRepository,
|
||||
private val creatorCommunityService: CreatorCommunityService,
|
||||
private val languageDetectionCacheService: ControlledLanguageDetectionCacheService,
|
||||
private val translationMemoryRepository: TranslationMemoryRepository,
|
||||
private val translationReadModelMaterializer: TranslationReadModelMaterializer,
|
||||
private val detailPipelineTaskExecutor: DetailPipelineTaskExecutor,
|
||||
transactionManager: PlatformTransactionManager
|
||||
) {
|
||||
private val transactionTemplate = TransactionTemplate(transactionManager)
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
languageDetectionCacheService.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 감지 이벤트는 기존 기본값을 유지하면서 현재 본문 메타데이터를 전달한다")
|
||||
fun shouldKeepLegacyDefaultsAndDeclareCreatorCommunityMetadata() {
|
||||
val fields = LanguageDetectEvent::class.java.declaredFields.map { it.name }
|
||||
|
||||
assertTrue(LanguageDetectTargetType.values().any { it.name == "CREATOR_COMMUNITY" })
|
||||
assertTrue(fields.contains("sourceRevision"))
|
||||
assertTrue(fields.contains("targetLanguage"))
|
||||
assertEquals(LanguageDetectTargetType.CONTENT, LanguageDetectEvent(1L, "legacy").targetType)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("감지 후 번역 요청 서비스는 독립 프록시 트랜잭션을 사용한다")
|
||||
fun shouldExposeProxiedRequiresNewTranslationRequestEntryPoint() {
|
||||
val annotation = AopUtils.getTargetClass(creatorCommunityTranslationService)
|
||||
.getMethod("requestTranslations", Long::class.javaPrimitiveType, String::class.java)
|
||||
.getAnnotation(Transactional::class.java)
|
||||
|
||||
assertTrue(AopUtils.isAopProxy(creatorCommunityTranslationService))
|
||||
assertEquals(Propagation.REQUIRES_NEW, annotation.propagation)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("상세 파이프라인 executor는 이전 작업 완료로 다음 작업 latch를 해제하지 않는다")
|
||||
fun shouldNotCompleteNextTaskWhenPreviousTaskFinishesAfterLatchIsReassigned() {
|
||||
val executor = DetailPipelineTaskExecutor()
|
||||
val firstStarted = CountDownLatch(1)
|
||||
val firstRelease = CountDownLatch(1)
|
||||
val secondStarted = CountDownLatch(1)
|
||||
val secondRelease = CountDownLatch(1)
|
||||
executor.initialize()
|
||||
|
||||
try {
|
||||
executor.prepareNextTask()
|
||||
executor.execute {
|
||||
firstStarted.countDown()
|
||||
check(firstRelease.await(5, TimeUnit.SECONDS))
|
||||
}
|
||||
assertTrue(firstStarted.await(5, TimeUnit.SECONDS))
|
||||
|
||||
executor.prepareNextTask()
|
||||
executor.execute {
|
||||
secondStarted.countDown()
|
||||
check(secondRelease.await(5, TimeUnit.SECONDS))
|
||||
}
|
||||
firstRelease.countDown()
|
||||
assertTrue(secondStarted.await(5, TimeUnit.SECONDS))
|
||||
|
||||
assertEquals(false, executor.awaitTaskCompletion(0, TimeUnit.MILLISECONDS))
|
||||
} finally {
|
||||
firstRelease.countDown()
|
||||
secondRelease.countDown()
|
||||
executor.shutdown()
|
||||
assertTrue(executor.threadPoolExecutor.awaitTermination(5, TimeUnit.SECONDS))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커밋 후 감지는 요청 언어의 번역 작업을 실제로 저장한다")
|
||||
fun shouldPersistRequestedTargetJobAfterCommittedDetection() {
|
||||
val postId = persistPost(content = "current source", contentRevision = 3)
|
||||
givenDetectedLanguage("current source", "ko")
|
||||
|
||||
publishAfterCommit(
|
||||
LanguageDetectEvent(
|
||||
id = postId,
|
||||
query = "current source",
|
||||
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||
sourceRevision = 3,
|
||||
targetLanguage = "ja"
|
||||
)
|
||||
)
|
||||
|
||||
assertJobTargets(postId, setOf("ja"))
|
||||
assertPost(postId, content = "current source", languageCode = "ko")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("상세 조회는 실제 프록시 감지와 작업 저장 뒤 재료화한 요청 언어 번역문을 반환한다")
|
||||
fun shouldRunDetailTranslationPipelineThroughProxyListenerAndScheduler() {
|
||||
val sourceContent = "detail source content"
|
||||
val postId = persistPost(content = sourceContent, contentRevision = 0, isCommentAvailable = false)
|
||||
val blockingDetection = languageDetectionCacheService.block(sourceContent, "en")
|
||||
detailPipelineTaskExecutor.prepareNextTask()
|
||||
|
||||
try {
|
||||
assertTrue(AopUtils.isAopProxy(creatorCommunityService))
|
||||
assertTrue(AopUtils.isAopProxy(creatorCommunityTranslationService))
|
||||
|
||||
val initialDetail = creatorCommunityService.getCommunityPostDetail(
|
||||
postId = postId,
|
||||
memberId = 999L,
|
||||
timezone = "Asia/Seoul",
|
||||
isAdult = true
|
||||
)
|
||||
|
||||
assertEquals(sourceContent, initialDetail.content)
|
||||
assertTrue(blockingDetection.started.await(5, TimeUnit.SECONDS))
|
||||
assertEquals(emptySet<String>(), jobTargets(postId))
|
||||
} finally {
|
||||
blockingDetection.release.countDown()
|
||||
}
|
||||
|
||||
assertTrue(detailPipelineTaskExecutor.awaitTaskCompletion(5, TimeUnit.SECONDS))
|
||||
assertPost(postId, content = sourceContent, languageCode = "en")
|
||||
assertEquals(setOf("ko"), jobTargets(postId))
|
||||
assertEquals(1, jobCount(postId))
|
||||
|
||||
val repeatedDetail = creatorCommunityService.getCommunityPostDetail(
|
||||
postId = postId,
|
||||
memberId = 999L,
|
||||
timezone = "Asia/Seoul",
|
||||
isAdult = true
|
||||
)
|
||||
assertEquals(sourceContent, repeatedDetail.content)
|
||||
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
try {
|
||||
val repeatedDetails = listOf(
|
||||
executor.submit(
|
||||
Callable {
|
||||
creatorCommunityService.getCommunityPostDetail(postId, 999L, "Asia/Seoul", true).content
|
||||
}
|
||||
),
|
||||
executor.submit(
|
||||
Callable {
|
||||
creatorCommunityService.getCommunityPostDetail(postId, 999L, "Asia/Seoul", true).content
|
||||
}
|
||||
)
|
||||
).map { it.get(5, TimeUnit.SECONDS) }
|
||||
|
||||
assertEquals(listOf(sourceContent, sourceContent), repeatedDetails)
|
||||
} finally {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
assertEquals(1, jobCount(postId))
|
||||
|
||||
transactionTemplate.executeWithoutResult {
|
||||
translationMemoryRepository.save(
|
||||
TranslationMemory(
|
||||
sourceHash = SourceTextNormalizer.hash(sourceContent),
|
||||
sourceText = sourceContent,
|
||||
sourceLanguage = "en",
|
||||
targetLanguage = "ko",
|
||||
translatedText = "translated detail content",
|
||||
provider = TranslationReadModelMaterializer.DEFAULT_PROVIDER,
|
||||
providerVersion = "nmt-v1"
|
||||
)
|
||||
)
|
||||
}
|
||||
assertTrue(
|
||||
translationReadModelMaterializer.materialize(
|
||||
LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
||||
postId,
|
||||
"ko"
|
||||
)
|
||||
)
|
||||
|
||||
val translatedDetail = creatorCommunityService.getCommunityPostDetail(
|
||||
postId = postId,
|
||||
memberId = 999L,
|
||||
timezone = "Asia/Seoul",
|
||||
isAdult = true
|
||||
)
|
||||
|
||||
assertEquals("translated detail content", translatedDetail.content)
|
||||
assertEquals(1, jobCount(postId))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("전체 범위 감지는 원문 언어를 제외한 실제 번역 작업을 저장한다")
|
||||
fun shouldPersistAllNonSourceTargetJobsAfterCommittedDetection() {
|
||||
val postId = persistPost(content = "English source", contentRevision = 1)
|
||||
givenDetectedLanguage("English source", "en")
|
||||
|
||||
publishAfterCommit(
|
||||
LanguageDetectEvent(
|
||||
id = postId,
|
||||
query = "English source",
|
||||
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||
sourceRevision = 1
|
||||
)
|
||||
)
|
||||
|
||||
assertJobTargets(postId, setOf("ja", "ko"))
|
||||
assertPost(postId, content = "English source", languageCode = "en")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("감지 중 변경된 본문은 refresh 뒤에도 이전 언어와 작업을 적용하지 않는다")
|
||||
fun shouldRejectStalePostAfterBlockedDetection() {
|
||||
val postId = persistPost(content = "before", contentRevision = 0)
|
||||
val blockingDetection = languageDetectionCacheService.block("before", "ko")
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
|
||||
try {
|
||||
val completion = executor.submit {
|
||||
detectInTransaction(
|
||||
LanguageDetectEvent(
|
||||
id = postId,
|
||||
query = "before",
|
||||
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||
sourceRevision = 0,
|
||||
targetLanguage = "ja"
|
||||
)
|
||||
)
|
||||
}
|
||||
assertTrue(blockingDetection.started.await(5, TimeUnit.SECONDS))
|
||||
updatePost(postId, content = "after", contentRevision = 1)
|
||||
blockingDetection.release.countDown()
|
||||
completion.get(5, TimeUnit.SECONDS)
|
||||
|
||||
assertPost(postId, content = "after", languageCode = null)
|
||||
assertEquals(emptySet<String>(), jobTargets(postId))
|
||||
} finally {
|
||||
blockingDetection.release.countDown()
|
||||
executor.shutdownNow()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("동시 감지 중 첫 감지가 언어를 저장해도 두 번째 요청 언어를 예약한다")
|
||||
fun shouldPreserveSecondTargetAfterConcurrentDetectionSetsLanguage() {
|
||||
val postId = persistPost(content = "concurrent source", contentRevision = 0)
|
||||
val concurrentDetection = languageDetectionCacheService.blockConcurrent("concurrent source", "ko")
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
|
||||
try {
|
||||
val first = executor.submit {
|
||||
detectInTransaction(
|
||||
LanguageDetectEvent(
|
||||
id = postId,
|
||||
query = "concurrent source",
|
||||
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||
sourceRevision = 0,
|
||||
targetLanguage = "ja"
|
||||
)
|
||||
)
|
||||
}
|
||||
assertTrue(concurrentDetection.firstStarted.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val second = executor.submit {
|
||||
detectInTransaction(
|
||||
LanguageDetectEvent(
|
||||
id = postId,
|
||||
query = "concurrent source",
|
||||
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||
sourceRevision = 0,
|
||||
targetLanguage = "en"
|
||||
)
|
||||
)
|
||||
}
|
||||
assertTrue(concurrentDetection.secondStarted.await(5, TimeUnit.SECONDS))
|
||||
|
||||
concurrentDetection.firstRelease.countDown()
|
||||
first.get(5, TimeUnit.SECONDS)
|
||||
concurrentDetection.secondRelease.countDown()
|
||||
second.get(5, TimeUnit.SECONDS)
|
||||
|
||||
assertPost(postId, content = "concurrent source", languageCode = "ko")
|
||||
assertJobTargets(postId, setOf("en", "ja"))
|
||||
} finally {
|
||||
concurrentDetection.firstRelease.countDown()
|
||||
concurrentDetection.secondRelease.countDown()
|
||||
executor.shutdownNow()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("언어가 이미 확정된 동시 목표 이벤트는 둘 다 번역 작업으로 이어진다")
|
||||
fun shouldContinueBothRequestedTargetsWhenLanguageIsAlreadyKnown() {
|
||||
val postId = persistPost(content = "known source", contentRevision = 2, languageCode = "ko")
|
||||
|
||||
publishAfterCommit(
|
||||
LanguageDetectEvent(
|
||||
id = postId,
|
||||
query = "known source",
|
||||
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||
sourceRevision = 2,
|
||||
targetLanguage = "ja"
|
||||
)
|
||||
)
|
||||
publishAfterCommit(
|
||||
LanguageDetectEvent(
|
||||
id = postId,
|
||||
query = "known source",
|
||||
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||
sourceRevision = 2,
|
||||
targetLanguage = "en"
|
||||
)
|
||||
)
|
||||
|
||||
assertJobTargets(postId, setOf("en", "ja"))
|
||||
assertTrue(languageDetectionCacheService.queries.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("빈 본문과 미지원 감지값과 감지 실패는 원문과 작업 상태를 유지한다")
|
||||
fun shouldKeepOriginalWhenDetectionCannotProduceSupportedLanguage() {
|
||||
val blankPostId = persistPost(content = " ", contentRevision = 0)
|
||||
val unsupportedPostId = persistPost(content = "unsupported", contentRevision = 0)
|
||||
val failedPostId = persistPost(content = "provider failure", contentRevision = 0)
|
||||
givenDetectedLanguage("unsupported", "fr")
|
||||
|
||||
publishAfterCommit(communityEvent(blankPostId, " ", 0))
|
||||
publishAfterCommit(communityEvent(unsupportedPostId, "unsupported", 0))
|
||||
publishAfterCommit(communityEvent(failedPostId, "provider failure", 0))
|
||||
|
||||
assertTrue(await { languageDetectionCacheService.queries.contains("unsupported") })
|
||||
assertTrue(await { languageDetectionCacheService.queries.contains("provider failure") })
|
||||
assertPost(blankPostId, content = " ", languageCode = null)
|
||||
assertPost(unsupportedPostId, content = "unsupported", languageCode = null)
|
||||
assertPost(failedPostId, content = "provider failure", languageCode = null)
|
||||
assertEquals(emptySet<String>(), jobTargets(blankPostId))
|
||||
assertEquals(emptySet<String>(), jobTargets(unsupportedPostId))
|
||||
assertEquals(emptySet<String>(), jobTargets(failedPostId))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("실제 프록시 생성 트랜잭션이 롤백되면 게시글과 번역 후속 작업을 남기지 않는다")
|
||||
fun shouldLeaveNoPostOrTranslationArtifactsWhenProxiedCreateRollsBack() {
|
||||
val creator = persistCreator()
|
||||
val content = "rolled back community ${UUID.randomUUID()}"
|
||||
val postId = transactionTemplate.execute { status ->
|
||||
creatorCommunityService.createCommunityPost(
|
||||
audioFile = null,
|
||||
postImage = null,
|
||||
requestString = createRequest(content),
|
||||
member = creator
|
||||
)
|
||||
val post = creatorCommunityRepository.findAll().single { it.content == content }
|
||||
status.setRollbackOnly()
|
||||
post.id!!
|
||||
}!!
|
||||
|
||||
assertTrue(AopUtils.isAopProxy(creatorCommunityService))
|
||||
assertTrue(transactionTemplate.execute { creatorCommunityRepository.findById(postId).isEmpty }!!)
|
||||
assertEquals(emptySet<String>(), jobTargets(postId))
|
||||
assertTrue(
|
||||
transactionTemplate.execute {
|
||||
creatorCommunityTranslationRepository.findAll().none { it.creatorCommunityId == postId }
|
||||
}!!
|
||||
)
|
||||
assertTrue(languageDetectionCacheService.queries.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("실제 영문 본문 수정은 이전 번역을 숨기고 커밋 후 영문 감지와 새 작업으로 이어진다")
|
||||
fun shouldHideStaleTranslationAndScheduleNewTargetsAfterCommittedEnglishModification() {
|
||||
val creator = persistCreator()
|
||||
val koreanContent = "기존 한국어 본문"
|
||||
val englishContent = "current English community source"
|
||||
val postId = transactionTemplate.execute {
|
||||
val post = creatorCommunityRepository.save(
|
||||
CreatorCommunity(
|
||||
content = koreanContent,
|
||||
price = 0,
|
||||
isCommentAvailable = true,
|
||||
isAdult = false,
|
||||
languageCode = "ko",
|
||||
contentRevision = 0
|
||||
).apply {
|
||||
member = creator
|
||||
}
|
||||
)
|
||||
creatorCommunityTranslationRepository.save(
|
||||
CreatorCommunityTranslation(
|
||||
creatorCommunityId = post.id!!,
|
||||
locale = "en",
|
||||
content = "old English translation",
|
||||
sourceRevision = 0,
|
||||
sourceHash = SourceTextNormalizer.hash(koreanContent),
|
||||
sourceLanguage = "ko"
|
||||
)
|
||||
)
|
||||
post.id!!
|
||||
}!!
|
||||
val blockingDetection = languageDetectionCacheService.block(englishContent, "en")
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
|
||||
try {
|
||||
val modification = executor.submit {
|
||||
transactionTemplate.executeWithoutResult {
|
||||
creatorCommunityService.modifyCommunityPost(
|
||||
postImage = null,
|
||||
requestString = modifyRequest(postId, englishContent),
|
||||
member = creator
|
||||
)
|
||||
}
|
||||
}
|
||||
assertTrue(blockingDetection.started.await(5, TimeUnit.SECONDS))
|
||||
|
||||
transactionTemplate.executeWithoutResult {
|
||||
val post = creatorCommunityRepository.findById(postId).orElseThrow()
|
||||
assertEquals(englishContent, post.content)
|
||||
assertNull(post.languageCode)
|
||||
assertEquals(1, post.contentRevision)
|
||||
assertEquals(
|
||||
englishContent,
|
||||
creatorCommunityTranslationService.findDisplayContents(listOf(postId), "en")[postId]?.content
|
||||
)
|
||||
assertEquals(
|
||||
"old English translation",
|
||||
creatorCommunityTranslationRepository
|
||||
.findByCreatorCommunityIdAndLocale(postId, "en")
|
||||
?.content
|
||||
)
|
||||
assertEquals(emptySet<String>(), jobTargets(postId))
|
||||
}
|
||||
|
||||
blockingDetection.release.countDown()
|
||||
modification.get(5, TimeUnit.SECONDS)
|
||||
|
||||
assertTrue(
|
||||
await {
|
||||
transactionTemplate.execute {
|
||||
creatorCommunityRepository.findById(postId).orElseThrow().languageCode == "en"
|
||||
} == true
|
||||
}
|
||||
)
|
||||
assertPost(postId, content = englishContent, languageCode = "en")
|
||||
assertJobTargets(postId, setOf("ja", "ko"))
|
||||
} finally {
|
||||
blockingDetection.release.countDown()
|
||||
executor.shutdownNow()
|
||||
}
|
||||
}
|
||||
|
||||
private fun communityEvent(postId: Long, content: String, sourceRevision: Long): LanguageDetectEvent {
|
||||
return LanguageDetectEvent(
|
||||
id = postId,
|
||||
query = content,
|
||||
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||
sourceRevision = sourceRevision
|
||||
)
|
||||
}
|
||||
|
||||
private fun givenDetectedLanguage(content: String, detectedLanguage: String) {
|
||||
languageDetectionCacheService.detections[content] = detectedLanguage
|
||||
}
|
||||
|
||||
private fun createRequest(content: String): String {
|
||||
return """{"content":"$content","price":0,"isCommentAvailable":true,"isAdult":false}"""
|
||||
}
|
||||
|
||||
private fun modifyRequest(postId: Long, content: String): String {
|
||||
return """{"creatorCommunityId":$postId,"content":"$content"}"""
|
||||
}
|
||||
|
||||
private fun publishAfterCommit(event: LanguageDetectEvent) {
|
||||
transactionTemplate.executeWithoutResult {
|
||||
applicationEventPublisher.publishEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
private fun detectInTransaction(event: LanguageDetectEvent) {
|
||||
val listener: LanguageDetectListener = AopTestUtils.getTargetObject(languageDetectListener)
|
||||
transactionTemplate.executeWithoutResult {
|
||||
listener.detectLanguage(event)
|
||||
}
|
||||
}
|
||||
|
||||
private fun persistPost(
|
||||
content: String,
|
||||
contentRevision: Long,
|
||||
languageCode: String? = null,
|
||||
isCommentAvailable: Boolean = true
|
||||
): Long {
|
||||
return transactionTemplate.execute {
|
||||
val member = memberRepository.save(
|
||||
Member(
|
||||
password = "password",
|
||||
nickname = "language-detect-${UUID.randomUUID()}",
|
||||
role = MemberRole.CREATOR
|
||||
)
|
||||
)
|
||||
creatorCommunityRepository.save(
|
||||
CreatorCommunity(
|
||||
content = content,
|
||||
price = 0,
|
||||
isCommentAvailable = isCommentAvailable,
|
||||
isAdult = false,
|
||||
languageCode = languageCode,
|
||||
contentRevision = contentRevision
|
||||
).apply {
|
||||
this.member = member
|
||||
}
|
||||
).id!!
|
||||
}!!
|
||||
}
|
||||
|
||||
private fun persistCreator(): Member {
|
||||
return transactionTemplate.execute {
|
||||
memberRepository.save(
|
||||
Member(
|
||||
password = "password",
|
||||
nickname = "community-service-${UUID.randomUUID()}",
|
||||
role = MemberRole.CREATOR
|
||||
)
|
||||
)
|
||||
}!!
|
||||
}
|
||||
|
||||
private fun updatePost(postId: Long, content: String, contentRevision: Long) {
|
||||
transactionTemplate.executeWithoutResult {
|
||||
val post = creatorCommunityRepository.findById(postId).orElseThrow()
|
||||
post.content = content
|
||||
post.contentRevision = contentRevision
|
||||
post.languageCode = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertPost(postId: Long, content: String, languageCode: String?) {
|
||||
val post = transactionTemplate.execute {
|
||||
creatorCommunityRepository.findById(postId).orElseThrow()
|
||||
}!!
|
||||
|
||||
assertEquals(content, post.content)
|
||||
if (languageCode == null) {
|
||||
assertNull(post.languageCode)
|
||||
} else {
|
||||
assertEquals(languageCode, post.languageCode)
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertJobTargets(postId: Long, expectedTargets: Set<String>) {
|
||||
assertTrue(await { jobTargets(postId) == expectedTargets })
|
||||
}
|
||||
|
||||
private fun jobTargets(postId: Long): Set<String> {
|
||||
return transactionTemplate.execute {
|
||||
translationJobRepository.findAll()
|
||||
.filter {
|
||||
it.resourceType == LanguageTranslationTargetType.CREATOR_COMMUNITY && it.resourceId == postId
|
||||
}
|
||||
.map { it.targetLanguage }
|
||||
.toSet()
|
||||
}.orEmpty()
|
||||
}
|
||||
|
||||
private fun jobCount(postId: Long): Int {
|
||||
return transactionTemplate.execute {
|
||||
translationJobRepository.findAll().count {
|
||||
it.resourceType == LanguageTranslationTargetType.CREATOR_COMMUNITY && it.resourceId == postId
|
||||
}
|
||||
}!!
|
||||
}
|
||||
|
||||
private fun await(condition: () -> Boolean): Boolean {
|
||||
repeat(50) {
|
||||
if (condition()) return true
|
||||
Thread.sleep(100)
|
||||
}
|
||||
return condition()
|
||||
}
|
||||
|
||||
@TestConfiguration
|
||||
@EnableAsync
|
||||
class AsyncConfiguration {
|
||||
@Bean(name = ["taskExecutor"])
|
||||
fun taskExecutor(): DetailPipelineTaskExecutor {
|
||||
return DetailPipelineTaskExecutor()
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
fun languageDetectionCacheService(
|
||||
languageDetectionResultRepository: LanguageDetectionResultRepository
|
||||
): ControlledLanguageDetectionCacheService {
|
||||
return ControlledLanguageDetectionCacheService(languageDetectionResultRepository)
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun creatorCommunityService(
|
||||
repository: CreatorCommunityRepository,
|
||||
blockMemberRepository: BlockMemberRepository,
|
||||
likeRepository: CreatorCommunityLikeRepository,
|
||||
commentRepository: CreatorCommunityCommentRepository,
|
||||
useCanRepository: UseCanRepository,
|
||||
applicationEventPublisher: ApplicationEventPublisher,
|
||||
creatorCommunityTranslationService: CreatorCommunityTranslationService,
|
||||
entityManager: EntityManager
|
||||
): CreatorCommunityService {
|
||||
return CreatorCommunityService(
|
||||
canPaymentService = Mockito.mock(CanPaymentService::class.java),
|
||||
repository = repository,
|
||||
blockMemberRepository = blockMemberRepository,
|
||||
likeRepository = likeRepository,
|
||||
commentRepository = commentRepository,
|
||||
useCanRepository = useCanRepository,
|
||||
s3Uploader = Mockito.mock(S3Uploader::class.java),
|
||||
objectMapper = ObjectMapper().registerModule(KotlinModule.Builder().build()),
|
||||
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java),
|
||||
applicationEventPublisher = applicationEventPublisher,
|
||||
messageSource = SodaMessageSource(),
|
||||
langContext = LangContext(),
|
||||
homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java),
|
||||
creatorCommunityTranslationService = creatorCommunityTranslationService,
|
||||
entityManager = entityManager,
|
||||
imageBucket = "test-image-bucket",
|
||||
contentBucket = "test-content-bucket",
|
||||
imageHost = "https://test.cloudfront.net"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ControlledLanguageDetectionCacheService(
|
||||
languageDetectionResultRepository: LanguageDetectionResultRepository
|
||||
) : LanguageDetectionCacheService(languageDetectionResultRepository) {
|
||||
val detections = ConcurrentHashMap<String, String>()
|
||||
val queries = ConcurrentLinkedQueue<String>()
|
||||
private val blockingDetections = ConcurrentHashMap<String, BlockingDetection>()
|
||||
private val concurrentDetections = ConcurrentHashMap<String, ConcurrentDetection>()
|
||||
|
||||
fun clear() {
|
||||
detections.clear()
|
||||
queries.clear()
|
||||
blockingDetections.clear()
|
||||
concurrentDetections.clear()
|
||||
}
|
||||
|
||||
fun block(query: String, detectedLanguage: String): BlockingDetection {
|
||||
return BlockingDetection(detectedLanguage).also { blockingDetections[query] = it }
|
||||
}
|
||||
|
||||
fun blockConcurrent(query: String, detectedLanguage: String): ConcurrentDetection {
|
||||
return ConcurrentDetection(detectedLanguage).also { concurrentDetections[query] = it }
|
||||
}
|
||||
|
||||
override fun detectWithCache(query: String, provider: String, detector: () -> String?): String? {
|
||||
queries.add(query)
|
||||
val concurrentDetection = concurrentDetections[query]
|
||||
if (concurrentDetection != null) {
|
||||
return concurrentDetection.detect()
|
||||
}
|
||||
val blockingDetection = blockingDetections[query]
|
||||
if (blockingDetection != null) {
|
||||
blockingDetection.started.countDown()
|
||||
check(blockingDetection.release.await(5, TimeUnit.SECONDS))
|
||||
return blockingDetection.detectedLanguage
|
||||
}
|
||||
return detections[query]
|
||||
}
|
||||
}
|
||||
|
||||
class BlockingDetection(val detectedLanguage: String) {
|
||||
val started = CountDownLatch(1)
|
||||
val release = CountDownLatch(1)
|
||||
}
|
||||
|
||||
class ConcurrentDetection(private val detectedLanguage: String) {
|
||||
private val order = AtomicInteger()
|
||||
val firstStarted = CountDownLatch(1)
|
||||
val secondStarted = CountDownLatch(1)
|
||||
val firstRelease = CountDownLatch(1)
|
||||
val secondRelease = CountDownLatch(1)
|
||||
|
||||
fun detect(): String {
|
||||
when (order.incrementAndGet()) {
|
||||
1 -> {
|
||||
firstStarted.countDown()
|
||||
check(firstRelease.await(5, TimeUnit.SECONDS))
|
||||
}
|
||||
|
||||
2 -> {
|
||||
secondStarted.countDown()
|
||||
check(secondRelease.await(5, TimeUnit.SECONDS))
|
||||
}
|
||||
}
|
||||
return detectedLanguage
|
||||
}
|
||||
}
|
||||
|
||||
class DetailPipelineTaskExecutor : ThreadPoolTaskExecutor() {
|
||||
private val completion = AtomicReference(CountDownLatch(0))
|
||||
|
||||
init {
|
||||
corePoolSize = 1
|
||||
maxPoolSize = 1
|
||||
setThreadNamePrefix("detail-pipeline-")
|
||||
setTaskDecorator { task ->
|
||||
val taskCompletion = completion.get()
|
||||
Runnable {
|
||||
try {
|
||||
task.run()
|
||||
} finally {
|
||||
taskCompletion.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareNextTask() {
|
||||
completion.set(CountDownLatch(1))
|
||||
}
|
||||
|
||||
fun awaitTaskCompletion(timeout: Long, unit: TimeUnit): Boolean {
|
||||
return completion.get().await(timeout, unit)
|
||||
}
|
||||
}
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
package kr.co.vividnext.sodalive.content
|
||||
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobRepository
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobStatus
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationReadModelMaterializer
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceExtractor
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.aop.support.AopUtils
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.client.ClientHttpRequest
|
||||
import org.springframework.http.client.ClientHttpRequestFactory
|
||||
import org.springframework.http.client.ClientHttpResponse
|
||||
import org.springframework.test.util.AopTestUtils
|
||||
import org.springframework.test.util.ReflectionTestUtils
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import org.springframework.web.client.RestTemplate
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.URI
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.Callable
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.ExecutorCompletionService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@DataJpaTest(
|
||||
properties = [
|
||||
"spring.cache.type=none",
|
||||
"spring.datasource.url=" +
|
||||
"jdbc:h2:mem:creator-community-language-detection-cache-concurrency;" +
|
||||
"MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE",
|
||||
"cloud.naver.papago-client-id=test-client-id",
|
||||
"cloud.naver.papago-client-secret=test-client-secret"
|
||||
]
|
||||
)
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import(
|
||||
QueryDslConfig::class,
|
||||
AudioContentThemeQueryRepository::class,
|
||||
LanguageDetectionCacheService::class,
|
||||
LanguageDetectListener::class,
|
||||
CreatorCommunityTranslationService::class,
|
||||
TranslationSourceExtractor::class,
|
||||
TranslationReadModelMaterializer::class,
|
||||
ResourceTranslationJobScheduler::class,
|
||||
TranslationJobScheduler::class
|
||||
)
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
class CreatorCommunityLanguageDetectionCacheConcurrencyTest @Autowired constructor(
|
||||
private val creatorCommunityRepository: CreatorCommunityRepository,
|
||||
private val memberRepository: MemberRepository,
|
||||
private val languageDetectionResultRepository: LanguageDetectionResultRepository,
|
||||
private val translationJobRepository: TranslationJobRepository,
|
||||
private val languageDetectionCacheService: LanguageDetectionCacheService,
|
||||
private val languageDetectListener: LanguageDetectListener,
|
||||
transactionManager: PlatformTransactionManager
|
||||
) {
|
||||
private val requiresNewTransactionTemplate = TransactionTemplate(transactionManager).apply {
|
||||
propagationBehavior = Propagation.REQUIRES_NEW.value()
|
||||
}
|
||||
private lateinit var papagoRequests: ControlledPapagoRequestFactory
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
papagoRequests = ControlledPapagoRequestFactory()
|
||||
val listener: LanguageDetectListener = AopTestUtils.getTargetObject(languageDetectListener)
|
||||
ReflectionTestUtils.setField(listener, "restTemplate", RestTemplate(papagoRequests))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최초 감지 캐시 저장 경합에서도 두 요청 언어의 번역 작업을 보존한다")
|
||||
fun shouldPreserveBothTargetsWhenInitialDetectionCacheWritesRace() {
|
||||
val content = "initial detection cache race ${UUID.randomUUID()}"
|
||||
val contentRevision = 7L
|
||||
val postId = persistPost(content, contentRevision)
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
val completions = ExecutorCompletionService<Unit>(executor)
|
||||
|
||||
try {
|
||||
assertTrue(AopUtils.isAopProxy(languageDetectionCacheService))
|
||||
completions.submit(detectionTask(postId, content, contentRevision, "ja"))
|
||||
completions.submit(detectionTask(postId, content, contentRevision, "en"))
|
||||
|
||||
assertTrue(
|
||||
papagoRequests.firstProviderEntry.await(5, TimeUnit.SECONDS),
|
||||
"first cache miss did not reach the provider boundary"
|
||||
)
|
||||
assertTrue(
|
||||
papagoRequests.secondProviderEntry.await(5, TimeUnit.SECONDS),
|
||||
"second cache miss did not reach the provider boundary"
|
||||
)
|
||||
|
||||
papagoRequests.firstProviderRelease.countDown()
|
||||
val firstCompletion = completions.poll(5, TimeUnit.SECONDS)
|
||||
?: throw AssertionError("first detection transaction did not complete")
|
||||
firstCompletion.get(5, TimeUnit.SECONDS)
|
||||
|
||||
papagoRequests.secondProviderRelease.countDown()
|
||||
val secondCompletion = completions.poll(5, TimeUnit.SECONDS)
|
||||
?: throw AssertionError("second detection transaction did not complete")
|
||||
secondCompletion.get(5, TimeUnit.SECONDS)
|
||||
|
||||
assertCacheRow(content)
|
||||
assertPostAndJobs(postId, content, contentRevision)
|
||||
} finally {
|
||||
papagoRequests.releaseAll()
|
||||
executor.shutdownNow()
|
||||
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("서로 다른 게시물의 동일 본문 최초 감지 경합에서도 각각의 전체 번역 작업을 보존한다")
|
||||
fun shouldPreserveAllNonSourceTargetsForDifferentPostsWithSameContentDuringInitialDetectionRace() {
|
||||
val content = "shared initial detection cache race ${UUID.randomUUID()}"
|
||||
val contentRevision = 7L
|
||||
val firstPostId = persistPost(content, contentRevision)
|
||||
val secondPostId = persistPost(content, contentRevision)
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
val completions = ExecutorCompletionService<Unit>(executor)
|
||||
|
||||
try {
|
||||
assertTrue(AopUtils.isAopProxy(languageDetectionCacheService))
|
||||
completions.submit(detectionTask(firstPostId, content, contentRevision, null))
|
||||
completions.submit(detectionTask(secondPostId, content, contentRevision, null))
|
||||
|
||||
assertTrue(
|
||||
papagoRequests.firstProviderEntry.await(5, TimeUnit.SECONDS),
|
||||
"first cache miss did not reach the provider boundary"
|
||||
)
|
||||
assertTrue(
|
||||
papagoRequests.secondProviderEntry.await(5, TimeUnit.SECONDS),
|
||||
"second cache miss did not reach the provider boundary"
|
||||
)
|
||||
|
||||
papagoRequests.firstProviderRelease.countDown()
|
||||
val firstCompletion = completions.poll(5, TimeUnit.SECONDS)
|
||||
?: throw AssertionError("first detection transaction did not complete")
|
||||
firstCompletion.get(5, TimeUnit.SECONDS)
|
||||
|
||||
papagoRequests.secondProviderRelease.countDown()
|
||||
val secondCompletion = completions.poll(5, TimeUnit.SECONDS)
|
||||
?: throw AssertionError("second detection transaction did not complete")
|
||||
secondCompletion.get(5, TimeUnit.SECONDS)
|
||||
|
||||
assertCacheRow(content)
|
||||
assertPostAndJobs(firstPostId, content, contentRevision)
|
||||
assertPostAndJobs(secondPostId, content, contentRevision)
|
||||
} finally {
|
||||
papagoRequests.releaseAll()
|
||||
executor.shutdownNow()
|
||||
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS))
|
||||
}
|
||||
}
|
||||
|
||||
private fun detectionTask(
|
||||
postId: Long,
|
||||
content: String,
|
||||
contentRevision: Long,
|
||||
targetLanguage: String?
|
||||
): Callable<Unit> {
|
||||
return Callable {
|
||||
val listener: LanguageDetectListener = AopTestUtils.getTargetObject(languageDetectListener)
|
||||
requiresNewTransactionTemplate.executeWithoutResult {
|
||||
listener.detectLanguage(
|
||||
LanguageDetectEvent(
|
||||
id = postId,
|
||||
query = content,
|
||||
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||
sourceRevision = contentRevision,
|
||||
targetLanguage = targetLanguage
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun persistPost(content: String, contentRevision: Long): Long {
|
||||
return requiresNewTransactionTemplate.execute {
|
||||
val member = memberRepository.save(
|
||||
Member(
|
||||
password = "password",
|
||||
nickname = "cache-race-${UUID.randomUUID()}",
|
||||
role = MemberRole.CREATOR
|
||||
)
|
||||
)
|
||||
creatorCommunityRepository.save(
|
||||
CreatorCommunity(
|
||||
content = content,
|
||||
price = 0,
|
||||
isCommentAvailable = true,
|
||||
isAdult = false,
|
||||
languageCode = null,
|
||||
contentRevision = contentRevision
|
||||
).apply {
|
||||
this.member = member
|
||||
}
|
||||
).id!!
|
||||
}!!
|
||||
}
|
||||
|
||||
private fun assertCacheRow(content: String) {
|
||||
val rows = requiresNewTransactionTemplate.execute {
|
||||
languageDetectionResultRepository.findAll().filter {
|
||||
it.sourceHash == SourceTextNormalizer.hash(content) &&
|
||||
it.provider == LanguageDetectionCacheService.DEFAULT_PROVIDER &&
|
||||
it.normalizationVersion == SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
}
|
||||
}.orEmpty()
|
||||
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals("ko", rows.single().detectedLanguage)
|
||||
}
|
||||
|
||||
private fun assertPostAndJobs(postId: Long, content: String, contentRevision: Long) {
|
||||
val post = requiresNewTransactionTemplate.execute {
|
||||
creatorCommunityRepository.findById(postId).orElseThrow()
|
||||
}!!
|
||||
val jobs = requiresNewTransactionTemplate.execute {
|
||||
translationJobRepository.findAll().filter {
|
||||
it.resourceType == LanguageTranslationTargetType.CREATOR_COMMUNITY &&
|
||||
it.resourceId == postId &&
|
||||
it.fieldKey == "content"
|
||||
}
|
||||
}.orEmpty()
|
||||
|
||||
assertEquals(content, post.content)
|
||||
assertEquals(contentRevision, post.contentRevision)
|
||||
assertEquals("ko", post.languageCode)
|
||||
assertEquals(2, jobs.size)
|
||||
assertEquals(setOf("ja", "en"), jobs.map { it.targetLanguage }.toSet())
|
||||
assertTrue(
|
||||
jobs.all { job ->
|
||||
job.sourceHash == SourceTextNormalizer.hash(content) &&
|
||||
job.sourceLanguage == "ko"
|
||||
}
|
||||
)
|
||||
assertTrue(jobs.all { it.status == TranslationJobStatus.PENDING })
|
||||
}
|
||||
|
||||
private class ControlledPapagoRequestFactory : ClientHttpRequestFactory {
|
||||
private val requestOrder = AtomicInteger()
|
||||
val firstProviderEntry = CountDownLatch(1)
|
||||
val secondProviderEntry = CountDownLatch(1)
|
||||
val firstProviderRelease = CountDownLatch(1)
|
||||
val secondProviderRelease = CountDownLatch(1)
|
||||
|
||||
override fun createRequest(uri: URI, httpMethod: HttpMethod): ClientHttpRequest {
|
||||
return object : ClientHttpRequest {
|
||||
private val headers = HttpHeaders()
|
||||
private val body = ByteArrayOutputStream()
|
||||
|
||||
override fun getMethodValue(): String = httpMethod.name
|
||||
|
||||
override fun getURI(): URI = uri
|
||||
|
||||
override fun getHeaders(): HttpHeaders = headers
|
||||
|
||||
override fun getBody(): OutputStream = body
|
||||
|
||||
override fun execute(): ClientHttpResponse {
|
||||
val languageCode = when (requestOrder.incrementAndGet()) {
|
||||
1 -> {
|
||||
firstProviderEntry.countDown()
|
||||
firstProviderRelease.await()
|
||||
"ko"
|
||||
}
|
||||
|
||||
2 -> {
|
||||
secondProviderEntry.countDown()
|
||||
secondProviderRelease.await()
|
||||
"en"
|
||||
}
|
||||
|
||||
else -> error("unexpected provider request")
|
||||
}
|
||||
return languageDetectionResponse(languageCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun releaseAll() {
|
||||
firstProviderRelease.countDown()
|
||||
secondProviderRelease.countDown()
|
||||
}
|
||||
|
||||
private fun languageDetectionResponse(languageCode: String): ClientHttpResponse {
|
||||
return object : ClientHttpResponse {
|
||||
private val headers = HttpHeaders().apply {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
}
|
||||
|
||||
override fun getStatusCode(): HttpStatus = HttpStatus.OK
|
||||
|
||||
override fun getRawStatusCode(): Int = HttpStatus.OK.value()
|
||||
|
||||
override fun getStatusText(): String = "OK"
|
||||
|
||||
override fun getHeaders(): HttpHeaders = headers
|
||||
|
||||
override fun getBody() = ByteArrayInputStream("{\"langCode\":\"$languageCode\"}".toByteArray())
|
||||
|
||||
override fun close() = Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
-1
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.content
|
||||
|
||||
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
|
||||
@@ -37,6 +38,67 @@ class LanguageDetectionCacheServiceTest {
|
||||
|
||||
assertEquals("en", detected)
|
||||
assertEquals(0, providerCalls)
|
||||
Mockito.verify(repository, Mockito.never()).save(Mockito.any(LanguageDetectionResult::class.java))
|
||||
Mockito.verify(repository).findBySourceHashAndProviderAndNormalizationVersion(
|
||||
sourceHash = sourceHash,
|
||||
provider = "papago",
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
Mockito.verifyNoMoreInteractions(repository)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최초 캐시 저장 경합에서는 저장된 승자의 감지 언어를 반환한다")
|
||||
fun shouldReturnStoredWinnerAfterInitialCacheInsert() {
|
||||
val repository = Mockito.mock(LanguageDetectionResultRepository::class.java)
|
||||
val service = LanguageDetectionCacheService(repository)
|
||||
val sourceHash = SourceTextNormalizer.hash("Hello world")
|
||||
|
||||
Mockito.`when`(
|
||||
repository.findBySourceHashAndProviderAndNormalizationVersion(
|
||||
sourceHash = sourceHash,
|
||||
provider = "papago",
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
).thenReturn(null)
|
||||
Mockito.`when`(
|
||||
repository.insertIfAbsent(
|
||||
sourceHash = sourceHash,
|
||||
sourceTextSample = "Hello world",
|
||||
detectedLanguage = "en",
|
||||
provider = "papago",
|
||||
confidence = null,
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
).thenReturn(0)
|
||||
Mockito.`when`(
|
||||
repository.findDetectedLanguageForUpdate(
|
||||
sourceHash = sourceHash,
|
||||
provider = "papago",
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
).thenReturn("ko")
|
||||
|
||||
val detected = service.detectWithCache("Hello world") { "en" }
|
||||
|
||||
assertEquals("ko", detected)
|
||||
Mockito.verify(repository).findBySourceHashAndProviderAndNormalizationVersion(
|
||||
sourceHash = sourceHash,
|
||||
provider = "papago",
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
Mockito.verify(repository).insertIfAbsent(
|
||||
sourceHash = sourceHash,
|
||||
sourceTextSample = "Hello world",
|
||||
detectedLanguage = "en",
|
||||
provider = "papago",
|
||||
confidence = null,
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
Mockito.verify(repository).findDetectedLanguageForUpdate(
|
||||
sourceHash = sourceHash,
|
||||
provider = "papago",
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
Mockito.verifyNoMoreInteractions(repository)
|
||||
}
|
||||
}
|
||||
|
||||
+344
-5
@@ -17,11 +17,14 @@ import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
|
||||
import kr.co.vividnext.sodalive.i18n.Lang
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityDisplayContent
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
@@ -36,10 +39,13 @@ import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.support.TransactionSynchronization
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import java.io.InputStream
|
||||
import java.time.LocalDateTime
|
||||
import java.util.Optional
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
class CreatorCommunityServiceTest {
|
||||
private lateinit var repository: CreatorCommunityRepository
|
||||
@@ -50,6 +56,9 @@ class CreatorCommunityServiceTest {
|
||||
private lateinit var s3Uploader: S3Uploader
|
||||
private lateinit var applicationEventPublisher: ApplicationEventPublisher
|
||||
private lateinit var homeFollowingNewsPublishService: HomeFollowingNewsPublishService
|
||||
private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService
|
||||
private lateinit var entityManager: EntityManager
|
||||
private lateinit var langContext: LangContext
|
||||
private lateinit var service: CreatorCommunityService
|
||||
|
||||
@BeforeEach
|
||||
@@ -62,6 +71,9 @@ class CreatorCommunityServiceTest {
|
||||
s3Uploader = Mockito.mock(S3Uploader::class.java)
|
||||
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||
homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
|
||||
creatorCommunityTranslationService = Mockito.mock(CreatorCommunityTranslationService::class.java)
|
||||
entityManager = Mockito.mock(EntityManager::class.java)
|
||||
langContext = LangContext()
|
||||
|
||||
service = CreatorCommunityService(
|
||||
canPaymentService = Mockito.mock(CanPaymentService::class.java),
|
||||
@@ -75,8 +87,10 @@ class CreatorCommunityServiceTest {
|
||||
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java),
|
||||
applicationEventPublisher = applicationEventPublisher,
|
||||
messageSource = SodaMessageSource(),
|
||||
langContext = LangContext(),
|
||||
langContext = langContext,
|
||||
homeFollowingNewsPublishService = homeFollowingNewsPublishService,
|
||||
creatorCommunityTranslationService = creatorCommunityTranslationService,
|
||||
entityManager = entityManager,
|
||||
imageBucket = "image-bucket",
|
||||
contentBucket = "content-bucket",
|
||||
imageHost = "https://cdn.test"
|
||||
@@ -252,7 +266,7 @@ class CreatorCommunityServiceTest {
|
||||
post.id = 501L
|
||||
post.member = creator
|
||||
|
||||
Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post)
|
||||
Mockito.`when`(repository.findByIdAndMemberIdForUpdate(post.id!!, creator.id!!)).thenReturn(post)
|
||||
Mockito.`when`(repository.countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(creator.id!!)).thenReturn(3L)
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
@@ -273,7 +287,7 @@ class CreatorCommunityServiceTest {
|
||||
post.id = 601L
|
||||
post.member = creator
|
||||
|
||||
Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post)
|
||||
Mockito.`when`(repository.findByIdAndMemberIdForUpdate(post.id!!, creator.id!!)).thenReturn(post)
|
||||
Mockito.`when`(repository.countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(creator.id!!)).thenReturn(2L)
|
||||
|
||||
service.updateCommunityPostFixed(
|
||||
@@ -295,7 +309,7 @@ class CreatorCommunityServiceTest {
|
||||
post.isFixed = true
|
||||
post.fixedAt = LocalDateTime.now()
|
||||
|
||||
Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post)
|
||||
Mockito.`when`(repository.findByIdAndMemberIdForUpdate(post.id!!, creator.id!!)).thenReturn(post)
|
||||
|
||||
service.updateCommunityPostFixed(
|
||||
request = UpdateCommunityPostFixedRequest(postId = post.id!!, isFixed = false),
|
||||
@@ -306,6 +320,120 @@ class CreatorCommunityServiceTest {
|
||||
assertNull(post.fixedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("본문이 실제로 변경되면 언어를 초기화하고 본문 개정을 한 번 증가시킨다")
|
||||
fun shouldResetLanguageAndIncrementRevisionWhenContentChanges() {
|
||||
val creator = createMember(id = 304L, role = MemberRole.CREATOR, nickname = "creator")
|
||||
val post = CreatorCommunity(
|
||||
content = "before",
|
||||
price = 0,
|
||||
isCommentAvailable = true,
|
||||
isAdult = false,
|
||||
languageCode = "ko",
|
||||
contentRevision = 7
|
||||
).apply {
|
||||
id = 304L
|
||||
member = creator
|
||||
}
|
||||
Mockito.`when`(repository.findByIdAndMemberIdForUpdate(post.id!!, creator.id!!)).thenReturn(post)
|
||||
|
||||
service.modifyCommunityPost(
|
||||
postImage = null,
|
||||
requestString = """{"creatorCommunityId":304,"content":"after"}""",
|
||||
member = creator
|
||||
)
|
||||
|
||||
assertEquals("after", post.content)
|
||||
assertNull(post.languageCode)
|
||||
assertEquals(8, post.contentRevision)
|
||||
Mockito.verify(creatorCommunityTranslationService).requestTranslations(post.id!!, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("동일 본문과 본문 외 수정은 언어와 개정 및 번역 요청을 유지한다")
|
||||
fun shouldNotResetLanguageOrRequestTranslationsWhenContentIsUnchanged() {
|
||||
val creator = createMember(id = 305L, role = MemberRole.CREATOR, nickname = "creator")
|
||||
val post = CreatorCommunity(
|
||||
content = "same",
|
||||
price = 0,
|
||||
isCommentAvailable = true,
|
||||
isAdult = false,
|
||||
languageCode = "ko",
|
||||
contentRevision = 7
|
||||
).apply {
|
||||
id = 305L
|
||||
member = creator
|
||||
}
|
||||
Mockito.`when`(repository.findByIdAndMemberIdForUpdate(post.id!!, creator.id!!)).thenReturn(post)
|
||||
Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post)
|
||||
|
||||
service.modifyCommunityPost(
|
||||
postImage = null,
|
||||
requestString = """
|
||||
{"creatorCommunityId":305,"content":"same","isCommentAvailable":false,"isAdult":true,"isActive":false}
|
||||
""".trimIndent(),
|
||||
member = creator
|
||||
)
|
||||
service.updateCommunityPostFixed(UpdateCommunityPostFixedRequest(post.id!!, isFixed = false), creator)
|
||||
|
||||
assertEquals("ko", post.languageCode)
|
||||
assertEquals(7, post.contentRevision)
|
||||
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("게시글 생성은 커밋 후에만 번역을 요청한다")
|
||||
fun shouldRequestTranslationsAfterCommunityPostCreationCommits() {
|
||||
val creator = createMember(id = 306L, role = MemberRole.CREATOR, nickname = "creator")
|
||||
Mockito.`when`(repository.save(Mockito.any(CreatorCommunity::class.java))).thenAnswer { invocation ->
|
||||
invocation.getArgument<CreatorCommunity>(0).apply { id = 306L }
|
||||
}
|
||||
TransactionSynchronizationManager.initSynchronization()
|
||||
|
||||
try {
|
||||
service.createCommunityPost(
|
||||
audioFile = null,
|
||||
postImage = null,
|
||||
requestString = """{"content":"created","price":0,"isCommentAvailable":true,"isAdult":false}""",
|
||||
member = creator
|
||||
)
|
||||
|
||||
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
|
||||
TransactionSynchronizationManager.getSynchronizations().forEach { it.afterCommit() }
|
||||
|
||||
Mockito.verify(creatorCommunityTranslationService).requestTranslations(306L, null)
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("게시글 생성 롤백은 번역을 요청하지 않는다")
|
||||
fun shouldNotRequestTranslationsWhenCommunityPostCreationRollsBack() {
|
||||
val creator = createMember(id = 307L, role = MemberRole.CREATOR, nickname = "creator")
|
||||
Mockito.`when`(repository.save(Mockito.any(CreatorCommunity::class.java))).thenAnswer { invocation ->
|
||||
invocation.getArgument<CreatorCommunity>(0).apply { id = 307L }
|
||||
}
|
||||
TransactionSynchronizationManager.initSynchronization()
|
||||
|
||||
try {
|
||||
service.createCommunityPost(
|
||||
audioFile = null,
|
||||
postImage = null,
|
||||
requestString = """{"content":"rolled back","price":0,"isCommentAvailable":true,"isAdult":false}""",
|
||||
member = creator
|
||||
)
|
||||
|
||||
TransactionSynchronizationManager.getSynchronizations().forEach {
|
||||
it.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK)
|
||||
}
|
||||
|
||||
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 게시글 생성 성공 후 최근 소식을 게시글 정보로 발행한다")
|
||||
fun shouldPublishNewsAfterCommunityPostCreated() {
|
||||
@@ -329,7 +457,7 @@ class CreatorCommunityServiceTest {
|
||||
Mockito.verify(homeFollowingNewsPublishService).publishCommunityPostCreated(
|
||||
postId = 901L,
|
||||
creatorId = creator.id!!,
|
||||
creatorNickname = creator.nickname!!,
|
||||
creatorNickname = creator.nickname,
|
||||
creatorProfileImagePath = creator.profileImage,
|
||||
title = "커뮤니티 새 게시글 본문입니다",
|
||||
body = "커뮤니티 새 게시글 본문입니다",
|
||||
@@ -369,6 +497,195 @@ class CreatorCommunityServiceTest {
|
||||
Mockito.verifyNoInteractions(homeFollowingNewsPublishService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("레거시 상세는 선택한 번역문에 유료 미리보기를 적용하고 요청 언어만 예약한다")
|
||||
fun shouldSelectTranslatedContentBeforePaidMaskingAndRequestCurrentLanguageForLegacyDetail() {
|
||||
langContext.setLang(Lang.JA)
|
||||
val post = selectCommunityPost(postId = 100L, content = "source paid content", price = 100)
|
||||
Mockito.`when`(repository.getCommunityPost(100L, true)).thenReturn(post)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(100L), "ja"))
|
||||
.thenReturn(mapOf(100L to CreatorCommunityDisplayContent("translated paid content", true)))
|
||||
|
||||
val response = service.getCommunityPostDetail(100L, memberId = 200L, timezone = "Asia/Seoul", isAdult = true)
|
||||
|
||||
assertEquals("translated paid...", response.content)
|
||||
Mockito.verify(creatorCommunityTranslationService).findDisplayContents(listOf(100L), "ja")
|
||||
Mockito.verify(creatorCommunityTranslationService, Mockito.never()).requestTranslations(100L, "ja")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("레거시 상세는 원문과 같은 유효 번역문에도 누락 예약을 요청하지 않는다")
|
||||
fun shouldNotRequestTranslationsWhenLegacyDetailHasValidTranslationMatchingSourceText() {
|
||||
langContext.setLang(Lang.JA)
|
||||
val post = selectCommunityPost(postId = 104L, content = "same content", price = 0)
|
||||
Mockito.`when`(repository.getCommunityPost(104L, true)).thenReturn(post)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(104L), "ja"))
|
||||
.thenReturn(mapOf(104L to CreatorCommunityDisplayContent("same content", true)))
|
||||
|
||||
service.getCommunityPostDetail(104L, memberId = 200L, timezone = "Asia/Seoul", isAdult = true)
|
||||
|
||||
Mockito.verify(creatorCommunityTranslationService, Mockito.never()).requestTranslations(104L, "ja")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("레거시 상세는 번역이 없으면 현재 원문을 반환하고 요청 언어만 예약한다")
|
||||
fun shouldReturnOriginalAndRequestCurrentLanguageWhenLegacyDetailTranslationIsMissing() {
|
||||
langContext.setLang(Lang.JA)
|
||||
val post = selectCommunityPost(postId = 101L, content = "current original", price = 0)
|
||||
Mockito.`when`(repository.getCommunityPost(101L, true)).thenReturn(post)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(101L), "ja"))
|
||||
.thenReturn(emptyMap())
|
||||
|
||||
val response = service.getCommunityPostDetail(101L, memberId = 200L, timezone = "Asia/Seoul", isAdult = true)
|
||||
|
||||
assertEquals("current original", response.content)
|
||||
Mockito.verify(creatorCommunityTranslationService).requestTranslations(101L, "ja")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("레거시 상세는 구매자에게 선택한 유료 번역문 전체를 반환한다")
|
||||
fun shouldReturnFullTranslatedContentToLegacyDetailBuyer() {
|
||||
langContext.setLang(Lang.JA)
|
||||
val post = selectCommunityPost(postId = 102L, content = "source paid content", price = 100)
|
||||
Mockito.`when`(repository.getCommunityPost(102L, true)).thenReturn(post)
|
||||
Mockito.`when`(useCanRepository.isExistCommunityPostOrdered(102L, 200L)).thenReturn(true)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(102L), "ja"))
|
||||
.thenReturn(mapOf(102L to CreatorCommunityDisplayContent("translated paid content", true)))
|
||||
|
||||
val response = service.getCommunityPostDetail(102L, memberId = 200L, timezone = "Asia/Seoul", isAdult = true)
|
||||
|
||||
assertEquals("translated paid content", response.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("레거시 상세는 소유자에게 선택한 유료 번역문 전체를 반환한다")
|
||||
fun shouldReturnFullTranslatedContentToLegacyDetailOwner() {
|
||||
langContext.setLang(Lang.JA)
|
||||
val post = selectCommunityPost(postId = 103L, creatorId = 200L, content = "source paid content", price = 100)
|
||||
Mockito.`when`(repository.getCommunityPost(103L, true)).thenReturn(post)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(103L), "ja"))
|
||||
.thenReturn(mapOf(103L to CreatorCommunityDisplayContent("translated paid content", true)))
|
||||
|
||||
val response = service.getCommunityPostDetail(103L, memberId = 200L, timezone = "Asia/Seoul", isAdult = true)
|
||||
|
||||
assertEquals("translated paid content", response.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("레거시 목록은 페이지 본문을 한 번에 번역 선택한 뒤 기존 유료 미리보기를 적용한다")
|
||||
fun shouldUseStoredTranslationsOnceForLegacyCommunityListBeforePaidMasking() {
|
||||
langContext.setLang(Lang.JA)
|
||||
val posts = listOf(
|
||||
selectCommunityPost(postId = 201L, content = "source free", price = 0),
|
||||
selectCommunityPost(postId = 202L, content = "source paid", price = 100),
|
||||
selectCommunityPost(postId = 203L, content = "source buyer", price = 100),
|
||||
selectCommunityPost(postId = 204L, creatorId = 200L, content = "source owner", price = 100),
|
||||
selectCommunityPost(postId = 205L, content = "source stale", price = 0)
|
||||
)
|
||||
val postIds = posts.map { it.id }
|
||||
Mockito.`when`(repository.getCommunityPostList(1L, 200L, 0L, 20L, true)).thenReturn(posts)
|
||||
Mockito.`when`(useCanRepository.isExistCommunityPostOrdered(203L, 200L)).thenReturn(true)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(postIds, "ja"))
|
||||
.thenReturn(
|
||||
mapOf(
|
||||
201L to CreatorCommunityDisplayContent("translated free", true),
|
||||
202L to CreatorCommunityDisplayContent("가나다라마바사아자차카타파하🙂끝", true),
|
||||
203L to CreatorCommunityDisplayContent("translated buyer", true),
|
||||
204L to CreatorCommunityDisplayContent("translated owner", true),
|
||||
205L to CreatorCommunityDisplayContent("source stale", false)
|
||||
)
|
||||
)
|
||||
|
||||
val responses = service.getCommunityPostList(
|
||||
creatorId = 1L,
|
||||
memberId = 200L,
|
||||
timezone = "Asia/Seoul",
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
isAdult = true
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"translated free",
|
||||
"가나다라마바사아자차카타파하🙂...",
|
||||
"translated buyer",
|
||||
"translated owner",
|
||||
"source stale"
|
||||
),
|
||||
responses.map { it.content }
|
||||
)
|
||||
Mockito.verify(creatorCommunityTranslationService).findDisplayContents(postIds, "ja")
|
||||
Mockito.verifyNoMoreInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("레거시 최신 목록은 차단 필터 뒤 남은 본문만 한 번에 번역 선택하고 작업을 만들지 않는다")
|
||||
fun shouldUseStoredTranslationsForVisibleLatestCommunityPostsWithoutScheduling() {
|
||||
langContext.setLang(Lang.JA)
|
||||
val visiblePost = selectCommunityPost(postId = 206L, content = "source visible", price = 0)
|
||||
val blockedPost = selectCommunityPost(postId = 207L, creatorId = 2L, content = "source blocked", price = 0)
|
||||
Mockito.`when`(repository.getLatestPostListFromCreatorsYouFollow(200L, true))
|
||||
.thenReturn(listOf(visiblePost, blockedPost))
|
||||
Mockito.`when`(blockMemberRepository.isBlocked(200L, 2L)).thenReturn(true)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(206L), "ja"))
|
||||
.thenReturn(mapOf(206L to CreatorCommunityDisplayContent("translated visible", true)))
|
||||
|
||||
val responses = service.getLatestPostListFromCreatorsYouFollow(
|
||||
timezone = "Asia/Seoul",
|
||||
memberId = 200L,
|
||||
isAdult = true
|
||||
)
|
||||
|
||||
assertEquals(listOf("translated visible"), responses.map { it.content })
|
||||
Mockito.verify(creatorCommunityTranslationService).findDisplayContents(listOf(206L), "ja")
|
||||
Mockito.verifyNoMoreInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("차단된 레거시 상세은 번역 조회나 예약을 시작하지 않는다")
|
||||
fun shouldNotScheduleTranslationsWhenLegacyDetailIsBlocked() {
|
||||
val post = selectCommunityPost(postId = 104L, content = "blocked", price = 0)
|
||||
Mockito.`when`(repository.getCommunityPost(104L, true)).thenReturn(post)
|
||||
Mockito.`when`(blockMemberRepository.isBlocked(200L, 1L)).thenReturn(true)
|
||||
|
||||
assertThrows(SodaException::class.java) {
|
||||
service.getCommunityPostDetail(104L, memberId = 200L, timezone = "Asia/Seoul", isAdult = true)
|
||||
}
|
||||
|
||||
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("비활성 레거시 상세은 번역 조회나 예약을 시작하지 않는다")
|
||||
fun shouldNotScheduleTranslationsWhenLegacyDetailIsInactive() {
|
||||
Mockito.`when`(repository.getCommunityPost(105L, true)).thenReturn(null)
|
||||
|
||||
assertThrows(SodaException::class.java) {
|
||||
service.getCommunityPostDetail(105L, memberId = 200L, timezone = "Asia/Seoul", isAdult = true)
|
||||
}
|
||||
|
||||
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("레거시 댓글과 구매 응답은 번역 예약 진입점이 아니다")
|
||||
fun shouldNotScheduleTranslationsFromLegacyCommentOrPurchaseResponses() {
|
||||
val creator = createMember(id = 1L, role = MemberRole.CREATOR, nickname = "creator")
|
||||
val post = CreatorCommunity(content = "original", price = 100, isCommentAvailable = false, isAdult = false).apply {
|
||||
id = 106L
|
||||
createdAt = LocalDateTime.of(2026, 9, 10, 10, 0)
|
||||
member = creator
|
||||
}
|
||||
Mockito.`when`(repository.findByIdAndActive(106L, true)).thenReturn(post)
|
||||
Mockito.`when`(useCanRepository.isExistCommunityPostOrdered(106L, 200L)).thenReturn(true)
|
||||
|
||||
service.getCommunityPostCommentList(106L, 200L, "Asia/Seoul", 0, 20, true)
|
||||
service.purchasePost(106L, 200L, "Asia/Seoul", true, "test")
|
||||
|
||||
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 소식 발행 실패는 커뮤니티 게시글 생성을 실패시키지 않는다")
|
||||
fun shouldNotFailCommunityPostCreationWhenNewsPublishFails() {
|
||||
@@ -457,4 +774,26 @@ class CreatorCommunityServiceTest {
|
||||
member.id = id
|
||||
return member
|
||||
}
|
||||
|
||||
private fun selectCommunityPost(
|
||||
postId: Long,
|
||||
creatorId: Long = 1L,
|
||||
content: String,
|
||||
price: Int
|
||||
): SelectCommunityPostResponse {
|
||||
return SelectCommunityPostResponse(
|
||||
id = postId,
|
||||
creatorId = creatorId,
|
||||
creatorNickname = "creator-$creatorId",
|
||||
creatorProfileUrl = "profile/$creatorId.png",
|
||||
imagePath = null,
|
||||
audioPath = null,
|
||||
content = content,
|
||||
date = LocalDateTime.of(2026, 9, 10, 10, 0),
|
||||
isCommentAvailable = false,
|
||||
price = price,
|
||||
isAdult = false,
|
||||
isFixed = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
package kr.co.vividnext.sodalive.explorer.profile.creatorCommunity
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||
import kr.co.vividnext.sodalive.can.use.UseCanRepository
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityCommentRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLikeRepository
|
||||
import kr.co.vividnext.sodalive.i18n.Lang
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobRepository
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationReadModelMaterializer
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationSource
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceExtractor
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceField
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslationRepository
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
|
||||
import org.hibernate.SessionFactory
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.context.annotation.Import
|
||||
import javax.persistence.EntityManager
|
||||
import javax.persistence.EntityManagerFactory
|
||||
|
||||
@DataJpaTest(
|
||||
properties = [
|
||||
"spring.cache.type=none",
|
||||
"spring.datasource.url=" +
|
||||
"jdbc:h2:mem:creator-community-detail-translation;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE",
|
||||
"spring.jpa.properties.hibernate.generate_statistics=true"
|
||||
]
|
||||
)
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import(QueryDslConfig::class)
|
||||
class CreatorCommunityTranslationEndToEndTest @Autowired constructor(
|
||||
private val creatorCommunityRepository: CreatorCommunityRepository,
|
||||
private val creatorCommunityTranslationRepository: CreatorCommunityTranslationRepository,
|
||||
private val memberRepository: MemberRepository,
|
||||
private val entityManager: EntityManager,
|
||||
private val entityManagerFactory: EntityManagerFactory,
|
||||
private val translationJobRepository: TranslationJobRepository
|
||||
) {
|
||||
@Test
|
||||
@DisplayName("실제 번역 읽기는 작은 배치와 큰 배치에서 게시글과 번역을 각각 한 번씩 조회한다")
|
||||
fun shouldReadTranslationsWithConstantSelectCountAcrossBatchSizes() {
|
||||
val postIds = (1..4).map { index ->
|
||||
persistPost(content = "batch source $index", languageCode = "ko", price = 0).id!!
|
||||
}
|
||||
val translationService = CreatorCommunityTranslationService(
|
||||
creatorCommunityRepository = creatorCommunityRepository,
|
||||
creatorCommunityTranslationRepository = creatorCommunityTranslationRepository,
|
||||
sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java),
|
||||
materializer = Mockito.mock(TranslationReadModelMaterializer::class.java),
|
||||
resourceTranslationJobScheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java),
|
||||
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java),
|
||||
entityManager = entityManager
|
||||
)
|
||||
val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
statistics.clear()
|
||||
translationService.findDisplayContents(postIds.take(1), "ja")
|
||||
val smallBatchStatementCount = statistics.prepareStatementCount
|
||||
|
||||
entityManager.clear()
|
||||
statistics.clear()
|
||||
translationService.findDisplayContents(postIds, "ja")
|
||||
val largeBatchStatementCount = statistics.prepareStatementCount
|
||||
|
||||
assertEquals(2L, smallBatchStatementCount)
|
||||
assertEquals(2L, largeBatchStatementCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("실제 레거시 목록 조회는 번역이 없어도 감지와 작업을 만들지 않는다")
|
||||
fun shouldNotPublishOrScheduleWhenLegacyListReadsMissingTranslation() {
|
||||
val post = persistPost(content = "missing translation", languageCode = null, price = 0)
|
||||
val publisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
val scheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
|
||||
translationJobRepository.deleteAll()
|
||||
entityManager.flush()
|
||||
val initialJobCount = translationJobRepository.count()
|
||||
|
||||
val responses = legacyService(
|
||||
sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java),
|
||||
materializer = materializer,
|
||||
publisher = publisher,
|
||||
scheduler = scheduler
|
||||
).getCommunityPostList(
|
||||
creatorId = post.member!!.id!!,
|
||||
memberId = 999L,
|
||||
timezone = "Asia/Seoul",
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
isAdult = true
|
||||
)
|
||||
|
||||
assertEquals(listOf("missing translation"), responses.map { it.content })
|
||||
assertEquals(initialJobCount, translationJobRepository.count())
|
||||
Mockito.verifyNoInteractions(publisher, materializer, scheduler)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("레거시 상세는 저장된 현재 번역문을 유료 마스킹 전에 사용한다")
|
||||
fun shouldUseStoredTranslationBeforeLegacyPaidMasking() {
|
||||
val post = persistPost(content = "source paid content", languageCode = "ko", price = 100)
|
||||
creatorCommunityTranslationRepository.save(
|
||||
CreatorCommunityTranslation(
|
||||
creatorCommunityId = post.id!!,
|
||||
locale = "ja",
|
||||
content = "translated paid content",
|
||||
sourceRevision = post.contentRevision,
|
||||
sourceHash = SourceTextNormalizer.hash(post.content),
|
||||
sourceLanguage = "ko"
|
||||
)
|
||||
)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, post.id!!))
|
||||
.thenReturn(translationSource(post.id!!, post.content, "ko", post.contentRevision))
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
Mockito.`when`(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, post.id!!, "ja"))
|
||||
.thenReturn(true)
|
||||
|
||||
val response = legacyService(sourceExtractor, materializer, Mockito.mock(ApplicationEventPublisher::class.java))
|
||||
.getCommunityPostDetail(post.id!!, memberId = 999L, timezone = "Asia/Seoul", isAdult = true)
|
||||
|
||||
assertEquals("translated paid...", response.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("언어 미확정 레거시 상세는 원문을 반환하고 요청 언어만 감지 이벤트에 전달한다")
|
||||
fun shouldReturnOriginalAndRequestOnlyCurrentLanguageDetectionForLegacyDetail() {
|
||||
val post = persistPost(content = "current original", languageCode = null, price = 0)
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, post.id!!)).thenReturn(null)
|
||||
val publisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||
|
||||
val response = legacyService(
|
||||
sourceExtractor,
|
||||
Mockito.mock(TranslationReadModelMaterializer::class.java),
|
||||
publisher
|
||||
).getCommunityPostDetail(post.id!!, memberId = 999L, timezone = "Asia/Seoul", isAdult = true)
|
||||
|
||||
assertEquals("current original", response.content)
|
||||
val eventCaptor = ArgumentCaptor.forClass(LanguageDetectEvent::class.java)
|
||||
Mockito.verify(publisher).publishEvent(eventCaptor.capture())
|
||||
assertEquals("ja", eventCaptor.value.targetLanguage)
|
||||
}
|
||||
|
||||
private fun legacyService(
|
||||
sourceExtractor: TranslationSourceExtractor,
|
||||
materializer: TranslationReadModelMaterializer,
|
||||
publisher: ApplicationEventPublisher,
|
||||
scheduler: ResourceTranslationJobScheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
|
||||
): CreatorCommunityService {
|
||||
val langContext = LangContext().apply { setLang(Lang.JA) }
|
||||
val translationService = CreatorCommunityTranslationService(
|
||||
creatorCommunityRepository = creatorCommunityRepository,
|
||||
creatorCommunityTranslationRepository = creatorCommunityTranslationRepository,
|
||||
sourceExtractor = sourceExtractor,
|
||||
materializer = materializer,
|
||||
resourceTranslationJobScheduler = scheduler,
|
||||
applicationEventPublisher = publisher,
|
||||
entityManager = entityManager
|
||||
)
|
||||
return CreatorCommunityService(
|
||||
canPaymentService = Mockito.mock(CanPaymentService::class.java),
|
||||
repository = creatorCommunityRepository,
|
||||
blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java),
|
||||
likeRepository = Mockito.mock(CreatorCommunityLikeRepository::class.java),
|
||||
commentRepository = Mockito.mock(CreatorCommunityCommentRepository::class.java),
|
||||
useCanRepository = Mockito.mock(UseCanRepository::class.java),
|
||||
s3Uploader = Mockito.mock(S3Uploader::class.java),
|
||||
objectMapper = ObjectMapper().registerModule(KotlinModule.Builder().build()),
|
||||
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java),
|
||||
applicationEventPublisher = publisher,
|
||||
messageSource = SodaMessageSource(),
|
||||
langContext = langContext,
|
||||
homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java),
|
||||
creatorCommunityTranslationService = translationService,
|
||||
entityManager = entityManager,
|
||||
imageBucket = "test-image-bucket",
|
||||
contentBucket = "test-content-bucket",
|
||||
imageHost = "https://cdn.test"
|
||||
)
|
||||
}
|
||||
|
||||
private fun persistPost(content: String, languageCode: String?, price: Int): CreatorCommunity {
|
||||
val creator = memberRepository.save(
|
||||
Member(
|
||||
email = "creator-${System.nanoTime()}@test.com",
|
||||
password = "password",
|
||||
nickname = "creator-${System.nanoTime()}",
|
||||
role = MemberRole.CREATOR
|
||||
)
|
||||
)
|
||||
return creatorCommunityRepository.save(
|
||||
CreatorCommunity(
|
||||
content = content,
|
||||
price = price,
|
||||
isCommentAvailable = false,
|
||||
isAdult = false,
|
||||
languageCode = languageCode
|
||||
).apply { member = creator }
|
||||
)
|
||||
}
|
||||
|
||||
private fun translationSource(
|
||||
postId: Long,
|
||||
content: String,
|
||||
sourceLanguage: String,
|
||||
sourceRevision: Long
|
||||
): TranslationSource {
|
||||
return TranslationSource(
|
||||
resourceType = LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
||||
resourceId = postId,
|
||||
sourceLanguage = sourceLanguage,
|
||||
fields = listOf(TranslationSourceField("content", content)),
|
||||
sourceRevision = sourceRevision
|
||||
)
|
||||
}
|
||||
}
|
||||
+47
-1
@@ -75,6 +75,51 @@ class TranslationJobWorkerTest {
|
||||
Mockito.verify(materializer, Mockito.times(1)).materialize(LanguageTranslationTargetType.CONTENT, 10L, "en")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldUseExistingMemoryBeforeMaterializingCreatorCommunity() {
|
||||
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
|
||||
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
|
||||
val provider = failingProvider()
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
val worker = TranslationJobWorker(
|
||||
translationJobRepository = jobRepository,
|
||||
translationMemoryRepository = memoryRepository,
|
||||
translationProvider = provider,
|
||||
materializer = materializer,
|
||||
transactionManager = TestTransactionManager()
|
||||
)
|
||||
val job = translationJob(
|
||||
resourceType = LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
||||
fieldKey = "content",
|
||||
sourceText = "본문"
|
||||
).also { it.id = 100L }
|
||||
Mockito.`when`(jobRepository.findNextPendingGroupJobIdsForUpdate(anyLocalDateTime())).thenReturn(listOf(100L))
|
||||
Mockito.`when`(jobRepository.findById(100L)).thenReturn(Optional.of(job))
|
||||
Mockito.`when`(
|
||||
memoryRepository.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
|
||||
job.sourceHash,
|
||||
"ko",
|
||||
"en",
|
||||
"papago",
|
||||
SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
).thenReturn(
|
||||
TranslationMemory(
|
||||
sourceHash = job.sourceHash,
|
||||
sourceText = job.sourceText,
|
||||
sourceLanguage = "ko",
|
||||
targetLanguage = "en",
|
||||
translatedText = "translated",
|
||||
provider = "papago",
|
||||
providerVersion = "nmt-v1"
|
||||
)
|
||||
)
|
||||
|
||||
worker.processNextGroup()
|
||||
|
||||
Mockito.verify(materializer).materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, 10L, "en")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldRetryGroupWhenMaterializationFails() {
|
||||
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
|
||||
@@ -174,11 +219,12 @@ class TranslationJobWorkerTest {
|
||||
|
||||
private fun translationJob(
|
||||
resourceId: Long = 10L,
|
||||
resourceType: LanguageTranslationTargetType = LanguageTranslationTargetType.CONTENT,
|
||||
fieldKey: String = "title",
|
||||
sourceText: String = "제목"
|
||||
): TranslationJob {
|
||||
return TranslationJob(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT,
|
||||
resourceType = resourceType,
|
||||
resourceId = resourceId,
|
||||
fieldKey = fieldKey,
|
||||
sourceHash = SourceTextNormalizer.hash(sourceText),
|
||||
|
||||
+166
-1
@@ -6,10 +6,12 @@ import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
@@ -54,7 +56,8 @@ class AiCharacterAdminCommunityPostConcurrencyTest @Autowired constructor(
|
||||
private val chatCharacterService: ChatCharacterService,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val entityManager: EntityManager,
|
||||
private val transactionManager: PlatformTransactionManager
|
||||
private val transactionManager: PlatformTransactionManager,
|
||||
private val creatorCommunityService: CreatorCommunityService
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var amazonS3Client: AmazonS3Client
|
||||
@@ -62,9 +65,15 @@ class AiCharacterAdminCommunityPostConcurrencyTest @Autowired constructor(
|
||||
@MockBean
|
||||
private lateinit var applicationEventPublisher: ApplicationEventPublisher
|
||||
|
||||
@MockBean
|
||||
private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService
|
||||
|
||||
@SpyBean
|
||||
private lateinit var creatorCommunityRepository: CreatorCommunityRepository
|
||||
|
||||
@SpyBean
|
||||
private lateinit var aiCharacterAdminCommunityPostRepository: AiCharacterAdminCommunityPostRepository
|
||||
|
||||
@Test
|
||||
@DisplayName("순차 고정 요청에서 세 번째는 허용되고 네 번째는 최대 고정 수로 거부된다")
|
||||
fun shouldAllowThirdFixedPostAndRejectFourthWhenRequestsAreSerialized() {
|
||||
@@ -167,6 +176,133 @@ class AiCharacterAdminCommunityPostConcurrencyTest @Autowired constructor(
|
||||
assertEquals(3L, transactionTemplate.execute { fixedPostCount(fixture.ownerId) })
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("병렬 본문 수정은 현재 행 잠금으로 개정을 순서대로 증가시킨다")
|
||||
fun shouldSerializeContentRevisionsWhenUpdatesRace() {
|
||||
val transactionTemplate = TransactionTemplate(transactionManager)
|
||||
val fixture = transactionTemplate.execute {
|
||||
val character = createCharacter("community-content-race")
|
||||
val post = savePost(character.creatorMember!!, "before").apply {
|
||||
languageCode = "ko"
|
||||
contentRevision = 0
|
||||
}
|
||||
entityManager.flush()
|
||||
ContentRaceFixture(character.id!!, post.id!!)
|
||||
} ?: throw IllegalStateException("fixture creation failed")
|
||||
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
val updatesReady = CountDownLatch(2)
|
||||
val startUpdates = CountDownLatch(1)
|
||||
try {
|
||||
val first = executor.submit<Int> {
|
||||
updatesReady.countDown()
|
||||
startUpdates.await(5, TimeUnit.SECONDS)
|
||||
mockMvc.perform(updateContentRequest(fixture.characterId, fixture.postId, "first"))
|
||||
.andReturn()
|
||||
.response
|
||||
.status
|
||||
}
|
||||
|
||||
val second = executor.submit<Int> {
|
||||
updatesReady.countDown()
|
||||
startUpdates.await(5, TimeUnit.SECONDS)
|
||||
mockMvc.perform(updateContentRequest(fixture.characterId, fixture.postId, "second"))
|
||||
.andReturn()
|
||||
.response
|
||||
.status
|
||||
}
|
||||
assertTrue(updatesReady.await(5, TimeUnit.SECONDS))
|
||||
startUpdates.countDown()
|
||||
assertEquals(listOf(200, 200), listOf(first.get(5, TimeUnit.SECONDS), second.get(5, TimeUnit.SECONDS)))
|
||||
} finally {
|
||||
startUpdates.countDown()
|
||||
executor.shutdown()
|
||||
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS))
|
||||
}
|
||||
|
||||
val updated = transactionTemplate.execute {
|
||||
entityManager.clear()
|
||||
entityManager.find(CreatorCommunity::class.java, fixture.postId)
|
||||
}!!
|
||||
assertEquals(2, updated.contentRevision)
|
||||
assertTrue(updated.content == "first" || updated.content == "second")
|
||||
Mockito.verify(creatorCommunityTranslationService, Mockito.times(2)).requestTranslations(fixture.postId, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("고정 상태의 stale read는 최신 본문 수정의 개정과 언어를 덮어쓰지 않는다")
|
||||
fun shouldApplyFixedAndContentUpdateAfterConcurrentContentCommit() {
|
||||
val transactionTemplate = TransactionTemplate(transactionManager)
|
||||
val fixture = transactionTemplate.execute {
|
||||
val character = createCharacter("community-fixed-content-race")
|
||||
val post = savePost(character.creatorMember!!, "before").apply {
|
||||
languageCode = "ko"
|
||||
contentRevision = 0
|
||||
}
|
||||
entityManager.flush()
|
||||
ContentRaceFixture(character.id!!, post.id!!)
|
||||
} ?: throw IllegalStateException("fixture creation failed")
|
||||
val staleAdminRead = CountDownLatch(1)
|
||||
val releaseFixedUpdate = CountDownLatch(1)
|
||||
val contentUpdatePreLock = CountDownLatch(1)
|
||||
val fixedReadPaused = AtomicBoolean(false)
|
||||
Mockito.doAnswer { invocation ->
|
||||
val post = invocation.callRealMethod() as CreatorCommunity?
|
||||
if (post?.id == fixture.postId && fixedReadPaused.compareAndSet(false, true)) {
|
||||
staleAdminRead.countDown()
|
||||
releaseFixedUpdate.await(5, TimeUnit.SECONDS)
|
||||
}
|
||||
post
|
||||
}.`when`(aiCharacterAdminCommunityPostRepository)
|
||||
.findActiveByIdAndCreatorMemberId(Mockito.anyLong(), Mockito.anyLong())
|
||||
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
try {
|
||||
val fixedAndContent = executor.submit<Int> {
|
||||
mockMvc.perform(fixedAndContentRequest(fixture.characterId, fixture.postId, "after fixed"))
|
||||
.andReturn()
|
||||
.response
|
||||
.status
|
||||
}
|
||||
assertTrue(staleAdminRead.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val concurrentContent = executor.submit<Int> {
|
||||
contentUpdatePreLock.countDown()
|
||||
creatorCommunityService.modifyCommunityPost(
|
||||
postImage = null,
|
||||
requestString = objectMapper.writeValueAsString(
|
||||
mapOf("creatorCommunityId" to fixture.postId, "content" to "after concurrent")
|
||||
),
|
||||
member = transactionTemplate.execute {
|
||||
entityManager.find(CreatorCommunity::class.java, fixture.postId).member!!
|
||||
}!!
|
||||
)
|
||||
200
|
||||
}
|
||||
assertTrue(contentUpdatePreLock.await(5, TimeUnit.SECONDS))
|
||||
assertEquals(200, concurrentContent.get(5, TimeUnit.SECONDS))
|
||||
|
||||
releaseFixedUpdate.countDown()
|
||||
assertEquals(200, fixedAndContent.get(5, TimeUnit.SECONDS))
|
||||
} finally {
|
||||
releaseFixedUpdate.countDown()
|
||||
executor.shutdown()
|
||||
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS))
|
||||
}
|
||||
|
||||
val updated = transactionTemplate.execute {
|
||||
entityManager.clear()
|
||||
entityManager.find(CreatorCommunity::class.java, fixture.postId)
|
||||
}!!
|
||||
assertEquals("after fixed", updated.content)
|
||||
assertEquals(2, updated.contentRevision)
|
||||
assertEquals(null, updated.languageCode)
|
||||
assertTrue(updated.isFixed)
|
||||
assertNotNull(updated.fixedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid target와 cross-owner 고정 요청은 DB, S3, event 변경 없이 거부된다")
|
||||
fun shouldRejectInvalidTargetAndCrossOwnerFixedRequestsWithoutSideEffects() {
|
||||
@@ -213,6 +349,30 @@ class AiCharacterAdminCommunityPostConcurrencyTest @Autowired constructor(
|
||||
postImage?.let(::file)
|
||||
}.with(adminAuthentication())
|
||||
|
||||
private fun updateContentRequest(characterId: Long, postId: Long, content: String) =
|
||||
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/$characterId/community-posts/$postId")
|
||||
.file(
|
||||
MockMultipartFile(
|
||||
"request",
|
||||
"request.json",
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
objectMapper.writeValueAsBytes(mapOf("content" to content))
|
||||
)
|
||||
)
|
||||
.with(adminAuthentication())
|
||||
|
||||
private fun fixedAndContentRequest(characterId: Long, postId: Long, content: String) =
|
||||
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/$characterId/community-posts/$postId")
|
||||
.file(
|
||||
MockMultipartFile(
|
||||
"request",
|
||||
"request.json",
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
objectMapper.writeValueAsBytes(mapOf("isFixed" to true, "content" to content))
|
||||
)
|
||||
)
|
||||
.with(adminAuthentication())
|
||||
|
||||
private fun pngFile() = MockMultipartFile(
|
||||
"postImage",
|
||||
"post.png",
|
||||
@@ -283,4 +443,9 @@ class AiCharacterAdminCommunityPostConcurrencyTest @Autowired constructor(
|
||||
val firstPostId: Long,
|
||||
val secondPostId: Long
|
||||
)
|
||||
|
||||
private data class ContentRaceFixture(
|
||||
val characterId: Long,
|
||||
val postId: Long
|
||||
)
|
||||
}
|
||||
|
||||
+20
@@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommuni
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
@@ -34,6 +35,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multi
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.net.URL
|
||||
import javax.persistence.EntityManager
|
||||
@@ -51,6 +53,9 @@ class AiCharacterAdminCommunityPostCreateTest @Autowired constructor(
|
||||
@MockBean
|
||||
private lateinit var amazonS3Client: AmazonS3Client
|
||||
|
||||
@MockBean
|
||||
private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService
|
||||
|
||||
@Test
|
||||
@DisplayName("정상 무료 게시글 생성은 target 소유자를 작성자로 사용하고 data null을 반환한다")
|
||||
fun shouldCreateFreePostForTargetOwnerAndReturnNullData() {
|
||||
@@ -73,6 +78,21 @@ class AiCharacterAdminCommunityPostCreateTest @Autowired constructor(
|
||||
assertTrue(post.member!!.id != otherCharacter.creatorMember!!.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("관리자 생성은 게시글 커밋 후 공용 번역 요청을 한 번 보낸다")
|
||||
fun shouldRequestTranslationsAfterCommittedAdminCreation() {
|
||||
val character = createCharacter("community-create-translation")
|
||||
val content = "translation request after committed admin creation"
|
||||
|
||||
mockMvc.perform(createRequest(character.id!!, content = content))
|
||||
.andExpect(status().isOk)
|
||||
|
||||
val post = findPost(content)
|
||||
assertNotNull(post)
|
||||
Mockito.verify(creatorCommunityTranslationService).requestTranslations(post!!.id!!, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("생성 price 생략은 기본값 0으로 게시글을 생성한다")
|
||||
fun shouldCreatePostWithDefaultPriceWhenPriceIsOmitted() {
|
||||
|
||||
+4
@@ -62,6 +62,8 @@ class AiCharacterAdminCommunityPostUpdateTest @Autowired constructor(
|
||||
val post = savePost(character.creatorMember!!, "before", isCommentAvailable = true, isAdult = false).apply {
|
||||
isFixed = true
|
||||
fixedAt = LocalDateTime.now()
|
||||
languageCode = "ko"
|
||||
contentRevision = 7
|
||||
}
|
||||
entityManager.flush()
|
||||
|
||||
@@ -86,6 +88,8 @@ class AiCharacterAdminCommunityPostUpdateTest @Autowired constructor(
|
||||
assertTrue(updated.isAdult)
|
||||
assertTrue(updated.isFixed)
|
||||
assertNotNull(updated.fixedAt)
|
||||
assertNull(updated.languageCode)
|
||||
assertEquals(8, updated.contentRevision)
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -23,6 +23,7 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
@@ -37,6 +38,7 @@ import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import java.io.InputStream
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
class LegacyCommunityPostCharacterizationTest {
|
||||
private lateinit var repository: CreatorCommunityRepository
|
||||
@@ -65,6 +67,8 @@ class LegacyCommunityPostCharacterizationTest {
|
||||
messageSource = SodaMessageSource(),
|
||||
langContext = LangContext(),
|
||||
homeFollowingNewsPublishService = homeFollowingNewsPublishService,
|
||||
creatorCommunityTranslationService = Mockito.mock(CreatorCommunityTranslationService::class.java),
|
||||
entityManager = Mockito.mock(EntityManager::class.java),
|
||||
imageBucket = "image-bucket",
|
||||
contentBucket = "content-bucket",
|
||||
imageHost = "https://cdn.test"
|
||||
@@ -176,7 +180,7 @@ class LegacyCommunityPostCharacterizationTest {
|
||||
post.member = creator
|
||||
post.isFixed = true
|
||||
post.fixedAt = LocalDateTime.of(2026, 7, 28, 10, 0)
|
||||
Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post)
|
||||
Mockito.`when`(repository.findByIdAndMemberIdForUpdate(post.id!!, creator.id!!)).thenReturn(post)
|
||||
Mockito.`when`(repository.countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(creator.id!!)).thenReturn(3L)
|
||||
|
||||
service.updateCommunityPostFixed(UpdateCommunityPostFixedRequest(post.id!!, isFixed = true), creator)
|
||||
@@ -193,7 +197,7 @@ class LegacyCommunityPostCharacterizationTest {
|
||||
val otherPost = CreatorCommunity("other fixed post", 0, true, false)
|
||||
otherPost.id = 1004L
|
||||
otherPost.member = creator
|
||||
Mockito.`when`(repository.findByIdAndMemberId(otherPost.id!!, creator.id!!)).thenReturn(otherPost)
|
||||
Mockito.`when`(repository.findByIdAndMemberIdForUpdate(otherPost.id!!, creator.id!!)).thenReturn(otherPost)
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
service.updateCommunityPostFixed(UpdateCommunityPostFixedRequest(otherPost.id!!, isFixed = true), creator)
|
||||
}
|
||||
|
||||
+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,
|
||||
|
||||
+152
@@ -13,6 +13,8 @@ import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMember
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityDisplayContent
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
@@ -47,6 +49,9 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
||||
@MockBean
|
||||
private lateinit var audioContentCloudFront: AudioContentCloudFront
|
||||
|
||||
@MockBean
|
||||
private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 탭 API는 E2E로 정렬, fallback, 성인 필터, 유료 미디어 접근 정책을 반환한다")
|
||||
fun shouldReturnCommunityTabThroughControllerServiceAndRepository() {
|
||||
@@ -97,6 +102,41 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
||||
Mockito.verifyNoMoreInteractions(audioContentCloudFront)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 탭 API는 저장 번역을 페이지 단위로 선택하고 유료 미리보기를 유지한다")
|
||||
fun shouldUseStoredTranslationsForCommunityTabWithoutScheduling() {
|
||||
val fixture = createFixture()
|
||||
val postIds = listOf(
|
||||
fixture.pinnedPostId,
|
||||
fixture.purchasedPaidPostId,
|
||||
fixture.unpurchasedPaidPostId,
|
||||
fixture.noImagePostId
|
||||
)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(postIds, "ja"))
|
||||
.thenReturn(
|
||||
mapOf(
|
||||
fixture.pinnedPostId to CreatorCommunityDisplayContent("translated pinned", true),
|
||||
fixture.purchasedPaidPostId to CreatorCommunityDisplayContent("translated purchased", true),
|
||||
fixture.unpurchasedPaidPostId to CreatorCommunityDisplayContent("가나다라마바사아자차카타파하🙂끝", true),
|
||||
fixture.noImagePostId to CreatorCommunityDisplayContent("no image community", false)
|
||||
)
|
||||
)
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/creator-channels/${fixture.creatorId}/community")
|
||||
.header("Accept-Language", "ja")
|
||||
.with(user(MemberAdapter(fixture.viewer)))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.communityPosts[0].content").value("translated pinned"))
|
||||
.andExpect(jsonPath("$.data.communityPosts[1].content").value("translated purchased"))
|
||||
.andExpect(jsonPath("$.data.communityPosts[2].content").value("가나다라마바사아자차카타파하🙂..."))
|
||||
.andExpect(jsonPath("$.data.communityPosts[3].content").value("no image community"))
|
||||
|
||||
Mockito.verify(creatorCommunityTranslationService).findDisplayContents(postIds, "ja")
|
||||
Mockito.verifyNoMoreInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 상세 API는 초기 댓글 20개와 최신 답글을 반환한다")
|
||||
fun shouldReturnCommunityPostDetailWithInitialComments() {
|
||||
@@ -115,6 +155,74 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
||||
.andExpect(jsonPath("$.data.comments.comments[0].createdAtUtc").value(org.hamcrest.Matchers.endsWith("Z")))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 상세 API는 저장 번역문에 유료 미리보기를 적용하고 요청 언어만 예약한다")
|
||||
fun shouldSelectTranslatedContentBeforePaidMaskingAndRequestCurrentLanguage() {
|
||||
val fixture = createTranslatedPaidDetailFixture(purchased = false)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(fixture.postId), "ja"))
|
||||
.thenReturn(mapOf(fixture.postId to CreatorCommunityDisplayContent("translated paid content", true)))
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/creator-channels/community-posts/${fixture.postId}")
|
||||
.header("Accept-Language", "ja")
|
||||
.with(user(MemberAdapter(fixture.viewer)))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.content").value("translated paid..."))
|
||||
|
||||
Mockito.verify(creatorCommunityTranslationService, Mockito.never()).requestTranslations(fixture.postId, "ja")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 상세 API는 구매자에게 선택한 유료 번역문 전체를 반환한다")
|
||||
fun shouldReturnFullTranslatedContentToBuyer() {
|
||||
val fixture = createTranslatedPaidDetailFixture(purchased = true)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(fixture.postId), "ja"))
|
||||
.thenReturn(mapOf(fixture.postId to CreatorCommunityDisplayContent("translated paid content", true)))
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/creator-channels/community-posts/${fixture.postId}")
|
||||
.header("Accept-Language", "ja")
|
||||
.with(user(MemberAdapter(fixture.viewer)))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.content").value("translated paid content"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 상세 API는 소유자에게 선택한 유료 번역문 전체를 반환한다")
|
||||
fun shouldReturnFullTranslatedContentToOwner() {
|
||||
val fixture = createTranslatedPaidOwnerDetailFixture()
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(fixture.postId), "ja"))
|
||||
.thenReturn(mapOf(fixture.postId to CreatorCommunityDisplayContent("translated paid content", true)))
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/creator-channels/community-posts/${fixture.postId}")
|
||||
.header("Accept-Language", "ja")
|
||||
.with(user(MemberAdapter(fixture.viewer)))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.content").value("translated paid content"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 상세 API는 반복 요청을 모두 성공시키고 같은 요청 언어만 전달한다")
|
||||
fun shouldKeepRepeatedCommunityDetailRequestsSuccessful() {
|
||||
val fixture = createTranslatedPaidDetailFixture(purchased = false)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(fixture.postId), "ja"))
|
||||
.thenReturn(emptyMap())
|
||||
|
||||
repeat(2) {
|
||||
mockMvc.perform(
|
||||
get("/api/v2/creator-channels/community-posts/${fixture.postId}")
|
||||
.header("Accept-Language", "ja")
|
||||
.with(user(MemberAdapter(fixture.viewer)))
|
||||
).andExpect(status().isOk)
|
||||
}
|
||||
|
||||
Mockito.verify(creatorCommunityTranslationService, Mockito.times(2)).requestTranslations(fixture.postId, "ja")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 댓글 API는 댓글 페이지와 최신 답글을 반환한다")
|
||||
fun shouldReturnPagedCommunityCommentsWithLatestReply() {
|
||||
@@ -131,6 +239,8 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
||||
.andExpect(jsonPath("$.data.comments.length()").value(20))
|
||||
.andExpect(jsonPath("$.data.hasNext").value(true))
|
||||
.andExpect(jsonPath("$.data.comments[0].latestReply.commentId").value(fixture.latestReplyId))
|
||||
|
||||
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -148,6 +258,8 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
||||
.andExpect(jsonPath("$.data.replyCount").value(1))
|
||||
.andExpect(jsonPath("$.data.replies[0].commentId").value(fixture.latestReplyId))
|
||||
.andExpect(jsonPath("$.data.replies[0].latestReply").doesNotExist())
|
||||
|
||||
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -176,6 +288,8 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
|
||||
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -189,6 +303,8 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
|
||||
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -435,6 +551,42 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
||||
}!!
|
||||
}
|
||||
|
||||
private fun createTranslatedPaidDetailFixture(purchased: Boolean): PostFixture {
|
||||
return transactionTemplate.execute {
|
||||
val viewer = saveMember("community-translated-viewer-$purchased", MemberRole.USER)
|
||||
val creator = saveMember("community-translated-creator-$purchased", MemberRole.CREATOR)
|
||||
savePreference(viewer, isAdultContentVisible = true)
|
||||
val post = saveCommunity(
|
||||
creator = creator,
|
||||
isFixed = false,
|
||||
price = 100,
|
||||
content = "source paid content"
|
||||
)
|
||||
if (purchased) {
|
||||
saveCommunityOrder(viewer, post)
|
||||
}
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
PostFixture(viewer = viewer, postId = post.id!!)
|
||||
}!!
|
||||
}
|
||||
|
||||
private fun createTranslatedPaidOwnerDetailFixture(): PostFixture {
|
||||
return transactionTemplate.execute {
|
||||
val creator = saveMember("community-translated-owner", MemberRole.CREATOR)
|
||||
savePreference(creator, isAdultContentVisible = true)
|
||||
val post = saveCommunity(
|
||||
creator = creator,
|
||||
isFixed = false,
|
||||
price = 100,
|
||||
content = "source paid content"
|
||||
)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
PostFixture(viewer = creator, postId = post.id!!)
|
||||
}!!
|
||||
}
|
||||
|
||||
private fun createCommentUnavailableFixture(): PostFixture {
|
||||
return transactionTemplate.execute {
|
||||
val viewer = saveMember("community-unavailable-viewer", MemberRole.USER)
|
||||
|
||||
+39
@@ -11,12 +11,14 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
|
||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
|
||||
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
||||
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
||||
import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisit
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
@@ -82,6 +84,28 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor(
|
||||
.andExpect(jsonPath("$.data.sns.instagramUrl").value("e2e-instagram"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("크리에이터 채널 홈 API는 고정과 일반 커뮤니티의 저장 번역을 반환한다")
|
||||
fun shouldUseStoredTranslationsForPinnedAndNormalHomeCommunityPosts() {
|
||||
Mockito.doReturn("US").`when`(countryContext).countryCode
|
||||
val fixture = createFixture()
|
||||
transactionTemplate.execute {
|
||||
saveCommunityTranslation(fixture.noticeId, "translated notice")
|
||||
saveCommunityTranslation(fixture.communityId, "translated community")
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
}
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/creator-channels/${fixture.creatorId}/home")
|
||||
.header("Accept-Language", "ja")
|
||||
.with(user(MemberAdapter(fixture.viewer)))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.notices[0].content").value("translated notice"))
|
||||
.andExpect(jsonPath("$.data.communities[0].content").value("translated community"))
|
||||
}
|
||||
|
||||
private fun createFixture(): Fixture {
|
||||
return transactionTemplate.execute {
|
||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||
@@ -237,6 +261,21 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor(
|
||||
return post
|
||||
}
|
||||
|
||||
private fun saveCommunityTranslation(postId: Long, content: String) {
|
||||
val post = entityManager.find(CreatorCommunity::class.java, postId)
|
||||
post.languageCode = "ko"
|
||||
entityManager.persist(
|
||||
CreatorCommunityTranslation(
|
||||
creatorCommunityId = postId,
|
||||
locale = "ja",
|
||||
content = content,
|
||||
sourceRevision = post.contentRevision,
|
||||
sourceHash = SourceTextNormalizer.hash(post.content),
|
||||
sourceLanguage = "ko"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun saveCheers(member: Member, creator: Member, cheers: String, createdAt: LocalDateTime): CreatorCheers {
|
||||
val creatorCheers = CreatorCheers(cheers = cheers, languageCode = "ko", isActive = true)
|
||||
creatorCheers.member = member
|
||||
|
||||
+75
@@ -1,6 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.home
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
@@ -12,6 +13,8 @@ import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacade
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityDisplayContent
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshot
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy
|
||||
@@ -27,6 +30,7 @@ import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.boot.test.system.CapturedOutput
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension
|
||||
import org.springframework.http.MediaType
|
||||
@@ -53,6 +57,9 @@ class HomeRecommendationControllerTest @Autowired constructor(
|
||||
private val creatorFollowingRepository: CreatorFollowingRepository,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService
|
||||
|
||||
@Test
|
||||
@DisplayName("추천 크리에이터 동시 팔로우 비로그인 요청은 Spring Security에서 거부한다")
|
||||
fun shouldRejectAnonymousFollowRequest() {
|
||||
@@ -230,6 +237,60 @@ class HomeRecommendationControllerTest @Autowired constructor(
|
||||
assertTrue(output.out.contains("emptySections="))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("비회원 홈 추천은 반환된 인기 커뮤니티 게시물만 일본어 표시 본문으로 반환한다")
|
||||
fun shouldReturnJapanesePopularCommunityTranslationForAnonymous() {
|
||||
val translatedCreator = saveMember("popular-community-translated-creator", MemberRole.CREATOR)
|
||||
val fallbackCreator = saveMember("popular-community-fallback-creator", MemberRole.CREATOR)
|
||||
val translatedPost = saveCommunityPost(translatedCreator, "historical translated popular body")
|
||||
val fallbackPost = saveCommunityPost(fallbackCreator, "historical fallback popular body")
|
||||
val snapshotAt = RecommendationSnapshotWindowPolicy()
|
||||
.previousKstDayUtcWindow(LocalDateTime.now(ZoneOffset.UTC))
|
||||
.snapshotAt
|
||||
entityManager.persist(
|
||||
RecommendationSnapshot(
|
||||
sectionType = RecommendedSectionType.POPULAR_COMMUNITY,
|
||||
targetId = translatedPost.id!!,
|
||||
score = 100.0,
|
||||
snapshotAt = snapshotAt,
|
||||
randomTieBreaker = 0.1
|
||||
)
|
||||
)
|
||||
entityManager.persist(
|
||||
RecommendationSnapshot(
|
||||
sectionType = RecommendedSectionType.POPULAR_COMMUNITY,
|
||||
targetId = fallbackPost.id!!,
|
||||
score = 90.0,
|
||||
snapshotAt = snapshotAt,
|
||||
randomTieBreaker = 0.2
|
||||
)
|
||||
)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
Mockito.`when`(
|
||||
creatorCommunityTranslationService.findDisplayContents(
|
||||
listOf(translatedPost.id!!, fallbackPost.id!!),
|
||||
"ja"
|
||||
)
|
||||
).thenReturn(
|
||||
mapOf(
|
||||
translatedPost.id!! to CreatorCommunityDisplayContent("translated popular body ja", true),
|
||||
fallbackPost.id!! to CreatorCommunityDisplayContent("current fallback popular body ja", false)
|
||||
)
|
||||
)
|
||||
|
||||
mockMvc.perform(get("/api/v2/home/recommendations").header("Accept-Language", "ja"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.popularCommunityPosts[0].content").value("translated popular body ja"))
|
||||
.andExpect(jsonPath("$.data.popularCommunityPosts[1].content").value("current fallback popular body ja"))
|
||||
|
||||
Mockito.verify(creatorCommunityTranslationService).findDisplayContents(
|
||||
listOf(translatedPost.id!!, fallbackPost.id!!),
|
||||
"ja"
|
||||
)
|
||||
Mockito.verifyNoMoreInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("메인 홈 통합 조회는 인증 회원도 호출 가능하고 성공 응답한다")
|
||||
fun shouldReturnHomeRecommendationsForMember() {
|
||||
@@ -590,6 +651,20 @@ class HomeRecommendationControllerTest @Autowired constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private fun saveCommunityPost(creator: Member, content: String): CreatorCommunity {
|
||||
val post = CreatorCommunity(
|
||||
content = content,
|
||||
price = 0,
|
||||
isCommentAvailable = true,
|
||||
isAdult = false,
|
||||
isActive = true
|
||||
).apply {
|
||||
member = creator
|
||||
}
|
||||
entityManager.persist(post)
|
||||
return post
|
||||
}
|
||||
|
||||
private fun saveLiveRoom(
|
||||
creator: Member,
|
||||
beginDateTime: LocalDateTime,
|
||||
|
||||
+11
-2
@@ -1,8 +1,10 @@
|
||||
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.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
|
||||
@@ -26,7 +28,13 @@ import java.time.LocalDateTime
|
||||
|
||||
class HomeRecommendationFacadeTest {
|
||||
private val queryPort = FakeHomeRecommendationQueryPort()
|
||||
private val queryService = HomeRecommendationQueryService(queryPort, EmptyRecommendationSnapshotPort())
|
||||
private val creatorCommunityTranslationService = Mockito.mock(CreatorCommunityTranslationService::class.java)
|
||||
private val queryService = HomeRecommendationQueryService(
|
||||
queryPort,
|
||||
EmptyRecommendationSnapshotPort(),
|
||||
langContext = LangContext(),
|
||||
creatorCommunityTranslationService = creatorCommunityTranslationService
|
||||
)
|
||||
private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||
private val facade = HomeRecommendationFacade(queryService, preferenceService, "https://cdn.test")
|
||||
|
||||
@@ -135,7 +143,8 @@ class HomeRecommendationFacadeTest {
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
memberId: Long?,
|
||||
includeAdultContents: Boolean
|
||||
includeAdultContents: Boolean,
|
||||
locale: String
|
||||
): List<HomeFirstAudioContentRecord> = firstAudioContents
|
||||
|
||||
override fun findAiCharacterSnapshots(
|
||||
|
||||
+55
@@ -18,6 +18,8 @@ import kr.co.vividnext.sodalive.member.MemberKind
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityDisplayContent
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInbox
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
|
||||
import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.CreatorRankingSnapshot
|
||||
@@ -63,6 +65,9 @@ class HomeFollowingEndToEndTest @Autowired constructor(
|
||||
@MockBean
|
||||
private lateinit var countryContext: CountryContext
|
||||
|
||||
@MockBean
|
||||
private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService
|
||||
|
||||
@Test
|
||||
@DisplayName("팔로잉 탭 API는 비회원에게 200 OK와 로그인 필요 빈 섹션 응답을 반환한다")
|
||||
fun shouldReturnLoginRequiredForAnonymous() {
|
||||
@@ -137,6 +142,56 @@ class HomeFollowingEndToEndTest @Autowired constructor(
|
||||
.andExpect(jsonPath("$.data.recentNews[?(@.communityPost.postId == ${fixture.paidPostId})]").isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("인증 회원 팔로잉 탭은 노출된 커뮤니티 소식만 영어 표시 본문으로 반환하고 다른 소식은 유지한다")
|
||||
fun shouldReturnEnglishCommunityTranslationForAuthenticatedFollowingTab() {
|
||||
Mockito.doReturn("US").`when`(countryContext).countryCode
|
||||
val fixture = createFixture()
|
||||
val fallbackPostId = transactionTemplate.execute {
|
||||
val creator = entityManager.find(Member::class.java, fixture.creatorId)
|
||||
val fallbackPost = saveCommunityPost(
|
||||
creator,
|
||||
"historical following fallback body",
|
||||
price = 0,
|
||||
imagePath = "community/fallback.png"
|
||||
)
|
||||
saveNews(
|
||||
memberId = fixture.viewer.id!!,
|
||||
creatorId = fixture.creatorId,
|
||||
sourceKey = "fallback-community-news",
|
||||
visibleFromAtUtc = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(10),
|
||||
newsType = FollowingNewsType.COMMUNITY_POST,
|
||||
targetId = fallbackPost.id!!
|
||||
)
|
||||
entityManager.flush()
|
||||
fallbackPost.id!!
|
||||
}!!
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(fallbackPostId, fixture.freePostId), "en"))
|
||||
.thenReturn(
|
||||
mapOf(
|
||||
fallbackPostId to CreatorCommunityDisplayContent("current following fallback body en", false),
|
||||
fixture.freePostId to CreatorCommunityDisplayContent("translated following body en", true)
|
||||
)
|
||||
)
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/home/following")
|
||||
.header("Accept-Language", "en")
|
||||
.with(user(MemberAdapter(fixture.viewer)))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.recentNews[0].type").value("COMMUNITY_POST"))
|
||||
.andExpect(jsonPath("$.data.recentNews[0].communityPost.content").value("current following fallback body en"))
|
||||
.andExpect(jsonPath("$.data.recentNews[1].type").value("CREATOR_RANKING"))
|
||||
.andExpect(jsonPath("$.data.recentNews[2].type").value("COMMUNITY_POST"))
|
||||
.andExpect(jsonPath("$.data.recentNews[2].communityPost.content").value("translated following body en"))
|
||||
.andExpect(jsonPath("$.data.recentNews[3].type").value("AUDIO_CONTENT"))
|
||||
.andExpect(jsonPath("$.data.recentNews[3].audioContent.title").value("home-following-audio"))
|
||||
|
||||
Mockito.verify(creatorCommunityTranslationService).findDisplayContents(listOf(fallbackPostId, fixture.freePostId), "en")
|
||||
Mockito.verifyNoMoreInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
private fun createFixture(): Fixture {
|
||||
return transactionTemplate.execute {
|
||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||
|
||||
+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,
|
||||
|
||||
+108
-1
@@ -15,6 +15,8 @@ import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorCha
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityDisplayContent
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
@@ -175,6 +177,92 @@ class CreatorChannelCommunityQueryServiceTest {
|
||||
assertEquals(true, posts.single().isLiked)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 탭은 페이지 본문을 한 번에 번역 선택한 뒤 유료 미리보기를 적용한다")
|
||||
fun shouldUseStoredTranslationsOnceForCommunityTabBeforePaidMasking() {
|
||||
val port = FakeCreatorChannelCommunityQueryPort().apply {
|
||||
communityPosts = listOf(
|
||||
communityPostRecord(11L, price = 0).copy(content = "source free"),
|
||||
communityPostRecord(12L, price = 100).copy(content = "source paid"),
|
||||
communityPostRecord(13L, price = 100, existOrdered = true).copy(content = "source buyer"),
|
||||
communityPostRecord(14L, creatorId = 10L, price = 100).copy(content = "source owner"),
|
||||
communityPostRecord(15L, price = 0).copy(content = "source stale")
|
||||
)
|
||||
}
|
||||
val translationService = Mockito.mock(CreatorCommunityTranslationService::class.java)
|
||||
val service = createService(port, creatorCommunityTranslationService = translationService)
|
||||
val postIds = port.communityPosts.map { it.postId }
|
||||
Mockito.`when`(translationService.findDisplayContents(postIds, "en"))
|
||||
.thenReturn(
|
||||
mapOf(
|
||||
11L to CreatorCommunityDisplayContent("translated free", true),
|
||||
12L to CreatorCommunityDisplayContent("가나다라마바사아자차카타파하🙂끝", true),
|
||||
13L to CreatorCommunityDisplayContent("translated buyer", true),
|
||||
14L to CreatorCommunityDisplayContent("translated owner", true),
|
||||
15L to CreatorCommunityDisplayContent("source stale", false)
|
||||
)
|
||||
)
|
||||
|
||||
val posts = service.getCommunityTab(1L, createMember(id = 10L), 0, 20).communityPosts
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"translated free",
|
||||
"가나다라마바사아자차카타파하🙂...",
|
||||
"translated buyer",
|
||||
"translated owner",
|
||||
"source stale"
|
||||
),
|
||||
posts.map { it.content }
|
||||
)
|
||||
Mockito.verify(translationService).findDisplayContents(postIds, "en")
|
||||
Mockito.verifyNoMoreInteractions(translationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 탭 번역 조회는 fetchLimit 다음 게시글을 제외한 반환 페이지 ID만 사용한다")
|
||||
fun shouldExcludeFetchLimitPlusOnePostFromCommunityTabTranslationLookup() {
|
||||
val port = FakeCreatorChannelCommunityQueryPort().apply {
|
||||
communityPosts = (1L..51L).map { communityPostRecord(it, price = 0) }
|
||||
}
|
||||
val translationService = Mockito.mock(CreatorCommunityTranslationService::class.java)
|
||||
val service = createService(port, creatorCommunityTranslationService = translationService)
|
||||
val returnedPostIds = (1L..50L).toList()
|
||||
Mockito.`when`(translationService.findDisplayContents(returnedPostIds, "en")).thenReturn(emptyMap())
|
||||
|
||||
val tab = service.getCommunityTab(1L, createMember(id = 10L), page = 0, size = 50)
|
||||
|
||||
assertEquals(50, tab.communityPosts.size)
|
||||
assertTrue(tab.hasNext)
|
||||
Mockito.verify(translationService).findDisplayContents(returnedPostIds, "en")
|
||||
Mockito.verifyNoMoreInteractions(translationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("채널 홈의 고정과 일반 본문은 각 그룹에서 번역 선택만 하고 작업을 만들지 않는다")
|
||||
fun shouldUseStoredTranslationsForPinnedAndNormalHomeCommunityPostsWithoutScheduling() {
|
||||
val port = FakeCreatorChannelCommunityQueryPort()
|
||||
val translationService = Mockito.mock(CreatorCommunityTranslationService::class.java)
|
||||
val service = createService(port, creatorCommunityTranslationService = translationService)
|
||||
port.homeCommunityPosts = listOf(communityPostRecord(21L, price = 0).copy(content = "source pinned"))
|
||||
Mockito.`when`(translationService.findDisplayContents(listOf(21L), "en"))
|
||||
.thenReturn(mapOf(21L to CreatorCommunityDisplayContent("translated pinned", true)))
|
||||
|
||||
val pinnedPosts = service.findHomeCommunityPosts(1L, 10L, true, false, 3)
|
||||
|
||||
port.homeCommunityPosts = listOf(communityPostRecord(22L, price = 0).copy(content = "source normal"))
|
||||
Mockito.`when`(translationService.findDisplayContents(listOf(22L), "en"))
|
||||
.thenReturn(mapOf(22L to CreatorCommunityDisplayContent("translated normal", true)))
|
||||
|
||||
val normalPosts = service.findHomeCommunityPosts(1L, 10L, false, false, 3)
|
||||
|
||||
assertEquals("translated pinned", pinnedPosts.single().content)
|
||||
assertEquals("translated normal", normalPosts.single().content)
|
||||
Mockito.verify(translationService).findDisplayContents(listOf(21L), "en")
|
||||
Mockito.verify(translationService).findDisplayContents(listOf(22L), "en")
|
||||
Mockito.verifyNoMoreInteractions(translationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 상세는 게시글과 초기 댓글 및 최신 답글을 조립한다")
|
||||
fun shouldAssembleCommunityPostDetailWithInitialComments() {
|
||||
@@ -201,6 +289,20 @@ class CreatorChannelCommunityQueryServiceTest {
|
||||
assertEquals("https://cdn.test/profile/default-profile.png", detail.comments.comments.first().writerProfileImageUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("v2 상세는 원문과 같은 유효 번역문에도 누락 예약을 요청하지 않는다")
|
||||
fun shouldNotRequestTranslationsWhenV2DetailHasValidTranslationMatchingSourceText() {
|
||||
val port = FakeCreatorChannelCommunityQueryPort()
|
||||
val translationService = Mockito.mock(CreatorCommunityTranslationService::class.java)
|
||||
val service = createService(port, creatorCommunityTranslationService = translationService)
|
||||
Mockito.`when`(translationService.findDisplayContents(listOf(1L), "en"))
|
||||
.thenReturn(mapOf(1L to CreatorCommunityDisplayContent("content-1", true)))
|
||||
|
||||
service.getCommunityPostDetail(1L, createMember(id = 10L), LocalDateTime.of(2026, 7, 6, 10, 0))
|
||||
|
||||
Mockito.verify(translationService, Mockito.never()).requestTranslations(1L, "en")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("댓글 불가 게시글은 댓글 port를 호출하지 않고 빈 댓글 응답을 반환한다")
|
||||
fun shouldReturnEmptyCommentsWhenCommentUnavailable() {
|
||||
@@ -298,7 +400,9 @@ class CreatorChannelCommunityQueryServiceTest {
|
||||
private fun createService(
|
||||
port: FakeCreatorChannelCommunityQueryPort,
|
||||
audioContentCloudFront: AudioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java),
|
||||
canViewAdultContent: Boolean = true
|
||||
canViewAdultContent: Boolean = true,
|
||||
creatorCommunityTranslationService: CreatorCommunityTranslationService =
|
||||
Mockito.mock(CreatorCommunityTranslationService::class.java)
|
||||
): CreatorChannelCommunityQueryService {
|
||||
val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||
Mockito.`when`(
|
||||
@@ -306,6 +410,8 @@ class CreatorChannelCommunityQueryServiceTest {
|
||||
).thenReturn(canViewAdultContent)
|
||||
val langContext = LangContext()
|
||||
langContext.setLang(Lang.EN)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(Mockito.anyList(), Mockito.anyString()))
|
||||
.thenReturn(emptyMap())
|
||||
return CreatorChannelCommunityQueryService(
|
||||
queryPortProvider = FixedCreatorChannelCommunityQueryPortProvider(port),
|
||||
queryPolicy = CreatorChannelCommunityQueryPolicy(),
|
||||
@@ -313,6 +419,7 @@ class CreatorChannelCommunityQueryServiceTest {
|
||||
audioContentCloudFront = audioContentCloudFront,
|
||||
messageSource = SodaMessageSource(),
|
||||
langContext = langContext,
|
||||
creatorCommunityTranslationService = creatorCommunityTranslationService,
|
||||
cloudFrontHost = "https://cdn.test"
|
||||
)
|
||||
}
|
||||
|
||||
+878
@@ -0,0 +1,878 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.community.translation
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
|
||||
import kr.co.vividnext.sodalive.admin.content.series.genre.AdminContentSeriesGenreRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.content.AudioContentRepository
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||
import kr.co.vividnext.sodalive.content.category.CategoryRepository
|
||||
import kr.co.vividnext.sodalive.content.category.CategoryTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJob
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobRepository
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationMemory
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationMemoryRepository
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationReadModelMaterializer
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationSource
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceExtractor
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceField
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslationRepository
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
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.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.TransactionDefinition
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@DataJpaTest(
|
||||
properties = [
|
||||
"spring.cache.type=none",
|
||||
"spring.datasource.url=jdbc:h2:mem:creator-community-translation;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
|
||||
]
|
||||
)
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import(QueryDslConfig::class)
|
||||
class CreatorCommunityTranslationServiceTest @Autowired constructor(
|
||||
private val persistentCreatorCommunityRepository: CreatorCommunityRepository,
|
||||
private val persistentMemberRepository: MemberRepository,
|
||||
private val persistentTranslationRepository: CreatorCommunityTranslationRepository,
|
||||
private val persistentMemoryRepository: TranslationMemoryRepository,
|
||||
private val persistentTranslationJobRepository: TranslationJobRepository,
|
||||
private val entityManager: EntityManager,
|
||||
private val transactionManager: PlatformTransactionManager
|
||||
) {
|
||||
private val transactionTemplate = TransactionTemplate(transactionManager)
|
||||
private val requiresNewTransactionTemplate = TransactionTemplate(transactionManager).apply {
|
||||
propagationBehavior = Propagation.REQUIRES_NEW.value()
|
||||
}
|
||||
private val repeatableReadTransactionTemplate = TransactionTemplate(transactionManager).apply {
|
||||
isolationLevel = TransactionDefinition.ISOLATION_REPEATABLE_READ
|
||||
}
|
||||
private val applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||
|
||||
@Test
|
||||
fun shouldRequireCreatorCommunityTranslationPersistenceContract() {
|
||||
val fieldNames = CreatorCommunity::class.java.declaredFields.map { it.name }
|
||||
|
||||
assertTrue(fieldNames.contains("languageCode"))
|
||||
assertTrue(fieldNames.contains("contentRevision"))
|
||||
assertTrue(LanguageTranslationTargetType.values().any { it.name == "CREATOR_COMMUNITY" })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("표시 조회는 현재 개정과 원문 언어가 일치하는 번역만 일괄 조회해 반환한다")
|
||||
fun shouldReturnOnlyCurrentTranslationFromBatchedRead() {
|
||||
val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java)
|
||||
val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java)
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
val scheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
|
||||
val service = CreatorCommunityTranslationService(
|
||||
creatorCommunityRepository,
|
||||
translationRepository,
|
||||
sourceExtractor,
|
||||
materializer,
|
||||
scheduler,
|
||||
applicationEventPublisher,
|
||||
Mockito.mock(EntityManager::class.java)
|
||||
)
|
||||
val current = creatorCommunity(postId = 1L, content = "current", languageCode = "ko", contentRevision = 2)
|
||||
val staleRevision = creatorCommunity(postId = 2L, content = "changed", languageCode = "ko", contentRevision = 3)
|
||||
val sameLanguage = creatorCommunity(postId = 3L, content = "original", languageCode = "en", contentRevision = 2)
|
||||
val inactive = creatorCommunity(postId = 4L, content = "hidden", languageCode = "ko", contentRevision = 2).apply {
|
||||
isActive = false
|
||||
}
|
||||
val currentTranslation = translation(postId = 1L, content = "translated", sourceRevision = 2, sourceText = "current")
|
||||
val staleTranslation = translation(postId = 2L, content = "old translated", sourceRevision = 2, sourceText = "changed")
|
||||
val sameLanguageTranslation = translation(
|
||||
postId = 3L,
|
||||
content = "should not show",
|
||||
sourceRevision = 2,
|
||||
sourceText = "original",
|
||||
sourceLanguage = "en"
|
||||
)
|
||||
|
||||
Mockito.`when`(creatorCommunityRepository.findAllById(listOf(1L, 2L, 3L, 4L)))
|
||||
.thenReturn(listOf(current, staleRevision, sameLanguage, inactive))
|
||||
Mockito.`when`(translationRepository.findByCreatorCommunityIdInAndLocale(listOf(1L, 2L, 3L), "en"))
|
||||
.thenReturn(listOf(currentTranslation, staleTranslation, sameLanguageTranslation))
|
||||
|
||||
val contents = service.findDisplayContents(listOf(1L, 2L, 3L, 4L), "EN")
|
||||
|
||||
assertEquals("translated", contents[1L]?.content)
|
||||
assertTrue(contents[1L]?.hasValidTranslation == true)
|
||||
assertEquals("changed", contents[2L]?.content)
|
||||
assertTrue(contents[2L]?.hasValidTranslation == false)
|
||||
assertEquals("original", contents[3L]?.content)
|
||||
assertTrue(contents[3L]?.hasValidTranslation == false)
|
||||
assertTrue(4L !in contents)
|
||||
Mockito.verifyNoInteractions(sourceExtractor, materializer, scheduler)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("번역이 없거나 원문 언어와 해시가 다르면 현재 원문을 반환한다")
|
||||
fun shouldFallbackToOriginalWhenTranslationIsMissingOrDoesNotMatchCurrentSource() {
|
||||
val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java)
|
||||
val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java)
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
val scheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
|
||||
val service = CreatorCommunityTranslationService(
|
||||
creatorCommunityRepository,
|
||||
translationRepository,
|
||||
sourceExtractor,
|
||||
materializer,
|
||||
scheduler,
|
||||
applicationEventPublisher,
|
||||
Mockito.mock(EntityManager::class.java)
|
||||
)
|
||||
val missing = creatorCommunity(1L, "missing", "ko", 1)
|
||||
val sourceLanguageMismatch = creatorCommunity(2L, "language", "ko", 1)
|
||||
val sourceHashMismatch = creatorCommunity(3L, "current", "ko", 1)
|
||||
val languageMismatchTranslation = translation(
|
||||
postId = 2L,
|
||||
content = "wrong language",
|
||||
sourceRevision = 1,
|
||||
sourceText = "language",
|
||||
sourceLanguage = "ja"
|
||||
)
|
||||
val hashMismatchTranslation = translation(
|
||||
postId = 3L,
|
||||
content = "wrong hash",
|
||||
sourceRevision = 1,
|
||||
sourceText = "previous"
|
||||
)
|
||||
|
||||
Mockito.`when`(creatorCommunityRepository.findAllById(listOf(1L, 2L, 3L)))
|
||||
.thenReturn(listOf(missing, sourceLanguageMismatch, sourceHashMismatch))
|
||||
Mockito.`when`(translationRepository.findByCreatorCommunityIdInAndLocale(listOf(1L, 2L, 3L), "en"))
|
||||
.thenReturn(listOf(languageMismatchTranslation, hashMismatchTranslation))
|
||||
|
||||
val contents = service.findDisplayContents(listOf(1L, 2L, 3L), "en")
|
||||
|
||||
assertEquals("missing", contents[1L]?.content)
|
||||
assertTrue(contents[1L]?.hasValidTranslation == false)
|
||||
assertEquals("language", contents[2L]?.content)
|
||||
assertTrue(contents[2L]?.hasValidTranslation == false)
|
||||
assertEquals("current", contents[3L]?.content)
|
||||
assertTrue(contents[3L]?.hasValidTranslation == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("현재 원문의 메모리를 재료화할 수 있으면 작업을 새로 예약하지 않는다")
|
||||
fun shouldRematerializeFromMemoryBeforeScheduling() {
|
||||
val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java)
|
||||
val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java)
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
val scheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
|
||||
val service = CreatorCommunityTranslationService(
|
||||
creatorCommunityRepository,
|
||||
translationRepository,
|
||||
sourceExtractor,
|
||||
materializer,
|
||||
scheduler,
|
||||
applicationEventPublisher,
|
||||
Mockito.mock(EntityManager::class.java)
|
||||
)
|
||||
val source = TranslationSource(
|
||||
resourceType = LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
||||
resourceId = 1L,
|
||||
sourceLanguage = "ko",
|
||||
fields = listOf(TranslationSourceField("content", "A")),
|
||||
sourceRevision = 2
|
||||
)
|
||||
Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(1L))
|
||||
.thenReturn(creatorCommunity(1L, "A", "ko", 2))
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L)).thenReturn(source)
|
||||
Mockito.`when`(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L, "en"))
|
||||
.thenReturn(true)
|
||||
|
||||
service.requestTranslations(1L, "EN")
|
||||
|
||||
Mockito.verify(materializer).materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L, "en")
|
||||
Mockito.verifyNoInteractions(scheduler)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("현재 원문의 메모리가 없으면 기존 작업 예약기를 사용한다")
|
||||
fun shouldScheduleMissingTranslationAfterMemoryCannotMaterialize() {
|
||||
val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java)
|
||||
val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java)
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
val scheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
|
||||
val service = CreatorCommunityTranslationService(
|
||||
creatorCommunityRepository,
|
||||
translationRepository,
|
||||
sourceExtractor,
|
||||
materializer,
|
||||
scheduler,
|
||||
applicationEventPublisher,
|
||||
Mockito.mock(EntityManager::class.java)
|
||||
)
|
||||
val source = TranslationSource(
|
||||
resourceType = LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
||||
resourceId = 1L,
|
||||
sourceLanguage = "ko",
|
||||
fields = listOf(TranslationSourceField("content", "B")),
|
||||
sourceRevision = 3
|
||||
)
|
||||
Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(1L))
|
||||
.thenReturn(creatorCommunity(1L, "B", "ko", 3))
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L)).thenReturn(source)
|
||||
Mockito.`when`(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L, "en"))
|
||||
.thenReturn(false)
|
||||
|
||||
service.requestTranslations(1L, "en")
|
||||
|
||||
Mockito.verify(scheduler).scheduleResourceTranslation(
|
||||
LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
||||
1L,
|
||||
"en"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("상세 요청 언어가 원문 언어와 같으면 번역 작업을 예약하지 않는다")
|
||||
fun shouldNotScheduleTranslationWhenDetailTargetLanguageEqualsSourceLanguage() {
|
||||
val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java)
|
||||
val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java)
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
val scheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
|
||||
val service = CreatorCommunityTranslationService(
|
||||
creatorCommunityRepository,
|
||||
translationRepository,
|
||||
sourceExtractor,
|
||||
materializer,
|
||||
scheduler,
|
||||
applicationEventPublisher,
|
||||
Mockito.mock(EntityManager::class.java)
|
||||
)
|
||||
val post = creatorCommunity(postId = 1L, content = "original", languageCode = "ja", contentRevision = 0)
|
||||
val source = translationSource(content = "original", sourceLanguage = "ja", sourceRevision = 0)
|
||||
Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(1L)).thenReturn(post)
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L)).thenReturn(source)
|
||||
|
||||
service.requestTranslations(1L, "ja")
|
||||
|
||||
Mockito.verifyNoInteractions(materializer, scheduler)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("언어가 없는 현재 커뮤니티 본문은 감지 이벤트에 개정과 요청 언어를 보존한다")
|
||||
fun shouldPublishCurrentContentDetectionEventWhenSourceLanguageIsUnknown() {
|
||||
val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java)
|
||||
val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java)
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
val scheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
|
||||
val service = CreatorCommunityTranslationService(
|
||||
creatorCommunityRepository,
|
||||
translationRepository,
|
||||
sourceExtractor,
|
||||
materializer,
|
||||
scheduler,
|
||||
applicationEventPublisher,
|
||||
Mockito.mock(EntityManager::class.java)
|
||||
)
|
||||
val creatorCommunity = CreatorCommunity(
|
||||
content = "current",
|
||||
price = 0,
|
||||
isCommentAvailable = true,
|
||||
isAdult = false,
|
||||
contentRevision = 2
|
||||
).apply {
|
||||
id = 1L
|
||||
}
|
||||
Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(1L)).thenReturn(creatorCommunity)
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L)).thenReturn(null)
|
||||
Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrue(1L)).thenReturn(creatorCommunity)
|
||||
|
||||
service.requestTranslations(1L, "ja")
|
||||
|
||||
val eventCaptor = org.mockito.ArgumentCaptor.forClass(LanguageDetectEvent::class.java)
|
||||
Mockito.verify(applicationEventPublisher).publishEvent(eventCaptor.capture())
|
||||
assertEquals(1L, eventCaptor.value.id)
|
||||
assertEquals("current", eventCaptor.value.query)
|
||||
assertEquals(LanguageDetectTargetType.CREATOR_COMMUNITY, eventCaptor.value.targetType)
|
||||
assertEquals(2L, eventCaptor.value.sourceRevision)
|
||||
assertEquals("ja", eventCaptor.value.targetLanguage)
|
||||
Mockito.verifyNoInteractions(materializer, scheduler)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("이전 본문으로 돌아오면 번역 메모리에서 현재 개정의 조회 모델을 복원한다")
|
||||
fun shouldRestoreReturnedContentFromTranslationMemoryAtCurrentRevision() {
|
||||
val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java)
|
||||
val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java)
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
|
||||
val materializer = materializer(
|
||||
sourceExtractor,
|
||||
memoryRepository,
|
||||
creatorCommunityRepository,
|
||||
translationRepository
|
||||
)
|
||||
val source = translationSource(content = "A", sourceLanguage = "ko", sourceRevision = 2)
|
||||
val currentPost = creatorCommunity(postId = 1L, content = "A", languageCode = "ko", contentRevision = 2)
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L)).thenReturn(source)
|
||||
givenTranslationMemory(memoryRepository, "A", "ko")
|
||||
Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(1L)).thenReturn(currentPost)
|
||||
Mockito.`when`(translationRepository.findByCreatorCommunityIdAndLocale(1L, "en")).thenReturn(null)
|
||||
|
||||
assertTrue(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L, "en"))
|
||||
|
||||
val translationCaptor = org.mockito.ArgumentCaptor.forClass(CreatorCommunityTranslation::class.java)
|
||||
Mockito.verify(translationRepository).save(translationCaptor.capture())
|
||||
assertEquals("translated A", translationCaptor.value.content)
|
||||
assertEquals(2L, translationCaptor.value.sourceRevision)
|
||||
assertEquals(SourceTextNormalizer.hash("A"), translationCaptor.value.sourceHash)
|
||||
assertEquals("ko", translationCaptor.value.sourceLanguage)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("원문 추출 뒤 본문 개정이 바뀌면 번역 결과를 저장하지 않는다")
|
||||
fun shouldNotStoreTranslationWhenRevisionChangesBeforeLockedWrite() {
|
||||
val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java)
|
||||
val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java)
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
|
||||
val materializer = materializer(
|
||||
sourceExtractor,
|
||||
memoryRepository,
|
||||
creatorCommunityRepository,
|
||||
translationRepository
|
||||
)
|
||||
val source = translationSource(content = "before", sourceLanguage = "ko", sourceRevision = 0)
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L)).thenReturn(source)
|
||||
givenTranslationMemory(memoryRepository, "before", "ko")
|
||||
Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(1L))
|
||||
.thenReturn(creatorCommunity(1L, "after", "ko", 1))
|
||||
|
||||
assertFalse(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L, "en"))
|
||||
|
||||
Mockito.verifyNoInteractions(translationRepository)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("원문 언어가 바뀌면 번역 결과를 저장하지 않는다")
|
||||
fun shouldNotStoreTranslationWhenSourceLanguageChangesBeforeLockedWrite() {
|
||||
val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java)
|
||||
val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java)
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
|
||||
val materializer = materializer(
|
||||
sourceExtractor,
|
||||
memoryRepository,
|
||||
creatorCommunityRepository,
|
||||
translationRepository
|
||||
)
|
||||
val source = translationSource(content = "same", sourceLanguage = "ko", sourceRevision = 0)
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L)).thenReturn(source)
|
||||
givenTranslationMemory(memoryRepository, "same", "ko")
|
||||
Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(1L))
|
||||
.thenReturn(creatorCommunity(1L, "same", "en", 0))
|
||||
|
||||
assertFalse(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L, "en"))
|
||||
|
||||
Mockito.verifyNoInteractions(translationRepository)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("원문 게시물이 비활성화되면 번역 결과를 저장하지 않는다")
|
||||
fun shouldNotStoreTranslationWhenPostBecomesInactiveBeforeLockedWrite() {
|
||||
val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java)
|
||||
val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java)
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
|
||||
val materializer = materializer(
|
||||
sourceExtractor,
|
||||
memoryRepository,
|
||||
creatorCommunityRepository,
|
||||
translationRepository
|
||||
)
|
||||
val source = translationSource(content = "before", sourceLanguage = "ko", sourceRevision = 0)
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L)).thenReturn(source)
|
||||
givenTranslationMemory(memoryRepository, "before", "ko")
|
||||
Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(1L)).thenReturn(null)
|
||||
|
||||
assertFalse(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L, "en"))
|
||||
|
||||
Mockito.verifyNoInteractions(translationRepository)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("이미 관리 중인 게시물이 다른 트랜잭션에서 수정되면 이전 번역을 저장하지 않는다")
|
||||
fun shouldRejectStaleManagedCreatorCommunityAfterConcurrentCommit() {
|
||||
val postId = persistCreatorCommunity("stale-A", 0)
|
||||
persistTranslationMemory("stale-A")
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val materializer = persistentMaterializer(sourceExtractor)
|
||||
val source = translationSource(postId, "stale-A", "ko", 0)
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId)).thenReturn(source)
|
||||
|
||||
transactionTemplate.executeWithoutResult {
|
||||
persistentCreatorCommunityRepository.findById(postId).orElseThrow()
|
||||
updateCreatorCommunity(postId, "stale-B", 1)
|
||||
|
||||
assertFalse(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, "en"))
|
||||
}
|
||||
|
||||
val translation = requiresNewTransactionTemplate.execute {
|
||||
persistentTranslationRepository.findByCreatorCommunityIdAndLocale(postId, "en")
|
||||
}
|
||||
assertEquals(null, translation)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("본문 A에서 B를 거쳐 A로 돌아오면 기존 번역 행을 현재 개정으로 갱신한다")
|
||||
fun shouldRematerializeAtoBtoAIntoExistingTranslationRow() {
|
||||
val postId = persistCreatorCommunity("restore-A", 0)
|
||||
persistTranslationMemory("restore-A")
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val materializer = persistentMaterializer(sourceExtractor)
|
||||
val firstSource = translationSource(postId, "restore-A", "ko", 0)
|
||||
val restoredSource = translationSource(postId, "restore-A", "ko", 2)
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId))
|
||||
.thenReturn(firstSource, restoredSource)
|
||||
|
||||
requiresNewTransactionTemplate.executeWithoutResult {
|
||||
assertTrue(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, "en"))
|
||||
}
|
||||
updateCreatorCommunity(postId, "restore-B", 1)
|
||||
updateCreatorCommunity(postId, "restore-A", 2)
|
||||
requiresNewTransactionTemplate.executeWithoutResult {
|
||||
assertTrue(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, "en"))
|
||||
}
|
||||
|
||||
val translation = requiresNewTransactionTemplate.execute {
|
||||
persistentTranslationRepository.findByCreatorCommunityIdAndLocale(postId, "en")
|
||||
}!!
|
||||
assertEquals("translated restore-A", translation.content)
|
||||
assertEquals(2L, translation.sourceRevision)
|
||||
assertEquals(SourceTextNormalizer.hash("restore-A"), translation.sourceHash)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("동시 재료화는 게시물 잠금과 locale 유일 키로 하나의 번역 행만 저장한다")
|
||||
fun shouldSerializeConcurrentCreatorCommunityUpserts() {
|
||||
val postId = persistCreatorCommunity("concurrent-A", 2)
|
||||
persistTranslationMemory("concurrent-A")
|
||||
val source = translationSource(postId, "concurrent-A", "ko", 2)
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val materializer = persistentMaterializer(sourceExtractor)
|
||||
val readyToLock = CountDownLatch(2)
|
||||
val releaseLock = CountDownLatch(1)
|
||||
val completed = CountDownLatch(2)
|
||||
val errors = ConcurrentLinkedQueue<Throwable>()
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId)).thenAnswer {
|
||||
readyToLock.countDown()
|
||||
if (!releaseLock.await(5, TimeUnit.SECONDS)) {
|
||||
throw IllegalStateException("workers did not begin together")
|
||||
}
|
||||
source
|
||||
}
|
||||
|
||||
try {
|
||||
repeat(2) {
|
||||
executor.submit {
|
||||
try {
|
||||
transactionTemplate.executeWithoutResult {
|
||||
materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, "en")
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
errors.add(error)
|
||||
} finally {
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(readyToLock.await(5, TimeUnit.SECONDS))
|
||||
releaseLock.countDown()
|
||||
assertTrue(completed.await(10, TimeUnit.SECONDS))
|
||||
assertTrue(errors.isEmpty())
|
||||
} finally {
|
||||
releaseLock.countDown()
|
||||
executor.shutdownNow()
|
||||
}
|
||||
|
||||
val translations = transactionTemplate.execute {
|
||||
persistentTranslationRepository.findByCreatorCommunityIdInAndLocale(listOf(postId), "en")
|
||||
}.orEmpty()
|
||||
assertEquals(1, translations.size)
|
||||
assertEquals("translated concurrent-A", translations.single().content)
|
||||
assertEquals(2L, translations.single().sourceRevision)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("같은 게시물의 누락 번역 예약은 하나의 작업만 만들고 둘 다 정상 종료한다")
|
||||
fun shouldSerializeConcurrentMissingMemoryTranslationJobScheduling() {
|
||||
val postId = persistCreatorCommunity("concurrent-job", 0)
|
||||
val missingJobReads = CountDownLatch(2)
|
||||
val jobRepository = delayingTranslationJobRepository(postId, "concurrent-job", missingJobReads)
|
||||
val service = persistentService(jobRepository)
|
||||
val start = CountDownLatch(1)
|
||||
val completed = CountDownLatch(2)
|
||||
val errors = ConcurrentLinkedQueue<Throwable>()
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
|
||||
try {
|
||||
repeat(2) {
|
||||
executor.submit {
|
||||
try {
|
||||
check(start.await(5, TimeUnit.SECONDS))
|
||||
requiresNewTransactionTemplate.executeWithoutResult {
|
||||
service.requestTranslations(postId, "en")
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
errors.add(error)
|
||||
} finally {
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start.countDown()
|
||||
assertTrue(completed.await(10, TimeUnit.SECONDS))
|
||||
} finally {
|
||||
start.countDown()
|
||||
executor.shutdownNow()
|
||||
}
|
||||
|
||||
assertTrue(errors.isEmpty())
|
||||
val jobs = requiresNewTransactionTemplate.execute {
|
||||
persistentTranslationJobRepository.findAll().filter {
|
||||
it.resourceType == LanguageTranslationTargetType.CREATOR_COMMUNITY &&
|
||||
it.resourceId == postId &&
|
||||
it.targetLanguage == "en"
|
||||
}
|
||||
}.orEmpty()
|
||||
assertEquals(1, jobs.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("반복 읽기 스냅샷 뒤 번역 행을 잠가 기존 행을 갱신한다")
|
||||
fun shouldUpdateExistingTranslationAfterRepeatableReadSnapshotWaitsForFirstWriter() {
|
||||
val postId = persistCreatorCommunity("repeatable-A", 0)
|
||||
persistTranslationMemory("repeatable-A")
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val materializer = persistentMaterializer(sourceExtractor)
|
||||
val source = translationSource(postId, "repeatable-A", "ko", 0)
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId)).thenReturn(source)
|
||||
val snapshotReady = CountDownLatch(2)
|
||||
val startFirstWriter = CountDownLatch(1)
|
||||
val firstWriterCommitted = CountDownLatch(1)
|
||||
val completed = CountDownLatch(2)
|
||||
val errors = ConcurrentLinkedQueue<Throwable>()
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
|
||||
try {
|
||||
executor.submit {
|
||||
try {
|
||||
repeatableReadTransactionTemplate.executeWithoutResult {
|
||||
loadTranslationMemoryForSnapshot("repeatable-A")
|
||||
snapshotReady.countDown()
|
||||
startFirstWriter.await()
|
||||
assertTrue(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, "en"))
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
errors.add(error)
|
||||
} finally {
|
||||
firstWriterCommitted.countDown()
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
executor.submit {
|
||||
try {
|
||||
repeatableReadTransactionTemplate.executeWithoutResult {
|
||||
loadTranslationMemoryForSnapshot("repeatable-A")
|
||||
snapshotReady.countDown()
|
||||
firstWriterCommitted.await()
|
||||
assertTrue(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, "en"))
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
errors.add(error)
|
||||
} finally {
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(snapshotReady.await(5, TimeUnit.SECONDS))
|
||||
startFirstWriter.countDown()
|
||||
assertTrue(completed.await(10, TimeUnit.SECONDS))
|
||||
assertTrue(errors.isEmpty())
|
||||
} finally {
|
||||
startFirstWriter.countDown()
|
||||
firstWriterCommitted.countDown()
|
||||
executor.shutdownNow()
|
||||
}
|
||||
|
||||
val translations = requiresNewTransactionTemplate.execute {
|
||||
persistentTranslationRepository.findByCreatorCommunityIdInAndLocale(listOf(postId), "en")
|
||||
}.orEmpty()
|
||||
assertEquals(1, translations.size)
|
||||
assertEquals("translated repeatable-A", translations.single().content)
|
||||
}
|
||||
|
||||
private fun materializer(
|
||||
sourceExtractor: TranslationSourceExtractor,
|
||||
memoryRepository: TranslationMemoryRepository,
|
||||
creatorCommunityRepository: CreatorCommunityRepository,
|
||||
translationRepository: CreatorCommunityTranslationRepository,
|
||||
entityManager: EntityManager = Mockito.mock(EntityManager::class.java)
|
||||
): TranslationReadModelMaterializer {
|
||||
return TranslationReadModelMaterializer(
|
||||
sourceExtractor = sourceExtractor,
|
||||
translationMemoryRepository = memoryRepository,
|
||||
contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java),
|
||||
aiCharacterTranslationRepository = Mockito.mock(AiCharacterTranslationRepository::class.java),
|
||||
contentThemeTranslationRepository = Mockito.mock(ContentThemeTranslationRepository::class.java),
|
||||
seriesTranslationRepository = Mockito.mock(SeriesTranslationRepository::class.java),
|
||||
seriesGenreTranslationRepository = Mockito.mock(SeriesGenreTranslationRepository::class.java),
|
||||
originalWorkTranslationRepository = Mockito.mock(OriginalWorkTranslationRepository::class.java),
|
||||
categoryTranslationRepository = Mockito.mock(CategoryTranslationRepository::class.java),
|
||||
creatorCommunityRepository = creatorCommunityRepository,
|
||||
creatorCommunityTranslationRepository = translationRepository,
|
||||
entityManager = entityManager
|
||||
)
|
||||
}
|
||||
|
||||
private fun persistentMaterializer(sourceExtractor: TranslationSourceExtractor): TranslationReadModelMaterializer {
|
||||
return materializer(
|
||||
sourceExtractor,
|
||||
persistentMemoryRepository,
|
||||
persistentCreatorCommunityRepository,
|
||||
persistentTranslationRepository,
|
||||
entityManager
|
||||
)
|
||||
}
|
||||
|
||||
private fun persistentService(translationJobRepository: TranslationJobRepository): CreatorCommunityTranslationService {
|
||||
val sourceExtractor = TranslationSourceExtractor(
|
||||
audioContentRepository = Mockito.mock(AudioContentRepository::class.java),
|
||||
chatCharacterRepository = Mockito.mock(ChatCharacterRepository::class.java),
|
||||
audioContentThemeRepository = Mockito.mock(AudioContentThemeQueryRepository::class.java),
|
||||
seriesRepository = Mockito.mock(AdminContentSeriesRepository::class.java),
|
||||
seriesGenreRepository = Mockito.mock(AdminContentSeriesGenreRepository::class.java),
|
||||
originalWorkRepository = Mockito.mock(OriginalWorkRepository::class.java),
|
||||
categoryRepository = Mockito.mock(CategoryRepository::class.java),
|
||||
creatorCommunityRepository = persistentCreatorCommunityRepository
|
||||
)
|
||||
return CreatorCommunityTranslationService(
|
||||
creatorCommunityRepository = persistentCreatorCommunityRepository,
|
||||
creatorCommunityTranslationRepository = persistentTranslationRepository,
|
||||
sourceExtractor = sourceExtractor,
|
||||
materializer = persistentMaterializer(sourceExtractor),
|
||||
resourceTranslationJobScheduler = ResourceTranslationJobScheduler(
|
||||
sourceExtractor,
|
||||
TranslationJobScheduler(translationJobRepository)
|
||||
),
|
||||
applicationEventPublisher = applicationEventPublisher,
|
||||
entityManager = entityManager
|
||||
)
|
||||
}
|
||||
|
||||
private fun delayingTranslationJobRepository(
|
||||
postId: Long,
|
||||
sourceText: String,
|
||||
missingJobReads: CountDownLatch
|
||||
): TranslationJobRepository {
|
||||
val sourceHash = SourceTextNormalizer.hash(sourceText)
|
||||
val translationJobRepository = Mockito.mock(TranslationJobRepository::class.java)
|
||||
Mockito.`when`(
|
||||
translationJobRepository.findByResourceTypeAndResourceIdAndFieldKeyAndTargetLanguageAndSourceHash(
|
||||
LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
||||
postId,
|
||||
"content",
|
||||
"en",
|
||||
sourceHash
|
||||
)
|
||||
).thenAnswer {
|
||||
val existing = persistentTranslationJobRepository
|
||||
.findByResourceTypeAndResourceIdAndFieldKeyAndTargetLanguageAndSourceHash(
|
||||
LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
||||
postId,
|
||||
"content",
|
||||
"en",
|
||||
sourceHash
|
||||
)
|
||||
if (existing == null) {
|
||||
missingJobReads.countDown()
|
||||
missingJobReads.await(1, TimeUnit.SECONDS)
|
||||
}
|
||||
existing
|
||||
}
|
||||
Mockito.`when`(translationJobRepository.save(Mockito.any(TranslationJob::class.java))).thenAnswer {
|
||||
persistentTranslationJobRepository.save(it.getArgument(0))
|
||||
}
|
||||
return translationJobRepository
|
||||
}
|
||||
|
||||
private fun translationSource(
|
||||
postId: Long = 1L,
|
||||
content: String,
|
||||
sourceLanguage: String,
|
||||
sourceRevision: Long
|
||||
): TranslationSource {
|
||||
return TranslationSource(
|
||||
resourceType = LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
||||
resourceId = postId,
|
||||
sourceLanguage = sourceLanguage,
|
||||
fields = listOf(TranslationSourceField("content", content)),
|
||||
sourceRevision = sourceRevision
|
||||
)
|
||||
}
|
||||
|
||||
private fun translationMemory(sourceText: String, sourceLanguage: String): TranslationMemory {
|
||||
return TranslationMemory(
|
||||
sourceHash = SourceTextNormalizer.hash(sourceText),
|
||||
sourceText = sourceText,
|
||||
sourceLanguage = sourceLanguage,
|
||||
targetLanguage = "en",
|
||||
translatedText = "translated $sourceText",
|
||||
provider = "papago",
|
||||
providerVersion = "nmt-v1"
|
||||
)
|
||||
}
|
||||
|
||||
private fun persistCreatorCommunity(content: String, contentRevision: Long): Long {
|
||||
return requiresNewTransactionTemplate.execute {
|
||||
val member = Member(
|
||||
password = "password",
|
||||
nickname = "translation-${UUID.randomUUID()}",
|
||||
role = MemberRole.CREATOR
|
||||
)
|
||||
val post = CreatorCommunity(
|
||||
content = content,
|
||||
price = 0,
|
||||
isCommentAvailable = true,
|
||||
isAdult = false,
|
||||
languageCode = "ko",
|
||||
contentRevision = contentRevision
|
||||
).apply {
|
||||
this.member = persistentMemberRepository.save(member)
|
||||
}
|
||||
persistentCreatorCommunityRepository.save(post).id!!
|
||||
}!!
|
||||
}
|
||||
|
||||
private fun persistTranslationMemory(sourceText: String) {
|
||||
requiresNewTransactionTemplate.executeWithoutResult {
|
||||
persistentMemoryRepository.save(translationMemory(sourceText, "ko"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCreatorCommunity(postId: Long, content: String, contentRevision: Long) {
|
||||
requiresNewTransactionTemplate.executeWithoutResult {
|
||||
val post = persistentCreatorCommunityRepository.findById(postId).orElseThrow()
|
||||
post.content = content
|
||||
post.contentRevision = contentRevision
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadTranslationMemoryForSnapshot(sourceText: String) {
|
||||
persistentMemoryRepository.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
|
||||
SourceTextNormalizer.hash(sourceText),
|
||||
"ko",
|
||||
"en",
|
||||
"papago",
|
||||
SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
}
|
||||
|
||||
private fun givenTranslationMemory(
|
||||
memoryRepository: TranslationMemoryRepository,
|
||||
sourceText: String,
|
||||
sourceLanguage: String
|
||||
) {
|
||||
Mockito.`when`(
|
||||
memoryRepository.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
|
||||
SourceTextNormalizer.hash(sourceText),
|
||||
sourceLanguage,
|
||||
"en",
|
||||
"papago",
|
||||
SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
).thenReturn(translationMemory(sourceText, sourceLanguage))
|
||||
}
|
||||
|
||||
private fun creatorCommunity(
|
||||
postId: Long,
|
||||
content: String,
|
||||
languageCode: String,
|
||||
contentRevision: Long
|
||||
): CreatorCommunity {
|
||||
return CreatorCommunity(
|
||||
content = content,
|
||||
price = 0,
|
||||
isCommentAvailable = true,
|
||||
isAdult = false,
|
||||
languageCode = languageCode,
|
||||
contentRevision = contentRevision
|
||||
).apply {
|
||||
id = postId
|
||||
}
|
||||
}
|
||||
|
||||
private fun translation(
|
||||
postId: Long,
|
||||
content: String,
|
||||
sourceRevision: Long,
|
||||
sourceText: String,
|
||||
sourceLanguage: String = "ko"
|
||||
): CreatorCommunityTranslation {
|
||||
return CreatorCommunityTranslation(
|
||||
creatorCommunityId = postId,
|
||||
locale = "en",
|
||||
content = content,
|
||||
sourceRevision = sourceRevision,
|
||||
sourceHash = SourceTextNormalizer.hash(sourceText),
|
||||
sourceLanguage = sourceLanguage
|
||||
)
|
||||
}
|
||||
}
|
||||
+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,
|
||||
|
||||
+51
-13
@@ -23,6 +23,7 @@ import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChann
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelActivity
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCreator
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelDonation
|
||||
@@ -101,6 +102,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 +488,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)}" }
|
||||
@@ -485,6 +506,7 @@ class CreatorChannelHomeQueryServiceTest {
|
||||
audioContentCloudFront = audioContentCloudFront,
|
||||
messageSource = messageSource,
|
||||
langContext = langContext,
|
||||
creatorCommunityTranslationService = Mockito.mock(CreatorCommunityTranslationService::class.java),
|
||||
cloudFrontHost = "https://cdn.test"
|
||||
),
|
||||
queryPolicy = CreatorChannelHomeQueryPolicy(),
|
||||
@@ -565,6 +587,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 +623,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 +652,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 +676,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 +694,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(
|
||||
|
||||
+66
-3
@@ -1,10 +1,16 @@
|
||||
package kr.co.vividnext.sodalive.v2.home.following.application
|
||||
|
||||
import kr.co.vividnext.sodalive.i18n.Lang
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.member.Gender
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.auth.Auth
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityDisplayContent
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCommunityPostNews
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews
|
||||
@@ -23,10 +29,14 @@ import java.util.TimeZone
|
||||
class HomeFollowingQueryServiceTest {
|
||||
private val queryPort = RecordingHomeFollowingQueryPort()
|
||||
private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||
private val langContext = LangContext()
|
||||
private val creatorCommunityTranslationService = Mockito.mock(CreatorCommunityTranslationService::class.java)
|
||||
private val now = LocalDateTime.of(2026, 6, 25, 12, 0)
|
||||
private val service = HomeFollowingQueryService(
|
||||
queryPort,
|
||||
memberContentPreferenceService
|
||||
memberContentPreferenceService,
|
||||
langContext,
|
||||
creatorCommunityTranslationService
|
||||
) { now }
|
||||
|
||||
@Test
|
||||
@@ -86,7 +96,12 @@ class HomeFollowingQueryServiceTest {
|
||||
try {
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"))
|
||||
val defaultQueryPort = RecordingHomeFollowingQueryPort()
|
||||
val defaultService = HomeFollowingQueryService(defaultQueryPort, memberContentPreferenceService)
|
||||
val defaultService = HomeFollowingQueryService(
|
||||
defaultQueryPort,
|
||||
memberContentPreferenceService,
|
||||
langContext,
|
||||
creatorCommunityTranslationService
|
||||
)
|
||||
val member = member(12L)
|
||||
Mockito.`when`(memberContentPreferenceService.canViewAdultContent(member)).thenReturn(true)
|
||||
val beforeUtc = LocalDateTime.now(ZoneOffset.UTC).minusSeconds(1)
|
||||
@@ -103,10 +118,57 @@ class HomeFollowingQueryServiceTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("팔로잉 최근 소식은 커뮤니티 게시물만 영어 표시 본문으로 일괄 치환하고 다른 소식과 원문 폴백을 유지한다")
|
||||
fun shouldTranslateOnlyCommunityPostNewsInOneEnglishBatch() {
|
||||
langContext.setLang(Lang.EN)
|
||||
val translatedNews = communityNews("community-1", 101L, "original-1")
|
||||
val audioNews = HomeFollowingNews(
|
||||
newsId = "audio-1",
|
||||
type = FollowingNewsType.AUDIO_CONTENT,
|
||||
visibleFromAtUtc = "2026-06-25T11:59:00Z"
|
||||
)
|
||||
val fallbackNews = communityNews("community-2", 102L, "historical-original-2")
|
||||
queryPort.recentNews = listOf(translatedNews, audioNews, fallbackNews)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(101L, 102L), "en"))
|
||||
.thenReturn(
|
||||
mapOf(
|
||||
101L to CreatorCommunityDisplayContent("translated-1-en", true),
|
||||
102L to CreatorCommunityDisplayContent("current-original-2", false)
|
||||
)
|
||||
)
|
||||
|
||||
val home = service.findHomeFollowing(member(14L))
|
||||
|
||||
assertEquals("translated-1-en", home.recentNews[0].communityPost?.content)
|
||||
assertEquals(audioNews, home.recentNews[1])
|
||||
assertEquals("current-original-2", home.recentNews[2].communityPost?.content)
|
||||
Mockito.verify(creatorCommunityTranslationService).findDisplayContents(listOf(101L, 102L), "en")
|
||||
Mockito.verifyNoMoreInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
private fun member(id: Long): Member {
|
||||
return Member(email = "member-$id@test.com", password = "password", nickname = "member-$id").apply { this.id = id }
|
||||
}
|
||||
|
||||
private fun communityNews(newsId: String, postId: Long, content: String): HomeFollowingNews {
|
||||
return HomeFollowingNews(
|
||||
newsId = newsId,
|
||||
type = FollowingNewsType.COMMUNITY_POST,
|
||||
visibleFromAtUtc = "2026-06-25T12:00:00Z",
|
||||
communityPost = HomeFollowingCommunityPostNews(
|
||||
postId = postId,
|
||||
creatorProfileImage = "profile.png",
|
||||
creatorNickname = "creator",
|
||||
imageUrl = "image.png",
|
||||
content = content,
|
||||
createdAt = "2026-06-25T12:00:00Z",
|
||||
likeCount = 1,
|
||||
commentCount = 2
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private class RecordingHomeFollowingQueryPort : HomeFollowingQueryPort {
|
||||
var memberId: Long? = null
|
||||
var followingCreatorsLimit: Int? = null
|
||||
@@ -122,6 +184,7 @@ class HomeFollowingQueryServiceTest {
|
||||
var recentNewsLimit: Int? = null
|
||||
var recentNewsNow: LocalDateTime? = null
|
||||
var recentNewsCanViewAdultContent: Boolean? = null
|
||||
var recentNews: List<HomeFollowingNews> = emptyList()
|
||||
|
||||
override fun findFollowingCreators(memberId: Long, limit: Int): List<HomeFollowingCreator> {
|
||||
this.memberId = memberId
|
||||
@@ -168,7 +231,7 @@ class HomeFollowingQueryServiceTest {
|
||||
recentNewsCanViewAdultContent = canViewAdultContent
|
||||
recentNewsNow = nowUtc
|
||||
recentNewsLimit = limit
|
||||
return emptyList()
|
||||
return recentNews
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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,
|
||||
|
||||
+159
-16
@@ -1,7 +1,10 @@
|
||||
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.creator.channel.community.translation.application.CreatorCommunityDisplayContent
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeBannerRecommendationRecord
|
||||
@@ -19,6 +22,7 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapsho
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
@@ -27,7 +31,19 @@ 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 creatorCommunityTranslationService = Mockito.mock(CreatorCommunityTranslationService::class.java)
|
||||
private val service = HomeRecommendationQueryService(
|
||||
port,
|
||||
snapshotPort,
|
||||
langContext = langContext,
|
||||
creatorCommunityTranslationService = creatorCommunityTranslationService
|
||||
)
|
||||
|
||||
init {
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(Mockito.anyList(), Mockito.anyString()))
|
||||
.thenReturn(emptyMap())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("다시듣기 테마 콘텐츠는 AUDIO가 아니라 LIVE_REPLAY 활동으로 분류한다")
|
||||
@@ -152,6 +168,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 +176,13 @@ 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,
|
||||
creatorCommunityTranslationService = creatorCommunityTranslationService
|
||||
)
|
||||
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
|
||||
val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
@@ -261,7 +284,13 @@ 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,
|
||||
creatorCommunityTranslationService = creatorCommunityTranslationService
|
||||
)
|
||||
port.aiCharacterDetails = listOf(
|
||||
HomeAiCharacterRecommendationRecord(
|
||||
characterId = 10L,
|
||||
@@ -286,7 +315,13 @@ 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,
|
||||
creatorCommunityTranslationService = creatorCommunityTranslationService
|
||||
)
|
||||
|
||||
val characters = service.findAiCharacterRecommendations()
|
||||
|
||||
@@ -302,7 +337,13 @@ 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,
|
||||
creatorCommunityTranslationService = creatorCommunityTranslationService
|
||||
)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.AI_CHARACTER,
|
||||
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
|
||||
@@ -325,7 +366,13 @@ 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,
|
||||
creatorCommunityTranslationService = creatorCommunityTranslationService
|
||||
)
|
||||
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.AI_CHARACTER,
|
||||
@@ -352,7 +399,13 @@ 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,
|
||||
creatorCommunityTranslationService = creatorCommunityTranslationService
|
||||
)
|
||||
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.AI_CHARACTER,
|
||||
@@ -376,7 +429,9 @@ class HomeRecommendationQueryServiceTest {
|
||||
port,
|
||||
snapshotPort,
|
||||
cheerCreatorSnapshotFallbackService = fallback,
|
||||
clock = fixedClock()
|
||||
clock = fixedClock(),
|
||||
langContext = langContext,
|
||||
creatorCommunityTranslationService = creatorCommunityTranslationService
|
||||
)
|
||||
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
@@ -419,7 +474,9 @@ class HomeRecommendationQueryServiceTest {
|
||||
port,
|
||||
snapshotPort,
|
||||
cheerCreatorSnapshotFallbackService = fallback,
|
||||
clock = fixedClock()
|
||||
clock = fixedClock(),
|
||||
langContext = langContext,
|
||||
creatorCommunityTranslationService = creatorCommunityTranslationService
|
||||
)
|
||||
port.cheerCreatorDetails = listOf(
|
||||
HomeCheerCreatorRecommendationRecord(
|
||||
@@ -448,7 +505,9 @@ class HomeRecommendationQueryServiceTest {
|
||||
port,
|
||||
snapshotPort,
|
||||
cheerCreatorSnapshotFallbackService = fallback,
|
||||
clock = fixedClock()
|
||||
clock = fixedClock(),
|
||||
langContext = langContext,
|
||||
creatorCommunityTranslationService = creatorCommunityTranslationService
|
||||
)
|
||||
val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
@@ -483,7 +542,9 @@ class HomeRecommendationQueryServiceTest {
|
||||
port,
|
||||
snapshotPort,
|
||||
cheerCreatorSnapshotFallbackService = fallback,
|
||||
clock = fixedClock()
|
||||
clock = fixedClock(),
|
||||
langContext = langContext,
|
||||
creatorCommunityTranslationService = creatorCommunityTranslationService
|
||||
)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.CHEER_CREATOR,
|
||||
@@ -502,7 +563,13 @@ class HomeRecommendationQueryServiceTest {
|
||||
@Test
|
||||
@DisplayName("인기 커뮤니티 추천은 대상일 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다")
|
||||
fun shouldFindPopularCommunitiesFromTargetDaySnapshotsWithLimitDetailsAndCreatorUniqueness() {
|
||||
val service = HomeRecommendationQueryService(port, snapshotPort, clock = fixedClock())
|
||||
val service = HomeRecommendationQueryService(
|
||||
port,
|
||||
snapshotPort,
|
||||
clock = fixedClock(),
|
||||
langContext = langContext,
|
||||
creatorCommunityTranslationService = creatorCommunityTranslationService
|
||||
)
|
||||
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.POPULAR_COMMUNITY,
|
||||
@@ -576,7 +643,13 @@ class HomeRecommendationQueryServiceTest {
|
||||
@Test
|
||||
@DisplayName("인기 커뮤니티 추천은 상위 스냅샷 중복 제거 후 뒤 후보로 기본 10개를 채운다")
|
||||
fun shouldBackfillPopularCommunitiesAfterRemovingDuplicateCreators() {
|
||||
val service = HomeRecommendationQueryService(port, snapshotPort, clock = fixedClock())
|
||||
val service = HomeRecommendationQueryService(
|
||||
port,
|
||||
snapshotPort,
|
||||
clock = fixedClock(),
|
||||
langContext = langContext,
|
||||
creatorCommunityTranslationService = creatorCommunityTranslationService
|
||||
)
|
||||
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.POPULAR_COMMUNITY,
|
||||
@@ -611,6 +684,47 @@ class HomeRecommendationQueryServiceTest {
|
||||
assertEquals(communities.size, communities.map { it.creatorId }.toSet().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("인기 커뮤니티 추천은 최종 노출 게시물만 일본어 표시 본문으로 일괄 치환하고 원문 폴백을 유지한다")
|
||||
fun shouldTranslateOnlyFinalPopularCommunityRecommendationsInOneJapaneseBatch() {
|
||||
langContext.setLang(Lang.JA)
|
||||
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.POPULAR_COMMUNITY,
|
||||
snapshotAt,
|
||||
(1L..4L).map { targetId ->
|
||||
snapshot(RecommendedSectionType.POPULAR_COMMUNITY, targetId, 100.0 - targetId, snapshotAt)
|
||||
}
|
||||
)
|
||||
port.popularCommunityDetails = listOf(
|
||||
popularCommunity(1L, creatorId = 10L, content = "original-1"),
|
||||
popularCommunity(2L, creatorId = 10L, content = "original-2"),
|
||||
popularCommunity(3L, creatorId = 11L, content = "historical-original-3"),
|
||||
popularCommunity(4L, creatorId = 12L, content = "original-4")
|
||||
)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(1L, 3L), "ja"))
|
||||
.thenReturn(
|
||||
mapOf(
|
||||
1L to CreatorCommunityDisplayContent("translated-1-ja", true),
|
||||
3L to CreatorCommunityDisplayContent("current-original-3", false)
|
||||
)
|
||||
)
|
||||
val translatedService = HomeRecommendationQueryService(
|
||||
port,
|
||||
snapshotPort,
|
||||
clock = fixedClock(),
|
||||
langContext = langContext,
|
||||
creatorCommunityTranslationService = creatorCommunityTranslationService
|
||||
)
|
||||
|
||||
val communities = translatedService.findPopularCommunityRecommendations(limit = 2)
|
||||
|
||||
assertEquals(listOf(1L, 3L), communities.map { it.communityId })
|
||||
assertEquals(listOf("translated-1-ja", "current-original-3"), communities.map { it.content })
|
||||
Mockito.verify(creatorCommunityTranslationService).findDisplayContents(listOf(1L, 3L), "ja")
|
||||
Mockito.verifyNoMoreInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("인기 커뮤니티 추천은 대상일 스냅샷이 없고 오래된 스냅샷만 있으면 fallback refresh를 호출한다")
|
||||
fun shouldFallbackWhenOnlyStalePopularCommunitySnapshotsExist() {
|
||||
@@ -621,7 +735,9 @@ class HomeRecommendationQueryServiceTest {
|
||||
port,
|
||||
snapshotPort,
|
||||
popularCommunitySnapshotFallbackService = fallback,
|
||||
clock = fixedClock()
|
||||
clock = fixedClock(),
|
||||
langContext = langContext,
|
||||
creatorCommunityTranslationService = creatorCommunityTranslationService
|
||||
)
|
||||
val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
@@ -666,7 +782,9 @@ class HomeRecommendationQueryServiceTest {
|
||||
port,
|
||||
snapshotPort,
|
||||
popularCommunitySnapshotFallbackService = fallback,
|
||||
clock = fixedClock()
|
||||
clock = fixedClock(),
|
||||
langContext = langContext,
|
||||
creatorCommunityTranslationService = creatorCommunityTranslationService
|
||||
)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.POPULAR_COMMUNITY,
|
||||
@@ -991,6 +1109,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 +1240,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
|
||||
}
|
||||
|
||||
@@ -1200,6 +1321,28 @@ class HomeRecommendationQueryServiceTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun popularCommunity(
|
||||
communityId: Long,
|
||||
creatorId: Long,
|
||||
content: String
|
||||
): HomePopularCommunityRecommendationRecord {
|
||||
return HomePopularCommunityRecommendationRecord(
|
||||
communityId = communityId,
|
||||
creatorId = creatorId,
|
||||
creatorNickname = "creator-$creatorId",
|
||||
creatorProfileImage = null,
|
||||
imagePath = null,
|
||||
audioPath = null,
|
||||
content = content,
|
||||
price = 0,
|
||||
createdAt = LocalDateTime.of(2026, 7, 9, 1, 0),
|
||||
likeCount = 0L,
|
||||
commentCount = 0L,
|
||||
existOrdered = false,
|
||||
isLiked = false
|
||||
)
|
||||
}
|
||||
|
||||
private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort {
|
||||
private val snapshots = mutableListOf<RecommendationSnapshotRecord>()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user