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:
2026-09-10 07:58:31 +00:00
73 changed files with 7564 additions and 197 deletions
@@ -0,0 +1,332 @@
# v2 콘텐츠 목록 요청 언어별 번역 구현 계획
- 작성일: 2026-09-09
- 상태: Phase 1·2 구현 및 자동 검증 완료, test 서버 HTTP Gate 대기
- 요구사항·API 기준: [prd.md](prd.md)
- 현재 활성 Goal: P1-GATE test 서버 HTTP 확인 대기
- 다음 Goal: P2-GATE test 서버 HTTP 확인
- 실행 여부와 결과는 각 Task/Gate 실행 기록을 기준으로 한다.
## 목표와 범위
PRD의 7개 GET API에서 오디오 제목·시리즈 제목/시리즈명·테마명을 요청 언어로 표시하고,
번역이 없거나 공백이면 기존 원문을 반환한다. 제외 항목은 PRD §2와 동일하다.
| Phase | 결과 | 상태 | 완료 Task |
|---|---|---|---|
| 1 | 채널·메인 전체 목록 번역 통일 | 자동 검증 완료, test 서버 HTTP Gate 대기 | 2/2 |
| 2 | 추천·더보기·랭킹 번역 통일 | 자동 검증 완료, test 서버 HTTP Gate 대기 | 2/2 |
## 구현 원칙
- 조사는 v2 패키지에서 시작하고 연결된 기존 번역 저장·조회 코드를 확인한다.
- 기존 LangInterceptor/LangContext와 ko/en/ja 파싱을 재사용하며 공통 언어 파서를 변경하지 않는다.
- Kotlin/Spring 기존 계층과 스타일을 따른다. 공개 응답 스키마와 인증·필터·정렬·페이지 조건은 유지한다.
- 번역 테이블 locale 조인 또는 기존 일괄 조회를 재사용한다. 항목 수만큼 추가 조회하는 N+1은 금지한다.
- 랭킹·추천 스냅샷의 집계/저장과 요청별 번역 표시를 분리한다.
- 저장 데이터·DDL·번역 생성 파이프라인·배너 국가별 선택 정책은 변경하지 않는다.
- 파일 목록은 수정 후보 및 확인 책임 범위다. 기존 동작이 맞는 파일은 테스트로만 확인한다.
- 기존 함수의 모든 호출자를 확인하고 공용 호출에 변경이 전파되면 관련 테스트와 이 문서에 기록한다.
- 새 추상화·의존성 없이 기존 Port/Repository를 최소 확장한다.
## 공통 참조 파일
- src/main/kotlin/kr/co/vividnext/sodalive/i18n/LangInterceptor.kt
- src/main/kotlin/kr/co/vividnext/sodalive/i18n/LangContext.kt
- src/main/kotlin/kr/co/vividnext/sodalive/i18n/Lang.kt
- src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslationRepository.kt
- src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt
## 공통 테스트 데이터
같은 원문에 서로 다른 ko/en/ja 번역을 저장한다. 번역 행 없음·빈 문자열·공백·다른 locale만 존재·
오디오와 시리즈 중 한쪽만 번역된 경우를 포함한다.
조회 요청에는 ko/en/ja, en-US, ja-JP, 누락, 빈 값, 미지원 fr를 사용한다.
en→ja→en 연속 요청에서 언어가 섞이지 않아야 한다.
언어별 응답은 대상 문자열만 달라지고 ID·개수·순서·필터·페이지·가격·제외 필드는 같아야 한다.
### Phase 1: 채널·메인 전체 목록
시작 조건: 사용자 구현 요청. 결과: LIST-01~04의 번역 표시와 기존 동작 유지.
- [x] **Task 1.1: 채널 오디오·시리즈·홈의 번역 표시 통일**
**Goal P1-T1**
- Objective: 오디오 title·seriesName·테마명, 홈 오디오/시리즈/AUDIO 일정 제목의 번역과 필드별 fallback을 검증한다. 이미 구현된 시리즈·테마 번역은 회귀를 먼저 확인하며 불필요하게 수정하지 않는다.
- 요구사항: LIST-01, LIST-02, LIST-04, LANG-01/02, TEXT-01/02, COMPAT-01, READ-01, ISOLATE-01
- 시작 조건: 사용자의 구현 착수 요청과 PRD 확인.
- 완료 증거: 아래 TDD 체크박스, focused test 성공, 실제 결과 기록.
- 범위 밖: 메인 목록·추천·랭킹 변경.
- Consumes: 기존 원문·저장된 번역·LangContext.lang.code.
- Produces: 기존 응답 계약을 유지한 요청 locale별 표시 문자열.
**확인/수정 후보 파일**
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/port/out/CreatorChannelAudioQueryPort.kt
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt
**테스트 파일**
- src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt
- src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt
- src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioEndToEndTest.kt
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesEndToEndTest.kt
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt
- [x] **RED:** 위 테스트에서 대상 경로의 언어 선택·원문 fallback·기존 값 불변을 검증하는 가장 작은 실패 사례를 작성한다.
- [x] **RED 확인:** 아래 명령으로 미구현 번역 때문에 발생한 제목 assertion 실패를 확인한다. 환경 실패는 RED 증거가 아니다.
- [x] **GREEN:** 기존 계층에서 locale 전달과 번역 조회/매핑만 최소 구현한다.
- [x] **GREEN 확인:** 같은 명령으로 의도한 번역·fallback 성공을 확인한다.
- [x] **REFACTOR:** 이번 변경이 만든 중복만 정리하고 아래 focused test 및 직접 호출자 회귀를 확인한다. 실행 결과를 Task 기록에 누적한다.
```bash
./gradlew test --tests '*DefaultCreatorChannelAudioQueryRepositoryTest' --tests '*DefaultCreatorChannelHomeQueryRepositoryTest' --tests '*DefaultCreatorChannelSeriesQueryRepositoryTest' --tests '*CreatorChannelAudioEndToEndTest' --tests '*CreatorChannelSeriesEndToEndTest' --tests '*CreatorChannelHomeEndToEndTest'
```
기대 결과: 종료 코드 0. 번역이 없어서 콘텐츠가 누락되거나 순서·개수·페이지가 달라지는 사례 0건.
**Task 실행 기록:**
- 2026-09-09: 사용자가 현재 브랜치 구현을 지시해 worktree 없이 `test` 브랜치에서 진행했다.
- RED: `./gradlew test --tests '*DefaultCreatorChannelAudioQueryRepositoryTest.shouldFindAudioContentsWithTranslatedTitleFallback' --tests '*DefaultCreatorChannelHomeQueryRepositoryTest.shouldFindHomeAudioContentsWithTranslatedTitleFallback' --tests '*DefaultCreatorChannelHomeQueryRepositoryTest.shouldFindHomeSchedulesAndSeriesWithTranslatedTitleFallback'` 실행. 종료 코드 1, 3개 테스트가 제목 assertion 실패로 실패했다.
- GREEN: 같은 RED 명령 재실행. 종료 코드 0, 오디오 title·홈 오디오 title/seriesName·AUDIO 스케줄 title·홈 시리즈 title의 번역 및 공백 fallback을 확인했다.
- Focused: `./gradlew test --tests '*DefaultCreatorChannelAudioQueryRepositoryTest' --tests '*DefaultCreatorChannelHomeQueryRepositoryTest' --tests '*DefaultCreatorChannelSeriesQueryRepositoryTest' --tests '*CreatorChannelAudioEndToEndTest' --tests '*CreatorChannelSeriesEndToEndTest' --tests '*CreatorChannelHomeEndToEndTest'` 실행. 종료 코드 0.
- 리뷰 보완: 같은 콘텐츠에 en/ja 번역을 함께 저장해 locale별 제목 선택과 ID 순서 불변을 추가 검증했다. 홈 service는 `LangContext.lang.code`가 최신 오디오·오디오 목록·스케줄·시리즈 port 호출에 전달되는지 확인했다.
- 보완 후 Focused 재실행: 위 P1-T1 focused command 실행. 종료 코드 0.
- [x] **Task 1.2: 메인 전체 목록 오디오 번역과 시리즈 회귀 확인**
**Goal P1-T2**
- Objective: 각 기존 type에서 오디오 제목 번역을 확인하고 기존 시리즈 locale 전달·fallback을 보존한다. 기존 요일·정렬·유료/무료 필터와 페이지 결과가 동일해야 한다.
- 요구사항: LIST-03, LANG-01/02, TEXT-01/02, COMPAT-01, READ-01, ISOLATE-01
- 시작 조건: P1-T1 완료.
- 완료 증거: 아래 TDD 체크박스, focused test 성공, 실제 결과 기록.
- 범위 밖: 추천·랭킹 변경.
- Consumes: 기존 원문·저장된 번역·LangContext.lang.code.
- Produces: 기존 응답 계약을 유지한 요청 locale별 표시 문자열.
**확인/수정 후보 파일**
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/port/out/MainContentAllQueryPort.kt
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt
**테스트 파일**
- src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt
- src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt
- [x] **RED:** 위 테스트에서 대상 경로의 언어 선택·원문 fallback·기존 값 불변을 검증하는 가장 작은 실패 사례를 작성한다.
- [x] **RED 확인:** 아래 명령으로 미구현 번역 때문에 발생한 제목 assertion 실패를 확인한다. 환경 실패는 RED 증거가 아니다.
- [x] **GREEN:** 기존 계층에서 locale 전달과 번역 조회/매핑만 최소 구현한다.
- [x] **GREEN 확인:** 같은 명령으로 의도한 번역·fallback 성공을 확인한다.
- [x] **REFACTOR:** 이번 변경이 만든 중복만 정리하고 아래 focused test 및 직접 호출자 회귀를 확인한다. 실행 결과를 Task 기록에 누적한다.
```bash
./gradlew test --tests '*DefaultMainContentAllQueryRepositoryTest' --tests '*MainContentAllQueryServiceTest' --tests '*MainContentAllEndToEndTest'
```
기대 결과: 종료 코드 0. 번역이 없어서 콘텐츠가 누락되거나 순서·개수·페이지가 달라지는 사례 0건.
**Task 실행 기록:**
- RED: `./gradlew test --tests '*MainContentAllQueryServiceTest.shouldQueryAudiosForAudioType' --tests '*DefaultMainContentAllQueryRepositoryTest.shouldFindAudiosWithTranslatedTitleFallback'` 실행. 종료 코드 1, service locale 전달 assertion과 repository 오디오 title assertion이 실패했다.
- GREEN/Focused: `./gradlew test --tests '*DefaultMainContentAllQueryRepositoryTest' --tests '*MainContentAllQueryServiceTest' --tests '*MainContentAllEndToEndTest'` 실행. 종료 코드 0, 오디오 title 번역·공백 fallback과 기존 시리즈 번역 회귀를 확인했다.
- 리뷰 보완: 같은 오디오에 en/ja 번역을 함께 저장해 locale별 title 선택과 ID 순서 불변을 추가 검증했다.
- 보완 후 Focused 재실행: 위 P1-T2 focused command 실행. 종료 코드 0.
#### Phase 1 Gate — P1-GATE
- Objective: Phase 1의 요청 헤더부터 DB 번역 선택·HTTP 응답까지 확인한다.
- 시작 조건: P1-T1, P1-T2 완료.
- 완료 증거: 해당 Task 명령 통과, 아래 수동 확인과 실제 명령·결과·환경 기록.
- 범위 밖: 운영 데이터 변경, Papago 실행, 테스트 삭제·완화.
- [x] 해당 Phase의 모든 focused test 명령을 실행하여 종료 코드 0을 확인한다.
- [ ] test 서버에서 같은 API·query를 유지하고 Accept-Language만 바꿔 GET 요청한다.
- [ ] ko/en/ja 번역·없는 번역·공백 번역·기본 언어 동작을 실제 JSON에서 확인한다.
- [ ] 대상 외 필드, 순서·개수·페이지·인증·성인/차단/구매 정책이 보존되는지 확인한다.
- [ ] 요청별 번역 조회가 항목 수에 비례해 늘지 않고 GET에서 번역 작업/외부 API/쓰기 호출이 없는지 확인한다.
- [ ] 검증 로그는 인증 토큰·개인정보를 제거해 기록한다.
수동 HTTP는 test 서버와 합성 테스트 계정을 사용한다. 인증이 필요한 API는 test 서버용 토큰을 사용하고 문서에 값을 기록하지 않는다.
test 서버 배포·DB 준비가 안 되면 수동 Gate를 완료 처리하지 않고 원인과 재개 조건을 남긴다.
**Phase 1 Gate 실행 기록:**
- 2026-09-09: P1-T1 focused command 재실행. 종료 코드 0.
- 2026-09-09: P1-T2 focused command 실행. 종료 코드 0.
- 2026-09-09: `./gradlew ktlintCheck`는 신규 테스트 호출 줄바꿈과 import 정렬 지적 후 수정해 재실행했다. 최종 종료 코드 0.
- 2026-09-09: `./gradlew assemble` 실행. 종료 코드 0.
- 2026-09-09: Oracle 리뷰에서 자동 검증 공백을 지적받아 다중 locale 선택, 홈 locale 전달, 공백 fallback 보강 테스트를 추가했다. 보강 focused test 종료 코드 0.
- 2026-09-09: 보강 후 P1-T1 focused command, P1-T2 focused command, `./gradlew ktlintCheck`, `./gradlew assemble`을 다시 실행했다. 각 종료 코드 0.
- 로컬 수동 테스트가 불가능하다는 사용자 확인에 따라 HTTP Gate를 test 서버로 이관했다. 재개 조건: test 서버 배포와 합성 인증 수단 준비 후 동일 query에서 `Accept-Language`만 바꿔 LIST-01~04 JSON을 확인한다.
### Phase 2: 추천·더보기·랭킹
시작 조건: P1-GATE 통과. 결과: LIST-05~07의 번역 표시와 요청 간 격리.
- [x] **Task 2.1: 추천·더보기의 모든 오디오 제목 번역**
**Goal P2-T1**
- Objective: 일반 카드와 별도 SQL을 쓰는 mostCommentedAudios를 모두 검증한다. 더보기 두 type과 FIRST_AUDIO_CONTENT 공유 호출자를 확인한다. 추천 스냅샷·백그라운드 작업에 요청 범위 의존을 추가하지 않으며, 공유 홈 추천 호출의 기존 응답을 회귀 검증한다.
- 요구사항: LIST-05, LIST-07, LANG-01/02, TEXT-01/02, COMPAT-01, READ-01, ISOLATE-01
- 시작 조건: P1-GATE 완료.
- 완료 증거: 아래 TDD 체크박스, focused test 성공, 실제 결과 기록.
- 범위 밖: 배너·댓글·닉네임 및 다른 API의 신규 번역 기능.
- Consumes: 기존 원문·저장된 번역·LangContext.lang.code.
- Produces: 기존 응답 계약을 유지한 요청 locale별 표시 문자열.
**확인/수정 후보 파일**
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt
- src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacade.kt
- src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt
- src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt
- src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt
**테스트 파일**
- src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt
- src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacadeTest.kt
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt
- [x] **RED:** 위 테스트에서 대상 경로의 언어 선택·원문 fallback·기존 값 불변을 검증하는 가장 작은 실패 사례를 작성한다.
- [x] **RED 확인:** 아래 명령으로 미구현 번역 때문에 발생한 제목 assertion 실패를 확인한다. 환경 실패는 RED 증거가 아니다.
- [x] **GREEN:** 기존 계층에서 locale 전달과 번역 조회/매핑만 최소 구현한다.
- [x] **GREEN 확인:** 같은 명령으로 의도한 번역·fallback 성공을 확인한다.
- [x] **REFACTOR:** 이번 변경이 만든 중복만 정리하고 아래 focused test 및 직접 호출자 회귀를 확인한다. 실행 결과를 Task 기록에 누적한다.
```bash
./gradlew test --tests '*DefaultAudioRecommendationQueryRepositoryTest' --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*ContentOverviewFacadeTest' --tests '*AudioRecommendationEndToEndTest' --tests '*ContentOverviewEndToEndTest' --tests '*HomeRecommendationFacadeTest'
```
기대 결과: 종료 코드 0. 번역이 없어서 콘텐츠가 누락되거나 순서·개수·페이지가 달라지는 사례 0건.
**Task 실행 기록:**
- 2026-09-09: RED는 이전 구현 담당자가 아래 focused command로 포착했다. 원시 Gradle 콘솔 로그는 현재 세션에 보관되어 있지 않아 출력은 재구성하지 않는다. LIST-05 전체 카드와 LIST-07 NEW_AND_HOT_AUDIO/FIRST_AUDIO_CONTENT가 요청 locale 번역 대신 원문 제목을 반환한 assertion 실패였다.
- 2026-09-09: GREEN 구현 후 저장소에서 locale별 배치 조회와 nonblank title fallback을 적용했다. Oracle 보완으로 같은 fixture의 en→ja→en MockMvc 격리와 빈 문자열·공백·누락·다른 locale fallback, most-commented의 ID 순서·댓글·작성자 프로필 필드 보존을 추가 검증했다.
- 2026-09-09: Focused: 위 command 실행, `BUILD SUCCESSFUL in 1m 43s`. production 코드는 Oracle 보완에서 변경하지 않았고, `pointAudios`의 기존 개수·인덱스 검증을 누적 fixture와 `rand()` 정렬에 독립적인 fixture ID·가격 조건으로 교체했다.
- 수동 HTTP는 로컬 실행이 불가능하다는 사용자 확인에 따라 test 서버 P2-GATE에서 대기한다.
- [x] **Task 2.2: 랭킹 응답 제목을 요청 언어로 표시**
**Goal P2-T2**
- Objective: 같은 랭킹 스냅샷의 contentId로 요청 locale 번역을 일괄 조회하여 표시 제목에만 반영한다. 번역 없음·공백이면 기존 snapshot.title을 사용한다. 집계 재실행 없이 번역 반영과 rank·rankChange·점수 불변을 검증한다.
- 요구사항: LIST-06, RANK-01, LANG-01/02, TEXT-01/02, COMPAT-01, READ-01, ISOLATE-01
- 시작 조건: P2-T1 완료.
- 완료 증거: 아래 TDD 체크박스, focused test 성공, 실제 결과 기록.
- 범위 밖: 집계·점수·스냅샷 저장 형식 변경.
- Consumes: 기존 원문·저장된 번역·LangContext.lang.code.
- Produces: 기존 응답 계약을 유지한 요청 locale별 표시 문자열.
**확인/수정 후보 파일**
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotPort.kt
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt
**테스트 파일**
- src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt
- src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt
- [x] **RED:** 위 테스트에서 대상 경로의 언어 선택·원문 fallback·기존 값 불변을 검증하는 가장 작은 실패 사례를 작성한다.
- [x] **RED 확인:** 아래 명령으로 미구현 번역 때문에 발생한 제목 assertion 실패를 확인한다. 환경 실패는 RED 증거가 아니다.
- [x] **GREEN:** 기존 계층에서 locale 전달과 번역 조회/매핑만 최소 구현한다.
- [x] **GREEN 확인:** 같은 명령으로 의도한 번역·fallback 성공을 확인한다.
- [x] **REFACTOR:** 이번 변경이 만든 중복만 정리하고 아래 focused test 및 직접 호출자 회귀를 확인한다. 실행 결과를 Task 기록에 누적한다.
```bash
./gradlew test --tests '*AudioRankingQueryServiceTest' --tests '*DefaultAudioRankingSnapshotPersistenceAdapterTest' --tests '*AudioRankingControllerTest'
```
기대 결과: 종료 코드 0. 번역이 없어서 콘텐츠가 누락되거나 순서·개수·페이지가 달라지는 사례 0건.
**Task 실행 기록:**
- 2026-09-09: RED: `./gradlew test --tests '*AudioRankingQueryServiceTest' --tests '*DefaultAudioRankingSnapshotPersistenceAdapterTest' --tests '*AudioRankingControllerTest'` 종료 코드 1. `AudioRankingQueryServiceTest.shouldUseRequestedLocaleTitlesWithoutChangingRankingValues`의 `expected: <[en-title-2, en-title-1]> but was: <[audio-2, audio-1]>` 및 `AudioRankingControllerTest.shouldIsolateRequestedLocaleTitlesWithoutChangingRankingResponse`의 `JSON path "$.data.items[0].title" expected:<en-title-2> but was:<audio-2>`를 확인했다. `DefaultAudioRankingSnapshotPersistenceAdapterTest.shouldTranslateSnapshotTitlesInBatchesWithSnapshotFallback`도 snapshot title 반환 assertion으로 실패했다.
- 2026-09-09: GREEN: `LangContext.lang.code`을 조회 port에만 전달하고, 최신·이전 각 스냅샷 결과 집합에서 `findByContentIdInAndLocale` 한 번으로 유효한 title만 적용했다. 빈 문자열·공백·누락·다른 locale 전용 번역은 각 snapshot.title로 fallback하며, replace/집계/refresh 경로는 변경하지 않았다.
- 2026-09-09: Focused: 위 명령 종료 코드 0, `BUILD SUCCESSFUL in 40s`. query service의 en→ja→en locale 전달과 title 외 `showRankChange`·type·순서·rank·rankChange·isNew·닉네임·cover URL 불변, 실제 adapter의 최신·이전 조회 배치 8회 statement, controller의 en→ja→en JSON을 검증했다.
- 2026-09-09: 직접 호출자 회귀: `./gradlew test --tests '*AudioRankingSnapshotRefreshServiceTest'` 종료 코드 0, `BUILD SUCCESSFUL in 6s`.
- 수동 HTTP는 test 서버 배포 후 P2-GATE에서 수행한다.
- 2026-09-09: P2-T2 spec-review 보강: `./gradlew test --tests '*DefaultAudioRankingSnapshotPersistenceAdapterTest'` 종료 코드 0, `BUILD SUCCESSFUL in 34s`. `rendered_payload`를 SQL NULL로 저장한 실제 translation row는 converter의 빈 payload로 읽혀 snapshot.title으로 fallback했다. `{ "title": null }` JSON은 non-null `ContentTranslationPayload.title` 계약 밖이라 생성하지 않았다.
- 2026-09-09: 빈 latest 조회 후 Hibernate `prepareStatementCount=1`, 이어진 빈 previous 조회 후 누적 `=2`를 확인했다. 각 snapshot native SELECT만 발생했고 ContentTranslation SELECT는 0회다. non-empty latest·previous en→ja→en 여섯 조회는 snapshot+batch translation 각 1회씩 총 12 statements였으며, title만 빈 문자열로 정규화한 전체 `AudioRankingSnapshotRecord`가 locale 간 동일했다. latest score base 10~14와 previous 20~24의 final/normalized/raw·count·growth·boost 필드를 포함했고, 재조회한 저장 snapshot title과 모든 score 값도 변경되지 않았다.
#### Phase 2 Gate — P2-GATE
- Objective: Phase 2의 요청 헤더부터 DB 번역 선택·HTTP 응답까지 확인한다.
- 시작 조건: P2-T1, P2-T2 완료.
- 완료 증거: 해당 Task 명령 통과, 아래 수동 확인과 실제 명령·결과·환경 기록.
- 범위 밖: 운영 데이터 변경, Papago 실행, 테스트 삭제·완화.
- [x] 해당 Phase의 모든 focused test 명령을 실행하여 종료 코드 0을 확인한다.
- [ ] test 서버에서 같은 API·query를 유지하고 Accept-Language만 바꿔 GET 요청한다.
- [ ] ko/en/ja 번역·없는 번역·공백 번역·기본 언어 동작을 실제 JSON에서 확인한다.
- [ ] 대상 외 필드, 순서·개수·페이지·인증·성인/차단/구매 정책이 보존되는지 확인한다.
- [ ] 요청별 번역 조회가 항목 수에 비례해 늘지 않고 GET에서 번역 작업/외부 API/쓰기 호출이 없는지 확인한다.
- [ ] 검증 로그는 인증 토큰·개인정보를 제거해 기록한다.
수동 HTTP는 test 서버와 합성 테스트 계정을 사용한다. 인증이 필요한 API는 test 서버용 토큰을 사용하고 문서에 값을 기록하지 않는다.
test 서버 배포·DB 준비가 안 되면 수동 Gate를 완료 처리하지 않고 원인과 재개 조건을 남긴다.
**Phase 2 Gate 실행 기록:**
- 2026-09-09: P2-T1 focused command를 `--rerun-tasks`로 강제 재실행했다. 종료 코드 0, `BUILD SUCCESSFUL in 5m 12s`, 10 tasks executed.
- 2026-09-09: P2-T2 focused command를 `--rerun-tasks`로 강제 재실행했다. 종료 코드 0, `BUILD SUCCESSFUL in 5m 14s`, 10 tasks executed.
- 2026-09-09: `AudioRankingSnapshotRefreshServiceTest` 직접 호출자 회귀를 실행했다. 종료 코드 0, `BUILD SUCCESSFUL in 6s`.
- 로컬 수동 테스트는 불가능하다는 사용자 확인에 따라 LIST-01~07 HTTP 검증을 test 서버 배포 후 수행한다.
## 실행 순서와 완료 기준
P1-T1 → P1-T2 → P1-GATE → P2-T1 → P2-T2 → P2-GATE. 2026-09-09 사용자 지시로 로컬 P1-GATE를 test 서버로 이관하고 P2 자동 구현·검증을 먼저 완료했다.
한 번에 하나의 Task 또는 Gate만 활성화하며, 완료된 기록은 되돌리거나 삭제하지 않는다.
최종 품질 명령:
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.*' --tests 'kr.co.vividnext.sodalive.v2.content.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.*' --tests '*HomeRecommendation*'
./gradlew ktlintCheck
./gradlew assemble
```
기대 결과: 각 자동 검증 명령 종료 코드 0. 대상 7개 API의 실제 HTTP 확인은 test 서버 Gate에서 별도 완료한다.
전체 test는 공통 인증·언어 파서·serialization 변경 또는 targeted 결과로 영향 판단이 불가능할 때 추가한다.
생략 시 국소적인 표시값 변경이라는 근거와 대신 실행한 위 영향 범위 회귀를 기록한다.
초기 문서 작성 단계에서는 위 구현 검증 명령을 실행하지 않았으며, 구현 후 결과는 각 Task/Gate 기록에 누적한다.
## 결정 기록
PRD의 DEC-LIST-01~07을 따른다.
기존 파싱·API 계약·원문 fallback을 유지하며, 요청 외 영역으로 확대해야 할 경우 먼저 문서와 사용자 결정을 갱신한다.
## 문서 검증 기록
- 2026-09-09: 사용자 인터뷰의 원문 fallback과 댓글·닉네임·배너 유지 결정을 PRD와 Task에 반영.
- 초기 문서 작성 당시 코드·테스트·운영 데이터 변경 없이 구현 Task와 Gate는 전부 미착수였다.
- 경로 존재 확인 및 필수 Gradle 명령 확인 결과는 아래에 누적한다.
- 2026-09-09: Task의 기존 소스·테스트 경로 존재를 확인했다. 채널 홈 EndToEndTest는 현재 없으므로 신규 작성 예정으로 구분했다. 구현 완료 체크박스와 템플릿 placeholder가 남아 있지 않음을 확인했다.
- 2026-09-09: 문서 유지보수 규칙에 따라 `./gradlew tasks --all` 실행. 종료 코드 0, `BUILD SUCCESSFUL in 26s`; test·ktlintCheck·assemble 태스크를 확인했다. 기능 테스트·빌드·HTTP 검증은 구현하지 않은 문서 작업이므로 실행하지 않았다.
- 2026-09-09: Phase 1·2 전체 영향 범위 test 명령 실행. 종료 코드 0, `BUILD SUCCESSFUL in 3m 23s`.
- 2026-09-09: 최초 `./gradlew ktlintCheck`에서 신규 랭킹·추천 테스트의 들여쓰기와 긴 줄 위반을 확인해 포맷만 수정했다. 관련 두 repository test 종료 코드 0, 최종 `./gradlew ktlintCheck` 종료 코드 0.
- 2026-09-09: `./gradlew assemble` 실행. 종료 코드 0, `BUILD SUCCESSFUL in 6s`.
- 2026-09-09: 전체 `./gradlew test` 실행. 종료 코드 0, `BUILD SUCCESSFUL in 8m 19s`.
- 2026-09-09: 문서 상태를 Phase 1·2 자동 검증 완료와 test 서버 HTTP Gate 대기로 갱신하고 `./gradlew tasks --all`로 명령 유효성을 재확인했다. 종료 코드 0.
- 2026-09-09: 전체 영향 범위 회귀 명령을 실행했다. 종료 코드 0, `BUILD SUCCESSFUL in 3m 23s`.
- 2026-09-09: `./gradlew ktlintCheck` 최초 실행에서 신규 테스트 두 파일의 들여쓰기·긴 줄 위반을 확인해 포맷만 수정했다. 관련 ranking adapter·recommendation repository 테스트 재실행 종료 코드 0, 최종 `ktlintCheck` 종료 코드 0.
- 2026-09-09: `./gradlew assemble` 실행. 종료 코드 0, `BUILD SUCCESSFUL in 6s`.
- 2026-09-09: 문서 상태와 test 서버 Gate 갱신 후 `./gradlew tasks --all` 재실행. 종료 코드 0, `BUILD SUCCESSFUL in 928ms`.
- 로컬 수동 테스트는 불가능하므로 자동 검증 완료 상태로 test 서버 배포를 준비한다. 배포 후 P1-GATE부터 LIST-01~07의 실제 HTTP 응답을 순서대로 확인한다.
@@ -0,0 +1,99 @@
# v2 콘텐츠 목록 요청 언어별 번역 PRD
## 문서 정보
- 작성일: 2026-09-09
- 상태: Phase 1·2 구현 및 자동 검증 완료, test 서버 HTTP Gate 대기
- 결정권자: 사용자
- 관련 계획: [plan-task.md](plan-task.md)
- 공개 API 경로·요청·응답 스키마는 유지하며 별도 API 계약 문서는 만들지 않는다.
- 사용자의 2026-09-09 구현 지시 이후 Phase 1·2 코드·테스트 구현을 진행했다. 운영 데이터 변경과 번역 실행은 수행하지 않는다.
## 1. 문제와 목표
동일한 콘텐츠라도 v2 조회 위치에 따라 번역 제목과 원문이 혼재한다.
요청의 Accept-Language로 결정된 언어에 맞춰 이미 저장된 번역을 표시하고,
번역이 없거나 공백이면 해당 필드의 기존 원문을 표시한다.
서버의 언어 결정은 기존 LangInterceptor → 요청 범위 LangContext를 재사용한다.
앱의 설정 언어가 실제 헤더로 전송되는지는 이 서버 작업에서 보장하지 않는다.
## 2. 대상 API와 필드
아래 경로는 모두 GET이다. 기존 응답에 존재하는 필드만 변경하며 설명·태그 같은 새 필드를 추가하지 않는다.
| ID | API | 대상 |
|---|---|---|
| LIST-01 | /api/v2/creator-channels/{creatorId}/audio | 오디오 title, 연결된 seriesName, 테마명 |
| LIST-02 | /api/v2/creator-channels/{creatorId}/series | 시리즈 title. 기존 연재 요일 언어 처리 유지 |
| LIST-03 | /api/v2/audio/contents | 오디오 title, 시리즈 title. 기존 type·정렬·요일 필터 유지 |
| LIST-04 | /api/v2/creator-channels/{creatorId}/home | 오디오 카드 title·seriesName, 시리즈 title, 일정 중 AUDIO 유형의 title |
| LIST-05 | /api/v2/audio/recommendations | latestAudios, newAndHotAudios, freeAudios, pointAudios, mostCommentedAudios, recommendedAudios의 title |
| LIST-06 | /api/v2/audio/rankings | 각 랭킹 항목 title |
| LIST-07 | /api/v2/contents | NEW_AND_HOT_AUDIO, FIRST_AUDIO_CONTENT의 title |
- LIST-05의 originalSeries는 현재 seriesId·coverImageUrl만 반환한다. 번역용 title 필드를 추가하지 않는다.
- LIST-04의 라이브 제목, 후원·팬톡 문구 등 오디오·시리즈·테마 외 필드는 대상이 아니다.
- /api/v2/home/recommendations 등 다른 경로는 신규 기능 범위가 아니다. 공유 조회 코드 변경 시 기존 동작 회귀를 검증한다.
- 관리자 목록, 상세·검색 API, 음성 파일 번역, 설명·태그 응답 확장은 제외한다.
## 3. 기능 요구사항과 수용 기준
| ID | 상태/근거 | 요구사항 | 수용 기준 | 연결 |
|---|---|---|---|---|
| LANG-01 | 사용자 확정 | 대상 API 전체에서 Accept-Language 기준 번역 선택 | 동일 데이터에 ko/en/ja 요청 시 해당 locale 번역 표시 | 모든 Task |
| LANG-02 | 기존 동작 유지 | Lang.fromAcceptLanguage 파싱 재사용 | 헤더 누락·빈 값·미지원 값은 ko, en-US는 en, ja-JP는 ja. q 가중치 파싱 확장 없음 | P1-GATE |
| TEXT-01 | 사용자 확정 | 번역 없음·빈 문자열·공백 문자열은 원문 표시 | null/빈 문자열/공백별 원문 일치, 다른 언어 번역으로 대체하지 않음 | 모든 Task |
| TEXT-02 | 사용자 확정 | 오디오 제목·시리즈 제목/시리즈명·테마명만 적용 | 댓글·닉네임·배너는 기존 값·선택 정책 유지 | 모든 Task |
| COMPAT-01 | 기존 기능 유지 | 언어는 표시 문자열에만 영향 | ID·개수·순서·hasNext·권한·성인/차단/구매 필터·가격·이미지 불변 | 각 Gate |
| RANK-01 | 구현 제약 | 랭킹 집계와 번역 표시를 분리 | 같은 스냅샷으로 언어만 바꿔 제목 변경, 순위·점수·rankChange 불변 | P2-T2 |
| READ-01 | 범위 제약 | 저장된 번역만 조회 | GET에서 Papago 호출·번역 작업 생성·DB 쓰기 없음 | 각 Gate |
| ISOLATE-01 | 기존 요청 범위 유지 | 언어가 다른 요청끼리 값 공유 금지 | en→ja→en 요청에서도 언어 혼입 없음 | 각 Gate |
원문이란 번역 적용 전 해당 경로에서 반환하던 문자열이다.
랭킹은 기존 스냅샷 title을 fallback으로 유지하며 최신 콘텐츠 제목으로 갱신하는 별도 기능을 추가하지 않는다.
번역 완료 전 원문이 보이는 것은 정상이다. 원문과 요청 언어가 같아도 별도 추정 없이 같은 locale 번역 조회/fallback 규칙을 적용한다.
## 4. 기술 근거와 최소 변경 방향
모든 경로는 src/main/kotlin/kr/co/vividnext/sodalive/ 아래를 기준으로 한다.
| 근거 파일 | 현재 확인한 동작 |
|---|---|
| i18n/LangInterceptor.kt, i18n/Lang.kt, configs/WebConfig.kt | 전체 경로에서 헤더를 읽고 ko/en/ja로 결정 |
| content/translation/ContentTranslationRepository.kt | findByContentIdInAndLocale 일괄 조회가 이미 존재 |
| i18n/translation/TranslationReadModelMaterializer.kt | content·series 번역을 renderedPayload에 저장 |
| v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt | 오디오 title·seriesName·테마는 요청 locale 번역 우선 |
| v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt | 시리즈 번역 우선과 원문 fallback 구현됨 |
| v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt | 오디오·시리즈 제목 및 AUDIO 일정 제목은 요청 locale 번역 우선 |
| v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt | 오디오·시리즈 제목은 요청 locale 번역 우선 |
| v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt | 일반 카드 및 mostCommentedAudios 제목은 요청 locale 번역 우선 |
| v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt | FIRST_AUDIO_CONTENT 제목은 요청 locale 번역 우선 |
| v2/content/ranking/application/AudioRankingQueryService.kt | 요청 locale을 조회 port에 전달하고 snapshot 표시 제목만 번역 |
기존 QueryService → Port → Persistence 경계를 유지하며 필요한 locale을 전달한다.
기존 번역 조인 또는 ID 일괄 조회를 재사용한다. 항목별 단건 조회로 N+1을 만들지 않는다.
페이지·정렬·필터 결과를 유지한 채 제목을 매핑한다. 별도 범용 번역 프레임워크·캐시·의존성·DDL은 추가하지 않는다.
랭킹/추천 스냅샷 생성 작업에 요청 범위 LangContext를 주입하지 않는다.
## 5. 검증 시나리오
1. 같은 원문에 ko/en/ja별 서로 다른 번역을 준비하고 7개 API의 대상 필드가 헤더에 맞는지 확인한다.
2. 번역 행 없음, 빈 제목, 공백 제목, 다른 locale 번역만 존재하는 경우 필드별 원문 fallback을 확인한다.
3. 오디오 번역만 있고 시리즈 번역이 없는 혼합 사례에서 각 필드가 독립적으로 선택되는지 확인한다.
4. 언어를 바꿔도 콘텐츠 ID·순서·페이지·필터·랭킹과 제외 필드가 동일한지 확인한다.
5. 실제 HTTP 요청과 DB 조회 테스트로 연결을 검증한다. Mock 응답의 문자열 확인만으로 완료 처리하지 않는다.
## 6. 결정 기록
| 날짜 | ID | 구분 | 내용 |
|---|---|---|---|
| 2026-09-09 | DEC-LIST-01 | 사용자 확정 | 앞서 조사한 조회 위치 모두 Accept-Language에 따른 번역 표시 |
| 2026-09-09 | DEC-LIST-02 | 사용자 확정 | 번역 없음·공백이면 원문 표시 |
| 2026-09-09 | DEC-LIST-03 | 사용자 확정 | 오디오 제목·시리즈 제목/시리즈명·테마명 대상, 댓글·닉네임·배너 유지 |
| 2026-09-09 | DEC-LIST-04 | 기존 동작 유지 | 언어 파싱·기본 언어·인증·조회 조건·응답 스키마 유지 |
| 2026-09-09 | DEC-LIST-05 | 사용자 제한 | 문서만 작성, 구현하지 않음 |
| 2026-09-09 | DEC-LIST-06 | 사용자 지시 | 현재 브랜치에서 Phase 1 구현 진행. PRD에도 구현 상태를 최종 갱신 |
| 2026-09-09 | DEC-LIST-07 | 사용자 지시 | 로컬 수동 테스트가 불가능하므로 Phase 2까지 자동 검증을 완료하고 test 서버에서 HTTP 검증 |
제품 결정이 필요한 미결 항목은 없다. test 서버 배포 후 대상 7개 API의 수동 HTTP 검증이 남아 있다.
@@ -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.lowercase(),
detectedLanguage = detectedLanguage,
provider = provider,
confidence = null,
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
)
return requireNotNull(
languageDetectionResultRepository.findDetectedLanguageForUpdate(
sourceHash = sourceHash,
provider = provider,
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?
}
@@ -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)
@@ -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 {
@@ -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)
return postList
.filter {
val visiblePostList = postList.filter {
!isBlockedBetweenMembers(memberId = memberId, creatorId = it.creatorId)
}
val displayContents = creatorCommunityTranslationService
.findDisplayContents(visiblePostList.map { it.id }, langContext.lang.code)
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(),
@@ -18,7 +18,8 @@ enum class LanguageTranslationTargetType {
ORIGINAL_WORK,
CREATOR_CONTENT_CATEGORY
CREATOR_CONTENT_CATEGORY,
CREATOR_COMMUNITY
}
class LanguageTranslationEvent(
@@ -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"
}
@@ -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
)
}
}
@@ -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,
@@ -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
@@ -23,6 +23,7 @@ interface MainContentAllQueryPort {
sort: ContentSort,
offset: Long,
limit: Int,
locale: String,
onlyPaid: Boolean = false,
onlyFree: Boolean = false,
onlyPointAvailable: Boolean = false
@@ -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,
@@ -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 {
@@ -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(
@@ -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,
@@ -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
)
}
@@ -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,
@@ -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,
@@ -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)
)
}
@@ -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()
@@ -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>
}
@@ -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
)
@@ -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),
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)!!
)
}
@@ -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,
@@ -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>
@@ -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
}
)
}
@@ -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(
@@ -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> {
@@ -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(
@@ -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)
}
}
@@ -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
}
}
}
}
@@ -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)
}
}
@@ -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
)
}
}
@@ -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
)
}
}
@@ -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),
@@ -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
)
}
@@ -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() {
@@ -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)
}
@@ -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)
}
@@ -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
@@ -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)
@@ -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,
@@ -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)
@@ -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
@@ -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,
@@ -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(
@@ -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)
@@ -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,
@@ -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
@@ -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
)
}
}
@@ -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
}
@@ -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(
@@ -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)
@@ -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)
@@ -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,
@@ -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"
)
}
@@ -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
)
}
}
@@ -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,
@@ -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,8 +694,11 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
now: LocalDateTime,
canViewAdultContent: Boolean,
contentType: ContentType,
locale: String,
limit: Int
): List<CreatorChannelSeriesRecord> = listOf(
): List<CreatorChannelSeriesRecord> {
seriesLocale = locale
return listOf(
CreatorChannelSeriesRecord(
seriesId = 501L,
title = "series",
@@ -671,6 +708,7 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
isOriginal = false
)
)
}
override fun findFanTalkSummary(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkSummaryRecord {
return CreatorChannelFanTalkSummaryRecord(
@@ -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
}
}
}
@@ -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,
@@ -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>()