From feb987dac933e625ecb088e708acc6e309e24f96 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 9 Sep 2026 23:16:15 +0900 Subject: [PATCH 01/29] =?UTF-8?q?docs(content-i18n):=20=EC=BD=98=ED=85=90?= =?UTF-8?q?=EC=B8=A0=20=EB=AA=A9=EB=A1=9D=20=EB=B2=88=EC=97=AD=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=EC=82=AC=ED=95=AD=EA=B3=BC=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=EC=9D=84=20=EA=B8=B0=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 332 ++++++++++++++++++ .../prd.md | 99 ++++++ 2 files changed, 431 insertions(+) create mode 100644 docs/20260909_v2_콘텐츠목록_요청언어별번역/plan-task.md create mode 100644 docs/20260909_v2_콘텐츠목록_요청언어별번역/prd.md diff --git a/docs/20260909_v2_콘텐츠목록_요청언어별번역/plan-task.md b/docs/20260909_v2_콘텐츠목록_요청언어별번역/plan-task.md new file mode 100644 index 00000000..1de90d46 --- /dev/null +++ b/docs/20260909_v2_콘텐츠목록_요청언어별번역/plan-task.md @@ -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: but was:`를 확인했다. `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 응답을 순서대로 확인한다. diff --git a/docs/20260909_v2_콘텐츠목록_요청언어별번역/prd.md b/docs/20260909_v2_콘텐츠목록_요청언어별번역/prd.md new file mode 100644 index 00000000..f8756e62 --- /dev/null +++ b/docs/20260909_v2_콘텐츠목록_요청언어별번역/prd.md @@ -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 검증이 남아 있다. From 6688bd71174b9957a1baf2d0df0c714c67288c3d Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 9 Sep 2026 23:16:33 +0900 Subject: [PATCH 02/29] =?UTF-8?q?feat(creator-channel):=20=EC=98=A4?= =?UTF-8?q?=EB=94=94=EC=98=A4=20=EB=AA=A9=EB=A1=9D=20=EC=A0=9C=EB=AA=A9=20?= =?UTF-8?q?=EB=B2=88=EC=97=AD=EC=9D=84=20=EC=A0=81=EC=9A=A9=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...faultCreatorChannelAudioQueryRepository.kt | 25 +++++++- ...tCreatorChannelAudioQueryRepositoryTest.kt | 64 +++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt index 9e96259e..19ec10f8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt @@ -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, seriesByContentId: Map, orderStatesByContentId: Map ): 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, locale: String): Map { + 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, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt index 7cb2ba6e..13753396 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt @@ -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, From 950ff98e18eb8be8612dbb0bd2e700b57fde6fa5 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 9 Sep 2026 23:16:45 +0900 Subject: [PATCH 03/29] =?UTF-8?q?feat(creator-channel):=20=ED=99=88=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=96=B8=EC=96=B4=EB=A5=BC=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorChannelHomeQueryService.kt | 12 ++-- .../port/out/CreatorChannelHomeQueryPort.kt | 6 +- .../CreatorChannelHomeQueryServiceTest.kt | 62 +++++++++++++++---- 3 files changed, 62 insertions(+), 18 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt index c9ceeb16..aacc99e8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt @@ -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, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt index b8a3e607..623f3a60 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt @@ -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 @@ -50,6 +52,7 @@ interface CreatorChannelHomeQueryPort { latestAudioContentId: Long?, canViewAdultContent: Boolean, viewerId: Long? = null, + locale: String = "ko", limit: Int = 9 ): List @@ -59,6 +62,7 @@ interface CreatorChannelHomeQueryPort { now: LocalDateTime, canViewAdultContent: Boolean, contentType: ContentType, + locale: String = "ko", limit: Int = 8 ): List diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt index 25f783a0..362545b3 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt @@ -101,6 +101,25 @@ class CreatorChannelHomeQueryServiceTest { assertEquals(false, port.currentLiveCanViewAdultContent) assertEquals(false, port.schedulesCanViewAdultContent) assertEquals(201L, port.audioContentsLatestAudioContentId) + assertEquals("ko", port.latestAudioLocale) + assertEquals("ko", port.audioContentsLocale) + assertEquals("ko", port.schedulesLocale) + assertEquals("ko", port.seriesLocale) + } + + @Test + @DisplayName("홈 서비스는 요청 locale을 오디오, 스케줄, 시리즈 조회에 전달한다") + fun shouldPassLocaleToHomeQueries() { + val port = FakeCreatorChannelHomeQueryPort() + val service = createService(port, lang = Lang.JA) + val viewer = createMember(id = 10L) + + service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0)) + + assertEquals("ja", port.latestAudioLocale) + assertEquals("ja", port.audioContentsLocale) + assertEquals("ja", port.schedulesLocale) + assertEquals("ja", port.seriesLocale) } @Test @@ -468,11 +487,12 @@ class CreatorChannelHomeQueryServiceTest { port: FakeCreatorChannelHomeQueryPort, communityPort: FakeCreatorChannelCommunityQueryPort = FakeCreatorChannelCommunityQueryPort(), canViewAdultContent: Boolean = true, - preferenceService: MemberContentPreferenceService = createPreferenceService(canViewAdultContent) + preferenceService: MemberContentPreferenceService = createPreferenceService(canViewAdultContent), + lang: Lang = Lang.KO ): CreatorChannelHomeQueryService { val messageSource = SodaMessageSource() val langContext = LangContext() - langContext.setLang(Lang.KO) + langContext.setLang(lang) val audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java) Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) .thenAnswer { invocation -> "https://signed.test/${invocation.getArgument(0)}" } @@ -565,6 +585,10 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort { var schedulesEffectiveViewerGender: Gender? = null var schedulesCanViewAdultContent: Boolean? = null var audioContentsLatestAudioContentId: Long? = null + var latestAudioLocale: String? = null + var schedulesLocale: String? = null + var audioContentsLocale: String? = null + var seriesLocale: String? = null var channelDonationNickname: String = "fan" override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? = creator @@ -597,8 +621,12 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort { creatorId: Long, now: LocalDateTime, canViewAdultContent: Boolean, - viewerId: Long? - ): CreatorChannelAudioContentRecord? = audioContentRecord(201L, "audio/latest.png") + viewerId: Long?, + locale: String + ): CreatorChannelAudioContentRecord? { + latestAudioLocale = locale + return audioContentRecord(201L, "audio/latest.png") + } override fun findChannelDonations( creatorId: Long, @@ -622,12 +650,14 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort { viewerId: Long?, isViewerCreator: Boolean, effectiveViewerGender: Gender?, + locale: String, limit: Int ): List { schedulesViewerId = viewerId schedulesIsViewerCreator = isViewerCreator schedulesEffectiveViewerGender = effectiveViewerGender schedulesCanViewAdultContent = canViewAdultContent + schedulesLocale = locale return listOf( scheduleRecord(401L, LocalDateTime.of(2026, 6, 13, 12, 0), CreatorActivityType.AUDIO, false), scheduleRecord(402L, LocalDateTime.of(2026, 6, 13, 12, 0), CreatorActivityType.LIVE, false), @@ -644,9 +674,11 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort { latestAudioContentId: Long?, canViewAdultContent: Boolean, viewerId: Long?, + locale: String, limit: Int ): List { audioContentsLatestAudioContentId = latestAudioContentId + audioContentsLocale = locale return listOf( audioContentRecord(201L, "audio/latest.png"), audioContentRecord(203L, "audio/203.png"), @@ -660,17 +692,21 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort { now: LocalDateTime, canViewAdultContent: Boolean, contentType: ContentType, + locale: String, limit: Int - ): List = listOf( - CreatorChannelSeriesRecord( - seriesId = 501L, - title = "series", - coverImagePath = "series.png", - numberOfContent = 2, - isNew = true, - isOriginal = false + ): List { + seriesLocale = locale + return listOf( + CreatorChannelSeriesRecord( + seriesId = 501L, + title = "series", + coverImagePath = "series.png", + numberOfContent = 2, + isNew = true, + isOriginal = false + ) ) - ) + } override fun findFanTalkSummary(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkSummaryRecord { return CreatorChannelFanTalkSummaryRecord( From 12b9a912c04db2a1e74356ee60de693f7e0df30f Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 9 Sep 2026 23:17:18 +0900 Subject: [PATCH 04/29] =?UTF-8?q?feat(creator-channel):=20=ED=99=88=20?= =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=EC=A0=9C=EB=AA=A9=20=EB=B2=88?= =?UTF-8?q?=EC=97=AD=EC=9D=84=20=EC=A0=81=EC=9A=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...efaultCreatorChannelHomeQueryRepository.kt | 91 +++++++++++--- ...ltCreatorChannelHomeQueryRepositoryTest.kt | 116 ++++++++++++++++++ 2 files changed, 190 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt index 26aaef32..6d465e6f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt @@ -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 { val liveSchedules = queryFactory @@ -235,18 +240,21 @@ class DefaultCreatorChannelHomeQueryRepository( ) .fetch() + val contentTranslation = QContentTranslation("homeScheduleContentTranslation") val audioSchedules = queryFactory .select( - Projections.constructor( - CreatorChannelScheduleRecord::class.java, - audioContent.releaseDate, - audioContent.title, - Expressions.constant(CreatorActivityType.AUDIO), - audioContent.id, - audioContent.isAdult - ) + audioContent.releaseDate, + audioContent.title, + contentTranslation.renderedPayload, + audioContent.id, + audioContent.isAdult ) .from(audioContent) + .leftJoin(contentTranslation) + .on( + contentTranslation.contentId.eq(audioContent.id), + contentTranslation.locale.eq(locale) + ) .where( audioContent.member.id.eq(creatorId), audioContent.member.isActive.isTrue, @@ -256,6 +264,17 @@ class DefaultCreatorChannelHomeQueryRepository( adultAudioCondition(canViewAdultContent) ) .fetch() + .map { + val originalTitle = it.get(audioContent.title)!! + val translatedTitle = it.get(contentTranslation.renderedPayload)?.title + CreatorChannelScheduleRecord( + scheduledAt = it.get(audioContent.releaseDate)!!, + title = translatedTitle.takeUnless(String?::isNullOrBlank) ?: originalTitle, + type = CreatorActivityType.AUDIO, + targetId = it.get(audioContent.id)!!, + isAdult = it.get(audioContent.isAdult)!! + ) + } return (liveSchedules + audioSchedules) .sortedWith(compareBy { it.scheduledAt }.thenBy { it.type.sortOrder }) @@ -268,14 +287,16 @@ class DefaultCreatorChannelHomeQueryRepository( latestAudioContentId: Long?, canViewAdultContent: Boolean, viewerId: Long?, + locale: String, limit: Int ): List { 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 { + 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, seriesByContentId: Map, orderStatesByContentId: Map ): 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, locale: String): Map { + 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, @@ -542,17 +591,25 @@ class DefaultCreatorChannelHomeQueryRepository( } } - private fun audioSeriesByContentIds(contentIds: List): Map { + private fun audioSeriesByContentIds(contentIds: List, locale: String): Map { 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)!! ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt index 65b8dec6..9922dfec 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt @@ -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, From 328c43302035e16f73c8cabc5cf7967885275b1b Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 9 Sep 2026 23:18:05 +0900 Subject: [PATCH 05/29] =?UTF-8?q?feat(content-all):=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=96=B8=EC=96=B4?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=84=EB=8B=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/content/all/application/MainContentAllQueryService.kt | 1 + .../v2/content/all/port/out/MainContentAllQueryPort.kt | 1 + .../content/all/application/MainContentAllQueryServiceTest.kt | 3 +++ 3 files changed, 5 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt index 6f396ddf..e19a53ba 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt @@ -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 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/port/out/MainContentAllQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/port/out/MainContentAllQueryPort.kt index 5c39ee1d..2a986c25 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/port/out/MainContentAllQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/port/out/MainContentAllQueryPort.kt @@ -23,6 +23,7 @@ interface MainContentAllQueryPort { sort: ContentSort, offset: Long, limit: Int, + locale: String, onlyPaid: Boolean = false, onlyFree: Boolean = false, onlyPointAvailable: Boolean = false diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt index e8a737d0..f8691bb0 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt @@ -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 From ee3dfc40d9af8756e3e17ce22e294816a04a5100 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 9 Sep 2026 23:18:44 +0900 Subject: [PATCH 06/29] =?UTF-8?q?feat(content-all):=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EC=A0=9C=EB=AA=A9=20=EB=B2=88=EC=97=AD?= =?UTF-8?q?=EC=9D=84=20=EC=A0=81=EC=9A=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DefaultMainContentAllQueryRepository.kt | 23 +++++- ...efaultMainContentAllQueryRepositoryTest.kt | 77 +++++++++++++++++-- 2 files changed, 91 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt index 484bd4ec..219d58f2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt @@ -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, locale: String): Map { + 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, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt index f6f95220..1a57db2a 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt @@ -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, From 44008c7623622ca16205e309ea2338113cfc88fe Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 9 Sep 2026 23:19:38 +0900 Subject: [PATCH 07/29] =?UTF-8?q?feat(audio-recommendation):=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EC=A1=B0=ED=9A=8C=20=EC=96=B8=EC=96=B4=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AudioRecommendationQueryService.kt | 23 ++++++++----- .../port/out/AudioRecommendationQueryPort.kt | 34 ++++++++++++++++--- .../AudioRecommendationQueryServiceTest.kt | 20 +++++++---- 3 files changed, 58 insertions(+), 19 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt index bac000d5..2cf8a09a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt @@ -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 ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt index 5d181dd0..18ceeda2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt @@ -17,16 +17,40 @@ interface AudioRecommendationQueryPort { lang: Lang = Lang.KO ): List fun findOriginalSeries(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List - fun findLatestAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List - fun findFreeAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List - fun findPointAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List + fun findLatestAudios( + limit: Int, + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + locale: String + ): List + fun findFreeAudios( + limit: Int, + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + locale: String + ): List + fun findPointAudios( + limit: Int, + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + locale: String + ): List fun findAudioCardsByIds( contentIds: List, memberId: Long?, canViewAdultContent: Boolean, - now: LocalDateTime + now: LocalDateTime, + locale: String ): List - fun findCommentedAudiosByIds(contentIds: List, memberId: Long?, canViewAdultContent: Boolean): List + fun findCommentedAudiosByIds( + contentIds: List, + memberId: Long?, + canViewAdultContent: Boolean, + locale: String + ): List fun findNewAndHotSnapshots( windowStart: LocalDateTime, snapshotAt: LocalDateTime, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt index af6a3610..e333b67d 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt @@ -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) From dba70483ad86ae5a31eb819a1c51874de24418a1 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 9 Sep 2026 23:20:21 +0900 Subject: [PATCH 08/29] =?UTF-8?q?feat(audio-recommendation):=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EC=BD=98=ED=85=90=EC=B8=A0=20=EC=A0=9C=EB=AA=A9=20?= =?UTF-8?q?=EB=B2=88=EC=97=AD=EC=9D=84=20=EC=A0=81=EC=9A=A9=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...faultAudioRecommendationQueryRepository.kt | 52 ++++-- .../in/web/ContentOverviewEndToEndTest.kt | 58 +++++++ .../in/web/AudioRecommendationEndToEndTest.kt | 75 ++++++++- ...tAudioRecommendationQueryRepositoryTest.kt | 157 +++++++++++++++++- 4 files changed, 319 insertions(+), 23 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt index 324f5bbf..70923408 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt @@ -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 { 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 { 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 { 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, memberId: Long?, canViewAdultContent: Boolean, - now: LocalDateTime + now: LocalDateTime, + locale: String ): List { 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, memberId: Long?, - canViewAdultContent: Boolean + canViewAdultContent: Boolean, + locale: String ): List { 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.toAudioCards(now: LocalDateTime, canViewAdultContent: Boolean): List { + private fun List.toAudioCards( + now: LocalDateTime, + canViewAdultContent: Boolean, + locale: String + ): List { 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, locale: String): Map { + 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, now: LocalDateTime, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt index 6d7092b2..84c0629d 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt @@ -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 diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt index a2426a31..7154a39f 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt @@ -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, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt index acdf8ea2..09285d47 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt @@ -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) From 14394b82638772a10c0957962fa322a5e439309a Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 9 Sep 2026 23:21:15 +0900 Subject: [PATCH 09/29] =?UTF-8?q?feat(home-recommendation):=20=EC=B2=AB=20?= =?UTF-8?q?=EC=98=A4=EB=94=94=EC=98=A4=20=EC=A1=B0=ED=9A=8C=20=EC=96=B8?= =?UTF-8?q?=EC=96=B4=EB=A5=BC=20=EC=A0=84=EB=8B=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HomeRecommendationQueryService.kt | 6 ++- .../port/out/HomeRecommendationQueryPort.kt | 3 +- .../HomeRecommendationQueryServiceTest.kt | 44 ++++++++++++------- 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt index f225999d..8611c9c8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.recommendation.application import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType @@ -29,7 +30,8 @@ class HomeRecommendationQueryService( private val aiCharacterSnapshotFallbackService: AiCharacterSnapshotFallbackPort? = null, private val cheerCreatorSnapshotFallbackService: CheerCreatorSnapshotFallbackPort? = null, private val popularCommunitySnapshotFallbackService: PopularCommunitySnapshotFallbackPort? = null, - private val clock: Clock = Clock.systemUTC() + private val clock: Clock = Clock.systemUTC(), + private val langContext: LangContext ) { private val snapshotWindowPolicy = RecommendationSnapshotWindowPolicy() @@ -76,7 +78,7 @@ class HomeRecommendationQueryService( memberId: Long? = null, includeAdultContents: Boolean = false ): List { - return queryPort.findFirstAudioContents(now, offset, limit, memberId, includeAdultContents) + return queryPort.findFirstAudioContents(now, offset, limit, memberId, includeAdultContents, langContext.lang.code) } fun findAiCharacterRecommendations( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt index 26578c8e..bd4d6fed 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt @@ -38,7 +38,8 @@ interface HomeRecommendationQueryPort { offset: Long = 0, limit: Int, memberId: Long? = null, - includeAdultContents: Boolean = false + includeAdultContents: Boolean = false, + locale: String ): List fun findAiCharacterSnapshots( diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt index ddd59d3c..2d462146 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.recommendation.application import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord @@ -27,7 +28,8 @@ import java.time.ZoneOffset class HomeRecommendationQueryServiceTest { private val port = FakeHomeRecommendationQueryPort() private val snapshotPort = FakeHomeRecommendationSnapshotPort() - private val service = HomeRecommendationQueryService(port, snapshotPort) + private val langContext = LangContext() + private val service = HomeRecommendationQueryService(port, snapshotPort, langContext = langContext) @Test @DisplayName("다시듣기 테마 콘텐츠는 AUDIO가 아니라 LIVE_REPLAY 활동으로 분류한다") @@ -152,6 +154,7 @@ class HomeRecommendationQueryServiceTest { assertEquals(now, port.firstAudioNow) assertEquals(103L, port.firstAudioMemberId) assertEquals(10, port.firstAudioLimit) + assertEquals(Lang.KO.code, port.firstAudioLocale) assertEquals(port.firstAudioContents, contents) } @@ -159,7 +162,7 @@ class HomeRecommendationQueryServiceTest { @DisplayName("AI 캐릭터 추천은 최신 스냅샷 20개를 기준으로 순서를 유지해 상세를 조립한다") fun shouldFindAiCharactersFromLatestSnapshotsWithLimitAndDetails() { val fallback = FakeAiCharacterSnapshotFallbackPort(emptyList()) - val service = HomeRecommendationQueryService(port, snapshotPort, fallback) + val service = HomeRecommendationQueryService(port, snapshotPort, fallback, langContext = langContext) val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59) val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) snapshotPort.replaceSnapshots( @@ -261,7 +264,7 @@ class HomeRecommendationQueryServiceTest { val fallback = FakeAiCharacterSnapshotFallbackPort( listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 10L, 10.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59))) ) - val service = HomeRecommendationQueryService(port, snapshotPort, fallback) + val service = HomeRecommendationQueryService(port, snapshotPort, fallback, langContext = langContext) port.aiCharacterDetails = listOf( HomeAiCharacterRecommendationRecord( characterId = 10L, @@ -286,7 +289,7 @@ class HomeRecommendationQueryServiceTest { @DisplayName("AI 캐릭터 추천은 fallback 후에도 스냅샷이 없으면 빈 배열을 반환한다") fun shouldReturnEmptyAiCharactersWhenFallbackDoesNotCreateSnapshots() { val fallback = FakeAiCharacterSnapshotFallbackPort(emptyList()) - val service = HomeRecommendationQueryService(port, snapshotPort, fallback) + val service = HomeRecommendationQueryService(port, snapshotPort, fallback, langContext = langContext) val characters = service.findAiCharacterRecommendations() @@ -302,7 +305,7 @@ class HomeRecommendationQueryServiceTest { val fallback = FakeAiCharacterSnapshotFallbackPort( listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59))) ) - val service = HomeRecommendationQueryService(port, snapshotPort, fallback) + val service = HomeRecommendationQueryService(port, snapshotPort, fallback, langContext = langContext) snapshotPort.replaceSnapshots( RecommendedSectionType.AI_CHARACTER, LocalDateTime.of(2026, 7, 9, 14, 59, 59), @@ -325,7 +328,7 @@ class HomeRecommendationQueryServiceTest { val fallback = FakeAiCharacterSnapshotFallbackPort( listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59))) ) - val service = HomeRecommendationQueryService(port, snapshotPort, fallback) + val service = HomeRecommendationQueryService(port, snapshotPort, fallback, langContext = langContext) val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) snapshotPort.replaceSnapshots( RecommendedSectionType.AI_CHARACTER, @@ -352,7 +355,7 @@ class HomeRecommendationQueryServiceTest { val fallback = FakeAiCharacterSnapshotFallbackPort( listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59))) ) - val service = HomeRecommendationQueryService(port, snapshotPort, fallback) + val service = HomeRecommendationQueryService(port, snapshotPort, fallback, langContext = langContext) val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) snapshotPort.replaceSnapshots( RecommendedSectionType.AI_CHARACTER, @@ -376,7 +379,8 @@ class HomeRecommendationQueryServiceTest { port, snapshotPort, cheerCreatorSnapshotFallbackService = fallback, - clock = fixedClock() + clock = fixedClock(), + langContext = langContext ) val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) snapshotPort.replaceSnapshots( @@ -419,7 +423,8 @@ class HomeRecommendationQueryServiceTest { port, snapshotPort, cheerCreatorSnapshotFallbackService = fallback, - clock = fixedClock() + clock = fixedClock(), + langContext = langContext ) port.cheerCreatorDetails = listOf( HomeCheerCreatorRecommendationRecord( @@ -448,7 +453,8 @@ class HomeRecommendationQueryServiceTest { port, snapshotPort, cheerCreatorSnapshotFallbackService = fallback, - clock = fixedClock() + clock = fixedClock(), + langContext = langContext ) val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59) snapshotPort.replaceSnapshots( @@ -483,7 +489,8 @@ class HomeRecommendationQueryServiceTest { port, snapshotPort, cheerCreatorSnapshotFallbackService = fallback, - clock = fixedClock() + clock = fixedClock(), + langContext = langContext ) snapshotPort.replaceSnapshots( RecommendedSectionType.CHEER_CREATOR, @@ -502,7 +509,7 @@ class HomeRecommendationQueryServiceTest { @Test @DisplayName("인기 커뮤니티 추천은 대상일 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다") fun shouldFindPopularCommunitiesFromTargetDaySnapshotsWithLimitDetailsAndCreatorUniqueness() { - val service = HomeRecommendationQueryService(port, snapshotPort, clock = fixedClock()) + val service = HomeRecommendationQueryService(port, snapshotPort, clock = fixedClock(), langContext = langContext) val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) snapshotPort.replaceSnapshots( RecommendedSectionType.POPULAR_COMMUNITY, @@ -576,7 +583,7 @@ class HomeRecommendationQueryServiceTest { @Test @DisplayName("인기 커뮤니티 추천은 상위 스냅샷 중복 제거 후 뒤 후보로 기본 10개를 채운다") fun shouldBackfillPopularCommunitiesAfterRemovingDuplicateCreators() { - val service = HomeRecommendationQueryService(port, snapshotPort, clock = fixedClock()) + val service = HomeRecommendationQueryService(port, snapshotPort, clock = fixedClock(), langContext = langContext) val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) snapshotPort.replaceSnapshots( RecommendedSectionType.POPULAR_COMMUNITY, @@ -621,7 +628,8 @@ class HomeRecommendationQueryServiceTest { port, snapshotPort, popularCommunitySnapshotFallbackService = fallback, - clock = fixedClock() + clock = fixedClock(), + langContext = langContext ) val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59) snapshotPort.replaceSnapshots( @@ -666,7 +674,8 @@ class HomeRecommendationQueryServiceTest { port, snapshotPort, popularCommunitySnapshotFallbackService = fallback, - clock = fixedClock() + clock = fixedClock(), + langContext = langContext ) snapshotPort.replaceSnapshots( RecommendedSectionType.POPULAR_COMMUNITY, @@ -991,6 +1000,7 @@ class HomeRecommendationQueryServiceTest { var firstAudioOffset: Long? = null var firstAudioMemberId: Long? = null var firstAudioIncludeAdultContents: Boolean? = null + var firstAudioLocale: String? = null var aiCharacterDetailIds: List = emptyList() val aiCharacterDetailIdRequests = mutableListOf>() var randomAiCharacterExcludeIds: List? = null @@ -1121,13 +1131,15 @@ class HomeRecommendationQueryServiceTest { offset: Long, limit: Int, memberId: Long?, - includeAdultContents: Boolean + includeAdultContents: Boolean, + locale: String ): List { firstAudioNow = now firstAudioOffset = offset firstAudioLimit = limit firstAudioMemberId = memberId firstAudioIncludeAdultContents = includeAdultContents + firstAudioLocale = locale return firstAudioContents } From 21f72f2827043efa97746059480812d8c1602938 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 9 Sep 2026 23:21:49 +0900 Subject: [PATCH 10/29] =?UTF-8?q?feat(home-recommendation):=20=EC=B2=AB=20?= =?UTF-8?q?=EC=98=A4=EB=94=94=EC=98=A4=20=EC=A0=9C=EB=AA=A9=20=EB=B2=88?= =?UTF-8?q?=EC=97=AD=EC=9D=84=20=EC=A0=81=EC=9A=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...efaultHomeRecommendationQueryRepository.kt | 22 ++- .../HomeRecommendationFacadeTest.kt | 10 +- ...ltHomeRecommendationQueryRepositoryTest.kt | 145 ++++++++++++++++-- 3 files changed, 159 insertions(+), 18 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt index cfd16a89..9c129fd2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt @@ -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 { val sql = """ with creator_debut as ( @@ -475,7 +478,7 @@ class DefaultHomeRecommendationQueryRepository( @Suppress("UNCHECKED_CAST") val rows = query.resultList as List> - 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( diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt index 265b8e57..56ad10cf 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.api.home.application import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService @@ -26,7 +27,11 @@ import java.time.LocalDateTime class HomeRecommendationFacadeTest { private val queryPort = FakeHomeRecommendationQueryPort() - private val queryService = HomeRecommendationQueryService(queryPort, EmptyRecommendationSnapshotPort()) + private val queryService = HomeRecommendationQueryService( + queryPort, + EmptyRecommendationSnapshotPort(), + langContext = LangContext() + ) private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) private val facade = HomeRecommendationFacade(queryService, preferenceService, "https://cdn.test") @@ -135,7 +140,8 @@ class HomeRecommendationFacadeTest { offset: Long, limit: Int, memberId: Long?, - includeAdultContents: Boolean + includeAdultContents: Boolean, + locale: String ): List = firstAudioContents override fun findAiCharacterSnapshots( diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt index 582b797c..56221dbf 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt @@ -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, From e5230c09502daebfebf4c5949ebd7236b3d97390 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 9 Sep 2026 23:22:44 +0900 Subject: [PATCH 11/29] =?UTF-8?q?feat(content-ranking):=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20=EC=A1=B0=ED=9A=8C=20=EC=96=B8=EC=96=B4=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/AudioRankingQueryService.kt | 15 +++-- .../port/out/AudioRankingSnapshotPort.kt | 6 +- .../in/web/AudioRankingControllerTest.kt | 63 +++++++++++++++++++ .../AudioRankingQueryServiceTest.kt | 55 ++++++++++++++-- 4 files changed, 128 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt index 76d566e1..e0c95d4c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt @@ -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 { - 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 { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotPort.kt index d1a49280..6c779028 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotPort.kt @@ -6,13 +6,15 @@ import java.time.LocalDateTime interface AudioRankingSnapshotPort { fun findLatestVisibleSnapshots( rankingType: AudioRankingType, - nowUtc: LocalDateTime + nowUtc: LocalDateTime, + locale: String ): List fun findPreviousVisibleSnapshots( rankingType: AudioRankingType, currentAggregationStartAtUtc: LocalDateTime, - nowUtc: LocalDateTime + nowUtc: LocalDateTime, + locale: String ): List fun replaceSnapshots( diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt index 47f50290..0e8949f9 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt @@ -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) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt index 48c00c36..6ee488fe 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt @@ -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 = mutableListOf() + var previousLocales: MutableList = mutableListOf() + var latestSnapshotsByLocale: Map> = emptyMap() override fun findLatestVisibleSnapshots( rankingType: AudioRankingType, - nowUtc: LocalDateTime + nowUtc: LocalDateTime, + locale: String ): List { 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 { this.currentAggregationStartAtUtc = currentAggregationStartAtUtc + previousLocales.add(locale) return previousSnapshots } From 50409e41c0c469529572f5d77033c3cb23d67d22 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 9 Sep 2026 23:23:19 +0900 Subject: [PATCH 12/29] =?UTF-8?q?feat(content-ranking):=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EC=A0=9C=EB=AA=A9=20?= =?UTF-8?q?=EB=B2=88=EC=97=AD=EC=9D=84=20=EC=A0=81=EC=9A=A9=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...tAudioRankingSnapshotPersistenceAdapter.kt | 29 ++- ...ioRankingSnapshotPersistenceAdapterTest.kt | 229 +++++++++++++++++- .../AudioRankingSnapshotRefreshServiceTest.kt | 6 +- 3 files changed, 247 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt index aa42851d..698e5b78 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt @@ -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 { - 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 { 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.toRecords(locale: String): List { + 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, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt index 1e7a57af..d5f4f013 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt @@ -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(), + adapter.findLatestVisibleSnapshots( + AudioRankingType.WEEKLY_POPULAR, + LocalDateTime.of(2026, 6, 8, 23, 59), + "en" + ) + ) + assertEquals(1L, statistics.prepareStatementCount) + assertEquals( + emptyList(), + 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 { + return listOf( + finalScore, + normalizedScore, + rawScore, + revenueCanAmount, + salesCount, + viewCount, + likeCount, + commentCount, + previousSalesCount, + previousViewCount, + previousLikeCount, + previousCommentCount, + salesGrowthRate, + viewGrowthRate, + likeGrowthRate, + commentGrowthRate, + contentGrowthScore, + boostMultiplier + ) + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshServiceTest.kt index 2225b8f6..cb0cf334 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshServiceTest.kt @@ -241,13 +241,15 @@ private class FakeAudioRankingSnapshotPort : AudioRankingSnapshotPort { override fun findLatestVisibleSnapshots( rankingType: AudioRankingType, - nowUtc: LocalDateTime + nowUtc: LocalDateTime, + locale: String ): List = snapshots override fun findPreviousVisibleSnapshots( rankingType: AudioRankingType, currentAggregationStartAtUtc: LocalDateTime, - nowUtc: LocalDateTime + nowUtc: LocalDateTime, + locale: String ): List = snapshots override fun replaceSnapshots( From 0e96e233aaf2a7c4bca41c04a4bd1c1570e08bd9 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 10 Sep 2026 15:09:36 +0900 Subject: [PATCH 13/29] =?UTF-8?q?docs(creator-community):=20=EB=B3=B8?= =?UTF-8?q?=EB=AC=B8=20=EB=B2=88=EC=97=AD=20=EC=9A=94=EA=B5=AC=EC=82=AC?= =?UTF-8?q?=ED=95=AD=EA=B3=BC=20=EA=B5=AC=ED=98=84=20=EA=B3=84=ED=9A=8D?= =?UTF-8?q?=EC=9D=84=20=EA=B8=B0=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 965 ++++++++++++++++++ .../prd.md | 176 ++++ 2 files changed, 1141 insertions(+) create mode 100644 docs/20260910_크리에이터커뮤니티게시물본문번역/plan-task.md create mode 100644 docs/20260910_크리에이터커뮤니티게시물본문번역/prd.md diff --git a/docs/20260910_크리에이터커뮤니티게시물본문번역/plan-task.md b/docs/20260910_크리에이터커뮤니티게시물본문번역/plan-task.md new file mode 100644 index 00000000..34df9b45 --- /dev/null +++ b/docs/20260910_크리에이터커뮤니티게시물본문번역/plan-task.md @@ -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, locale: String): Map` | 읽기 전용. 현재 게시물과 번역을 일괄 조회해 표시 본문과 유효 번역 여부를 반환한다. 감지·예약·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: but was: `로 실패했다. 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 실행 예시가 실제 클래스와 일치하는지도 갱신한다. diff --git a/docs/20260910_크리에이터커뮤니티게시물본문번역/prd.md b/docs/20260910_크리에이터커뮤니티게시물본문번역/prd.md new file mode 100644 index 00000000..a96326af --- /dev/null +++ b/docs/20260910_크리에이터커뮤니티게시물본문번역/prd.md @@ -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`를 동기화한다. From 19b068b77551ecc691dffe504e0c8f36adeab10e Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 10 Sep 2026 15:09:36 +0900 Subject: [PATCH 14/29] =?UTF-8?q?docs(creator-community):=201=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=EA=B2=80=ED=86=A0=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reviews/p1-t2-review.md | 118 +++++++++++++++ .../reviews/phase-1-review.md | 134 ++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/p1-t2-review.md create mode 100644 docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/phase-1-review.md diff --git a/docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/p1-t2-review.md b/docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/p1-t2-review.md new file mode 100644 index 00000000..02684130 --- /dev/null +++ b/docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/p1-t2-review.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 수동 동시성 검증은 사용자 정책상 실행하지 않았다. diff --git a/docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/phase-1-review.md b/docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/phase-1-review.md new file mode 100644 index 00000000..b5a157de --- /dev/null +++ b/docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/phase-1-review.md @@ -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 소비자가 통과했고 별도의 미해결 공통 경계가 없다는 영향 범위 판단에 따른다. From 74fbf18c586b5972e65d64b3bc5a98fd35b3abf2 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 10 Sep 2026 15:09:37 +0900 Subject: [PATCH 15/29] =?UTF-8?q?docs(creator-community):=202=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=EA=B2=80=ED=86=A0=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reviews/phase-2-review.md | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/phase-2-review.md diff --git a/docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/phase-2-review.md b/docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/phase-2-review.md new file mode 100644 index 00000000..6d82adac --- /dev/null +++ b/docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/phase-2-review.md @@ -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 수정의 영향 재검토와 기존 테스트 서버 수동 확인이다. 이번 리뷰에서 수정한 제품 코드는 없다. From 250b16888753e81d4f6918b7f64770239a17d420 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 10 Sep 2026 15:09:37 +0900 Subject: [PATCH 16/29] =?UTF-8?q?docs(creator-community):=20=EC=B5=9C?= =?UTF-8?q?=EC=A2=85=20=EA=B2=80=ED=86=A0=20=EA=B2=B0=EA=B3=BC=EC=99=80=20?= =?UTF-8?q?=EC=A6=9D=EA=B1=B0=EB=A5=BC=20=EA=B8=B0=EB=A1=9D=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reviews/phase-3-review.md | 84 ++++++++++++ .../reviews/review-evidence.md | 121 ++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/phase-3-review.md create mode 100644 docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/review-evidence.md diff --git a/docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/phase-3-review.md b/docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/phase-3-review.md new file mode 100644 index 00000000..8314a476 --- /dev/null +++ b/docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/phase-3-review.md @@ -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 확인은 기존 수동 체크리스트대로 남긴다. 이번 리뷰에서 제품 코드를 수정하지 않았다. diff --git a/docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/review-evidence.md b/docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/review-evidence.md new file mode 100644 index 00000000..141fa23a --- /dev/null +++ b/docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/review-evidence.md @@ -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, 배포는 +검증하지 않았으며 기존 정적 리뷰의 실환경 검증 한계도 유지한다. From 5b9bc50379d46279712d597c2daddeaf94aa8af6 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 10 Sep 2026 15:09:37 +0900 Subject: [PATCH 17/29] =?UTF-8?q?docs(creator-community):=20=EB=B3=B8?= =?UTF-8?q?=EB=AC=B8=20=EB=B2=88=EC=97=AD=20=EC=8A=A4=ED=82=A4=EB=A7=88?= =?UTF-8?q?=EB=A5=BC=20=EA=B8=B0=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schema.sql | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 docs/20260910_크리에이터커뮤니티게시물본문번역/schema.sql diff --git a/docs/20260910_크리에이터커뮤니티게시물본문번역/schema.sql b/docs/20260910_크리에이터커뮤니티게시물본문번역/schema.sql new file mode 100644 index 00000000..5f29a8cc --- /dev/null +++ b/docs/20260910_크리에이터커뮤니티게시물본문번역/schema.sql @@ -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 = '크리에이터 커뮤니티 게시물 본문 번역'; From 533007c14c867adb593b661344afeff2034b7cdd Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 10 Sep 2026 15:10:25 +0900 Subject: [PATCH 18/29] =?UTF-8?q?feat(creator-community):=20=EB=B3=B8?= =?UTF-8?q?=EB=AC=B8=20=EC=96=B8=EC=96=B4=EC=99=80=20=EA=B0=9C=EC=A0=95=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EA=B8=B0=EB=B0=98=EC=9D=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../creatorCommunity/CreatorCommunity.kt | 6 +++++- .../CreatorCommunityRepository.kt | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt index f3b62f66..fdd42838 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt @@ -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) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityRepository.kt index 616c25f3..5b2a5029 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityRepository.kt @@ -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, 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 { From 46803f40b56eed01f94c316e7a134e329e86bbee Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 10 Sep 2026 15:10:27 +0900 Subject: [PATCH 19/29] =?UTF-8?q?feat(creator-community):=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=EB=AC=BC=20=EB=B2=88=EC=97=AD=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorCommunityTranslation.kt | 37 +++++++++++++++++++ .../CreatorCommunityTranslationRepository.kt | 26 +++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/adapter/out/persistence/CreatorCommunityTranslation.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/adapter/out/persistence/CreatorCommunityTranslationRepository.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/adapter/out/persistence/CreatorCommunityTranslation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/adapter/out/persistence/CreatorCommunityTranslation.kt new file mode 100644 index 00000000..9a3da987 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/adapter/out/persistence/CreatorCommunityTranslation.kt @@ -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() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/adapter/out/persistence/CreatorCommunityTranslationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/adapter/out/persistence/CreatorCommunityTranslationRepository.kt new file mode 100644 index 00000000..fb603ce8 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/adapter/out/persistence/CreatorCommunityTranslationRepository.kt @@ -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 { + 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, + locale: String + ): List +} From 3e9ba0b23ec5ec024e7fd57cd450ec88077c9f11 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 10 Sep 2026 15:10:55 +0900 Subject: [PATCH 20/29] =?UTF-8?q?feat(translation):=20=EC=BB=A4=EB=AE=A4?= =?UTF-8?q?=EB=8B=88=ED=8B=B0=20=EC=9B=90=EB=AC=B8=20=EC=B6=94=EC=B6=9C=20?= =?UTF-8?q?=EB=8C=80=EC=83=81=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../translation/LanguageTranslationEvent.kt | 3 +- .../translation/TranslationSourceExtractor.kt | 20 +++++++- .../translation/TranslationJobWorkerTest.kt | 48 ++++++++++++++++++- 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt index 75763590..b266e4c7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt @@ -18,7 +18,8 @@ enum class LanguageTranslationTargetType { ORIGINAL_WORK, - CREATOR_CONTENT_CATEGORY + CREATOR_CONTENT_CATEGORY, + CREATOR_COMMUNITY } class LanguageTranslationEvent( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationSourceExtractor.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationSourceExtractor.kt index a7f84596..9175c79c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationSourceExtractor.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationSourceExtractor.kt @@ -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 + val fields: List, + 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 + ) + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorkerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorkerTest.kt index 6c2ea3f0..1bf48ef7 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorkerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorkerTest.kt @@ -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), From 6efecf5495febbf4d959573e2d0beebb260124fa Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 10 Sep 2026 15:11:22 +0900 Subject: [PATCH 21/29] =?UTF-8?q?feat(creator-community):=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=EB=AC=BC=20=EB=B2=88=EC=97=AD=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TranslationReadModelMaterializer.kt | 54 +- .../CreatorCommunityTranslationService.kt | 113 +++ .../CreatorCommunityTranslationServiceTest.kt | 878 ++++++++++++++++++ 3 files changed, 1044 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/CreatorCommunityTranslationServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationReadModelMaterializer.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationReadModelMaterializer.kt index 6b93f0b9..7adcc50b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationReadModelMaterializer.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationReadModelMaterializer.kt @@ -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 + ): 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" } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt new file mode 100644 index 00000000..e51a61e0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt @@ -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, locale: String): Map { + 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 +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/CreatorCommunityTranslationServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/CreatorCommunityTranslationServiceTest.kt new file mode 100644 index 00000000..caf3cb0b --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/CreatorCommunityTranslationServiceTest.kt @@ -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() + 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() + 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() + 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 + ) + } +} From 02840706673f5678691d19264e0c0236d0aef6ed Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 10 Sep 2026 15:11:48 +0900 Subject: [PATCH 22/29] =?UTF-8?q?fix(language-detection):=20=EC=B5=9C?= =?UTF-8?q?=EC=B4=88=20=EA=B0=90=EC=A7=80=20=EC=BA=90=EC=8B=9C=20=EA=B2=BD?= =?UTF-8?q?=ED=95=A9=EC=9D=84=20=EC=A7=81=EB=A0=AC=ED=99=94=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/LanguageDetectionCacheService.kt | 18 +- .../LanguageDetectionResultRepository.kt | 54 +++ ...tyLanguageDetectionCacheConcurrencyTest.kt | 336 ++++++++++++++++++ .../LanguageDetectionCacheServiceTest.kt | 64 +++- 4 files changed, 464 insertions(+), 8 deletions(-) create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectionCacheConcurrencyTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt index f3a67654..d1048470 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt @@ -25,18 +25,22 @@ class LanguageDetectionCacheService( ) if (cached != null) return cached.detectedLanguage - val detectedLanguage = detector()?.takeIf { it.isNotBlank() } ?: return null - languageDetectionResultRepository.save( - LanguageDetectionResult( + val detectedLanguage = detector()?.takeIf { it.isNotBlank() }?.lowercase() ?: return null + languageDetectionResultRepository.insertIfAbsent( + sourceHash = sourceHash, + sourceTextSample = normalizedQuery.take(MAX_SAMPLE_LENGTH), + detectedLanguage = detectedLanguage, + provider = provider, + confidence = null, + normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION + ) + return requireNotNull( + languageDetectionResultRepository.findDetectedLanguageForUpdate( sourceHash = sourceHash, - sourceTextSample = normalizedQuery.take(MAX_SAMPLE_LENGTH), - detectedLanguage = detectedLanguage.lowercase(), provider = provider, - confidence = null, normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION ) ) - return detectedLanguage.lowercase() } companion object { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionResultRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionResultRepository.kt index 23e4ae35..be8c003a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionResultRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionResultRepository.kt @@ -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 { fun findBySourceHashAndProviderAndNormalizationVersion( @@ -8,4 +11,55 @@ interface LanguageDetectionResultRepository : JpaRepository(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(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 { + 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 + } + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt index ab2aefda..9125bb91 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt @@ -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) } } From 1b19ade69e8567ce95aa0b7e0aa575fc9e3f40fc Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 10 Sep 2026 15:12:16 +0900 Subject: [PATCH 23/29] =?UTF-8?q?feat(creator-community):=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=EB=AC=BC=20=EC=96=B8=EC=96=B4=20=EA=B0=90=EC=A7=80=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=EC=9D=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/content/LanguageDetectEvent.kt | 65 +- .../CreatorCommunityLanguageDetectTest.kt | 813 ++++++++++++++++++ 2 files changed, 876 insertions(+), 2 deletions(-) create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt index 0e77b44e..669b8141 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt @@ -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) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt new file mode 100644 index 00000000..2ccd3785 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt @@ -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(), 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(), 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(), jobTargets(blankPostId)) + assertEquals(emptySet(), jobTargets(unsupportedPostId)) + assertEquals(emptySet(), 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(), 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(), 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) { + assertTrue(await { jobTargets(postId) == expectedTargets }) + } + + private fun jobTargets(postId: Long): Set { + 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() + val queries = ConcurrentLinkedQueue() + private val blockingDetections = ConcurrentHashMap() + private val concurrentDetections = ConcurrentHashMap() + + 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) + } +} From 11e29460b75422f3afed992ca3556cf3872fa709 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 10 Sep 2026 15:13:40 +0900 Subject: [PATCH 24/29] =?UTF-8?q?feat(creator-community):=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=EA=B3=BC=20=EC=83=81=EC=84=B8=EC=97=90=20=EB=B3=B8?= =?UTF-8?q?=EB=AC=B8=20=EB=B2=88=EC=97=AD=EC=9D=84=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorCommunityService.kt | 47 ++- .../CreatorCommunityServiceTest.kt | 349 +++++++++++++++++- ...CreatorCommunityTranslationEndToEndTest.kt | 248 +++++++++++++ 3 files changed, 629 insertions(+), 15 deletions(-) create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityTranslationEndToEndTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt index ffd5e9e0..9eb1805b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt @@ -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 { val postList = repository.getLatestPostListFromCreatorsYouFollow(memberId = memberId, isAdult = isAdult) + val visiblePostList = postList.filter { + !isBlockedBetweenMembers(memberId = memberId, creatorId = it.creatorId) + } + val displayContents = creatorCommunityTranslationService + .findDisplayContents(visiblePostList.map { it.id }, langContext.lang.code) - return postList - .filter { - !isBlockedBetweenMembers(memberId = memberId, creatorId = it.creatorId) - } + return visiblePostList .map { val isLike = likeRepository.findByPostIdAndMemberId(postId = it.id, memberId = memberId)?.isActive ?: false @@ -649,7 +676,7 @@ class CreatorCommunityService( val existOrdered = useCanRepository.isExistCommunityPostOrdered(postId = it.id, memberId = memberId) - it.toCommunityPostListResponse( + it.copy(content = displayContents[it.id]?.content ?: it.content).toCommunityPostListResponse( imageHost = imageHost, audioUrl = null, date = it.date.getTimeAgoString(), diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt index ad19b50e..93283d6b 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt @@ -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(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(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 + ) + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityTranslationEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityTranslationEndToEndTest.kt new file mode 100644 index 00000000..7e56d58b --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityTranslationEndToEndTest.kt @@ -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 + ) + } +} From 059cef8a35b51d4d2f58884b9a48c97e293b56f0 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 10 Sep 2026 15:14:08 +0900 Subject: [PATCH 25/29] =?UTF-8?q?test(admin-community):=20=EB=B3=B8?= =?UTF-8?q?=EB=AC=B8=20=EB=B2=88=EC=97=AD=20=EC=83=9D=EB=AA=85=EC=A3=BC?= =?UTF-8?q?=EA=B8=B0=20=ED=9A=8C=EA=B7=80=EB=A5=BC=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...racterAdminCommunityPostConcurrencyTest.kt | 167 +++++++++++++++++- ...AiCharacterAdminCommunityPostCreateTest.kt | 20 +++ ...AiCharacterAdminCommunityPostUpdateTest.kt | 4 + ...LegacyCommunityPostCharacterizationTest.kt | 8 +- 4 files changed, 196 insertions(+), 3 deletions(-) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostConcurrencyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostConcurrencyTest.kt index 77d1574c..2b820a4d 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostConcurrencyTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostConcurrencyTest.kt @@ -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 { + updatesReady.countDown() + startUpdates.await(5, TimeUnit.SECONDS) + mockMvc.perform(updateContentRequest(fixture.characterId, fixture.postId, "first")) + .andReturn() + .response + .status + } + + val second = executor.submit { + 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 { + mockMvc.perform(fixedAndContentRequest(fixture.characterId, fixture.postId, "after fixed")) + .andReturn() + .response + .status + } + assertTrue(staleAdminRead.await(5, TimeUnit.SECONDS)) + + val concurrentContent = executor.submit { + 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 + ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt index cb200f9f..92b48e1e 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt @@ -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() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostUpdateTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostUpdateTest.kt index 571e02a2..a64cd6e8 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostUpdateTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostUpdateTest.kt @@ -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) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt index 7eaaad48..e3978075 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt @@ -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) } From 5a11c2e53388c61060ef32f6598a59a0a9c78ea7 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 10 Sep 2026 15:14:16 +0900 Subject: [PATCH 26/29] =?UTF-8?q?feat(creator-channel):=20=EC=BB=A4?= =?UTF-8?q?=EB=AE=A4=EB=8B=88=ED=8B=B0=20=EC=A1=B0=ED=9A=8C=EC=97=90=20?= =?UTF-8?q?=EB=B3=B8=EB=AC=B8=20=EB=B2=88=EC=97=AD=EC=9D=84=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorChannelCommunityQueryService.kt | 27 +++- .../CreatorChannelCommunityEndToEndTest.kt | 152 ++++++++++++++++++ ...CreatorChannelCommunityQueryServiceTest.kt | 109 ++++++++++++- 3 files changed, 283 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt index 1c3f3c5f..785d6015 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt @@ -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 { - 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) ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt index c805fb01..f0303992 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt @@ -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) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt index 6fd723c6..2a6b8dc9 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt @@ -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" ) } From 02fceca3530bf018e7ceaa86a25013324ebd6ea6 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 10 Sep 2026 15:14:26 +0900 Subject: [PATCH 27/29] =?UTF-8?q?test(creator-channel):=20=EC=B1=84?= =?UTF-8?q?=EB=84=90=20=ED=99=88=20=EC=BB=A4=EB=AE=A4=EB=8B=88=ED=8B=B0=20?= =?UTF-8?q?=EB=B2=88=EC=97=AD=EC=9D=84=20=EA=B2=80=EC=A6=9D=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/CreatorChannelHomeEndToEndTest.kt | 39 +++++++++++++++++++ .../CreatorChannelHomeQueryServiceTest.kt | 2 + 2 files changed, 41 insertions(+) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt index 9970dff6..6f62841b 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt @@ -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 diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt index 362545b3..50fbc451 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt @@ -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 @@ -505,6 +506,7 @@ class CreatorChannelHomeQueryServiceTest { audioContentCloudFront = audioContentCloudFront, messageSource = messageSource, langContext = langContext, + creatorCommunityTranslationService = Mockito.mock(CreatorCommunityTranslationService::class.java), cloudFrontHost = "https://cdn.test" ), queryPolicy = CreatorChannelHomeQueryPolicy(), From 0d44258a610abb0d82cb5b2fd1faa73e78b64122 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 10 Sep 2026 15:14:35 +0900 Subject: [PATCH 28/29] =?UTF-8?q?feat(home-following):=20=EC=BB=A4?= =?UTF-8?q?=EB=AE=A4=EB=8B=88=ED=8B=B0=20=EC=86=8C=EC=8B=9D=20=EB=B3=B8?= =?UTF-8?q?=EB=AC=B8=20=EB=B2=88=EC=97=AD=EC=9D=84=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/HomeFollowingQueryService.kt | 23 ++++++- .../in/web/HomeFollowingEndToEndTest.kt | 55 +++++++++++++++ .../HomeFollowingQueryServiceTest.kt | 69 ++++++++++++++++++- 3 files changed, 143 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt index 8e8dc442..d896247b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt @@ -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 + } ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt index 75099cf5..c0c0e176 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt @@ -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) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt index db834f1d..cff15ee5 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt @@ -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 = emptyList() override fun findFollowingCreators(memberId: Long, limit: Int): List { this.memberId = memberId @@ -168,7 +231,7 @@ class HomeFollowingQueryServiceTest { recentNewsCanViewAdultContent = canViewAdultContent recentNewsNow = nowUtc recentNewsLimit = limit - return emptyList() + return recentNews } } } From da5937318470dbc6be7e69ae8876473279670357 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 10 Sep 2026 15:14:44 +0900 Subject: [PATCH 29/29] =?UTF-8?q?feat(recommendation):=20=EC=9D=B8?= =?UTF-8?q?=EA=B8=B0=20=EC=BB=A4=EB=AE=A4=EB=8B=88=ED=8B=B0=20=EB=B3=B8?= =?UTF-8?q?=EB=AC=B8=20=EB=B2=88=EC=97=AD=EC=9D=84=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HomeRecommendationQueryService.kt | 17 +- .../home/HomeRecommendationControllerTest.kt | 75 ++++++++ .../HomeRecommendationFacadeTest.kt | 5 +- .../HomeRecommendationQueryServiceTest.kt | 161 ++++++++++++++++-- 4 files changed, 240 insertions(+), 18 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt index 8611c9c8..d9c42f1c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.v2.recommendation.application import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType +import kr.co.vividnext.sodalive.v2.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 @@ -31,7 +32,8 @@ class HomeRecommendationQueryService( private val cheerCreatorSnapshotFallbackService: CheerCreatorSnapshotFallbackPort? = null, private val popularCommunitySnapshotFallbackService: PopularCommunitySnapshotFallbackPort? = null, private val clock: Clock = Clock.systemUTC(), - private val langContext: LangContext + private val langContext: LangContext, + private val creatorCommunityTranslationService: CreatorCommunityTranslationService ) { private val snapshotWindowPolicy = RecommendationSnapshotWindowPolicy() @@ -154,9 +156,20 @@ class HomeRecommendationQueryService( .associateBy { it.communityId } val selectedCreatorIds = mutableSetOf() - 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 { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt index a32db47b..26c98681 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt @@ -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, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt index 56ad10cf..398e70a1 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt @@ -4,6 +4,7 @@ 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 @@ -27,10 +28,12 @@ import java.time.LocalDateTime class HomeRecommendationFacadeTest { private val queryPort = FakeHomeRecommendationQueryPort() + private val creatorCommunityTranslationService = Mockito.mock(CreatorCommunityTranslationService::class.java) private val queryService = HomeRecommendationQueryService( queryPort, EmptyRecommendationSnapshotPort(), - langContext = LangContext() + langContext = LangContext(), + creatorCommunityTranslationService = creatorCommunityTranslationService ) private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) private val facade = HomeRecommendationFacade(queryService, preferenceService, "https://cdn.test") diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt index 2d462146..6ad3d381 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt @@ -3,6 +3,8 @@ 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 @@ -20,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 @@ -29,7 +32,18 @@ class HomeRecommendationQueryServiceTest { private val port = FakeHomeRecommendationQueryPort() private val snapshotPort = FakeHomeRecommendationSnapshotPort() private val langContext = LangContext() - private val service = HomeRecommendationQueryService(port, snapshotPort, 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 활동으로 분류한다") @@ -162,7 +176,13 @@ class HomeRecommendationQueryServiceTest { @DisplayName("AI 캐릭터 추천은 최신 스냅샷 20개를 기준으로 순서를 유지해 상세를 조립한다") fun shouldFindAiCharactersFromLatestSnapshotsWithLimitAndDetails() { val fallback = FakeAiCharacterSnapshotFallbackPort(emptyList()) - val service = HomeRecommendationQueryService(port, snapshotPort, fallback, langContext = langContext) + 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( @@ -264,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, langContext = langContext) + val service = HomeRecommendationQueryService( + port, + snapshotPort, + fallback, + langContext = langContext, + creatorCommunityTranslationService = creatorCommunityTranslationService + ) port.aiCharacterDetails = listOf( HomeAiCharacterRecommendationRecord( characterId = 10L, @@ -289,7 +315,13 @@ class HomeRecommendationQueryServiceTest { @DisplayName("AI 캐릭터 추천은 fallback 후에도 스냅샷이 없으면 빈 배열을 반환한다") fun shouldReturnEmptyAiCharactersWhenFallbackDoesNotCreateSnapshots() { val fallback = FakeAiCharacterSnapshotFallbackPort(emptyList()) - val service = HomeRecommendationQueryService(port, snapshotPort, fallback, langContext = langContext) + val service = HomeRecommendationQueryService( + port, + snapshotPort, + fallback, + langContext = langContext, + creatorCommunityTranslationService = creatorCommunityTranslationService + ) val characters = service.findAiCharacterRecommendations() @@ -305,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, langContext = langContext) + val service = HomeRecommendationQueryService( + port, + snapshotPort, + fallback, + langContext = langContext, + creatorCommunityTranslationService = creatorCommunityTranslationService + ) snapshotPort.replaceSnapshots( RecommendedSectionType.AI_CHARACTER, LocalDateTime.of(2026, 7, 9, 14, 59, 59), @@ -328,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, langContext = langContext) + 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, @@ -355,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, langContext = langContext) + 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, @@ -380,7 +430,8 @@ class HomeRecommendationQueryServiceTest { snapshotPort, cheerCreatorSnapshotFallbackService = fallback, clock = fixedClock(), - langContext = langContext + langContext = langContext, + creatorCommunityTranslationService = creatorCommunityTranslationService ) val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) snapshotPort.replaceSnapshots( @@ -424,7 +475,8 @@ class HomeRecommendationQueryServiceTest { snapshotPort, cheerCreatorSnapshotFallbackService = fallback, clock = fixedClock(), - langContext = langContext + langContext = langContext, + creatorCommunityTranslationService = creatorCommunityTranslationService ) port.cheerCreatorDetails = listOf( HomeCheerCreatorRecommendationRecord( @@ -454,7 +506,8 @@ class HomeRecommendationQueryServiceTest { snapshotPort, cheerCreatorSnapshotFallbackService = fallback, clock = fixedClock(), - langContext = langContext + langContext = langContext, + creatorCommunityTranslationService = creatorCommunityTranslationService ) val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59) snapshotPort.replaceSnapshots( @@ -490,7 +543,8 @@ class HomeRecommendationQueryServiceTest { snapshotPort, cheerCreatorSnapshotFallbackService = fallback, clock = fixedClock(), - langContext = langContext + langContext = langContext, + creatorCommunityTranslationService = creatorCommunityTranslationService ) snapshotPort.replaceSnapshots( RecommendedSectionType.CHEER_CREATOR, @@ -509,7 +563,13 @@ class HomeRecommendationQueryServiceTest { @Test @DisplayName("인기 커뮤니티 추천은 대상일 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다") fun shouldFindPopularCommunitiesFromTargetDaySnapshotsWithLimitDetailsAndCreatorUniqueness() { - val service = HomeRecommendationQueryService(port, snapshotPort, clock = fixedClock(), langContext = langContext) + 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, @@ -583,7 +643,13 @@ class HomeRecommendationQueryServiceTest { @Test @DisplayName("인기 커뮤니티 추천은 상위 스냅샷 중복 제거 후 뒤 후보로 기본 10개를 채운다") fun shouldBackfillPopularCommunitiesAfterRemovingDuplicateCreators() { - val service = HomeRecommendationQueryService(port, snapshotPort, clock = fixedClock(), langContext = langContext) + 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, @@ -618,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() { @@ -629,7 +736,8 @@ class HomeRecommendationQueryServiceTest { snapshotPort, popularCommunitySnapshotFallbackService = fallback, clock = fixedClock(), - langContext = langContext + langContext = langContext, + creatorCommunityTranslationService = creatorCommunityTranslationService ) val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59) snapshotPort.replaceSnapshots( @@ -675,7 +783,8 @@ class HomeRecommendationQueryServiceTest { snapshotPort, popularCommunitySnapshotFallbackService = fallback, clock = fixedClock(), - langContext = langContext + langContext = langContext, + creatorCommunityTranslationService = creatorCommunityTranslationService ) snapshotPort.replaceSnapshots( RecommendedSectionType.POPULAR_COMMUNITY, @@ -1212,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()