Compare commits
17
Commits
50409e41c0
...
da59373184
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da59373184 | ||
|
|
0d44258a61 | ||
|
|
02fceca353 | ||
|
|
5a11c2e533 | ||
|
|
059cef8a35 | ||
|
|
11e29460b7 | ||
|
|
1b19ade69e | ||
|
|
0284070667 | ||
|
|
6efecf5495 | ||
|
|
3e9ba0b23e | ||
|
|
46803f40b5 | ||
|
|
533007c14c | ||
|
|
5b9bc50379 | ||
|
|
250b168887 | ||
|
|
74fbf18c58 | ||
|
|
19b068b775 | ||
|
|
0e96e233aa |
@@ -0,0 +1,965 @@
|
||||
# 크리에이터 커뮤니티 게시물 본문 번역 구현 계획
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 기존 로컬 Gate 기록 유지 · `P1-R4` 구현·자동 회귀·수정 후 리뷰 완료 · 테스트 서버 수동 검증 대기 |
|
||||
| 작성일 | 2026-09-10 |
|
||||
| 요구사항 / API 기준 | [PRD](prd.md), 특히 §3 요구사항과 §5 API 계약 |
|
||||
| 기준 템플릿 | `docs/sample/sample-plan-task.md` |
|
||||
| 현재 Phase / 활성 Goal | `P1-R4` 및 P1/P2 영향 회귀 완료 / 활성 구현 Goal 없음 |
|
||||
| 다음 Goal | 테스트 브랜치 배포 후 실제 MySQL/Papago/HTTP 수동 검증 |
|
||||
|
||||
## 목표·범위·제약
|
||||
|
||||
본문 원문을 보존하면서 한국어·영어·일본어 번역을 저장하고, 상세·목록·미리보기 전체에 일관되게 제공한다.
|
||||
신규/본문 수정은 전체 목표 언어를 처리하고, 기존 게시물의 누락 번역은 상세에서 요청 언어만 예약한다.
|
||||
|
||||
기존 Kotlin, Java 17, Spring Boot 2.7.14, Gradle Wrapper, JUnit 5, QueryDSL/JPA, Papago를 사용한다.
|
||||
새 큐·스케줄러·외부 의존성·공개 API 필드를 추가하지 않는다. 원문과 번역을 분리하며 구매/차단/성인 정책을 유지한다.
|
||||
새 재사용 기능은 `v2/creator/channel/community/translation` 아래에 두고 기존 공통 번역 연결부만 확장한다.
|
||||
현재 PRD의 제외 범위는 모든 Goal에 적용한다. 신규 파일 경로는 아래 표에서 `생성`으로 구분한다.
|
||||
|
||||
로컬 검증은 Gradle 자동 테스트와 ktlint으로 한정한다. DDL·HTTP·실제 MySQL 동시성은 로컬에서 실행하지 않고,
|
||||
테스트 브랜치 배포 후 이 문서의 수동 체크리스트에서 확인한다.
|
||||
|
||||
## 현재 상태와 실행 순서
|
||||
|
||||
| Phase | 결과 | 상태 | 완료 Task | 다음 Goal |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 현재 본문에 대응하는 번역 저장·감지 기반 | `P1-R4` 포함 로컬 자동 검증 완료 | 6/6 | 테스트 서버 수동 검증 |
|
||||
| 2 | 작성·수정·상세의 비동기 연결과 폴백 | 완료 | 2/2 | 테스트 서버 수동 검증 |
|
||||
| 3 | 모든 목록·홈 적용과 전체 사용자 흐름 검증 | 로컬 Gate 완료 | 2/2 | 테스트 서버 수동 검증 |
|
||||
|
||||
실행 순서: `P1-T1 → P1-T2 → P1-GATE → P1-R1 → P1-GATE 재검증 → P1-R2 → P1-GATE 재검증 → P1-R3 → P1-GATE 재검증 → P2-T1 → P2-T2 → P2-GATE → P3-T1 → P3-T2 → P3-GATE → P3-R1 → P3-GATE 재검증 → P1-R4 → P1/P2 영향 회귀 → 수정 후 리뷰`.
|
||||
Task 또는 Gate 하나만 활성 Goal로 운용한다. 각 Goal의 시작 조건·체크박스·완료 증거·검증 기록을 모두 충족해야 완료한다.
|
||||
과거 완료 기록은 되돌리지 않고 후속 문제를 별도 수정 Task로 누적한다.
|
||||
|
||||
2026-09-10 후속 리뷰: [Phase 1](reviews/phase-1-review.md)은 수정 1건, [Phase 2](reviews/phase-2-review.md)와
|
||||
[Phase 3](reviews/phase-3-review.md)는 추가 확정 발견 사항 없음.
|
||||
당시 후속 실행 순서: `P1-R4 → P1-GATE 영향 범위 재검토 → P2 상세 경로 영향 재검토 → 기존 테스트 서버 수동 검증`.
|
||||
당시 정적 리뷰는 사용자 지시에 따라 테스트를 재실행하지 않았다. 이후 `P1-R4`의 RED/GREEN, P1/P2 영향 회귀와
|
||||
수정 후 리뷰를 완료했다. 기존 Task/Gate 기록은 유지하며 남은 단계는 테스트 서버 수동 검증이다.
|
||||
|
||||
## 설계 계약
|
||||
|
||||
### 데이터와 공통 파이프라인
|
||||
|
||||
- `CreatorCommunity.languageCode: String?`, `contentRevision: Long`을 추가한다. 기존 행은 NULL/0, 신규 행도 0부터 시작한다.
|
||||
실제 원문 문자열 변경 때만 개정 번호를 증가시키고 언어를 NULL로 초기화한다. 동시 수정 시 개정 번호가 충돌하지 않도록
|
||||
기존 소유권 조건을 유지한 행 잠금 조회를 사용한다.
|
||||
- 신규 `CreatorCommunityTranslation`에는 `creatorCommunityId`, `locale`, `content`, `sourceRevision`, `sourceHash`,
|
||||
`sourceLanguage`와 기존 BaseEntity 시각 필드를 둔다. 게시물 ID와 locale 조합을 유일하게 만든다.
|
||||
- `LanguageDetectTargetType`과 `LanguageTranslationTargetType`에 `CREATOR_COMMUNITY`를 추가한다.
|
||||
번역 필드 키는 `content` 하나다. 다른 리소스 분기는 수정하지 않는다.
|
||||
- `TranslationSource`에 커뮤니티 원문의 개정 번호를 전달할 선택 필드를 추가하고 기존 호출은 기본값으로 유지한다.
|
||||
`TranslationSourceExtractor`는 활성 커뮤니티의 본문·언어·개정 번호를 같은 스냅샷으로 추출한다.
|
||||
- materializer는 추출된 개정 번호에 대응하는 메모리만 사용한다. 저장 시 현재 행의 개정 번호·언어·활성을 다시 검사하고
|
||||
동일한 짧은 DB 트랜잭션 안에서 upsert한다. 오래된 값이 새 번역을 덮어쓰지 않도록 개정 검사와 쓰기를 직렬화한다.
|
||||
외부 Papago 호출은 해당 잠금 밖에서 기존 워커가 처리한다.
|
||||
- 기존 `translation_job` 유일 키와 메모리 키는 유지한다. 이미 완료한 원문으로 복원되는 경우에는 메모리에서
|
||||
현재 개정 번호의 조회 모델을 먼저 materialize하고, 부족한 경우에만 기존 scheduler를 호출한다.
|
||||
|
||||
### 신규 공유 서비스 경계
|
||||
|
||||
`CreatorCommunityTranslationService` 하나에 다음의 명확히 분리된 진입점을 둔다. 단순 내부 호출을 위한 별도 port는 만들지 않는다.
|
||||
|
||||
| 인터페이스 | 책임 / 호출 조건 |
|
||||
|---|---|
|
||||
| `findDisplayContents(postIds: List<Long>, locale: String): Map<Long, CreatorCommunityDisplayContent>` | 읽기 전용. 현재 게시물과 번역을 일괄 조회해 표시 본문과 유효 번역 여부를 반환한다. 감지·예약·materialize 금지. 조회 권한 판정은 호출자가 먼저 수행한다. |
|
||||
| `requestTranslations(postId: Long, targetLanguage: String? = null)` | 별도 쓰기 트랜잭션. NULL이면 원문 외 지원 언어 전체, 값이 있으면 그 언어만 처리한다. 언어가 없으면 감지 이벤트를 발행하고, 있으면 메모리 복원 후 누락 작업을 예약한다. |
|
||||
|
||||
감지 이벤트에는 커뮤니티용 `sourceRevision: Long? = null`, `targetLanguage: String? = null` 정보를 추가한다.
|
||||
기존 이벤트 발행자는 기본값으로 동작한다. 커뮤니티 핸들러는 감지 요청 당시 개정과 현재 개정을 비교한다.
|
||||
감지 결과 저장은 개정 번호·활성 상태 조건을 포함한 갱신으로 처리하고, 저장 커밋 후 공유 서비스를 호출한다.
|
||||
상세에서 넘어온 targetLanguage를 유지해야 기존 글의 첫 감지가 전체 언어 번역으로 확대되지 않는다.
|
||||
|
||||
이미 감지된 본문에는 감지를 반복하지 않고 해당 언어로 예약을 이어간다. 다중 상세 요청에서 감지 API의 동시 중복 호출을
|
||||
완전히 제거하는 신규 분산 잠금은 추가하지 않는다. 기존 감지 캐시를 재사용하고 작업 중복/유일성 경합은 기존 DB 키와
|
||||
커뮤니티 예약 경계에서 제어한다. 유일 키 경합으로 정상 상세 응답이 실패하지 않도록 통합 검증한다.
|
||||
|
||||
### 트랜잭션과 표시 순서
|
||||
|
||||
- 작성·수정 커밋 후 처리에는 기존 커밋 후 실행 패턴을 재사용한다. 감지·예약은 롤백된 원문을 소비하지 않는다.
|
||||
- 레거시 서비스와 v2 facade/query service는 읽기 전용 트랜잭션이 있으므로 상세의 감지/예약은 별도 Spring bean의
|
||||
`REQUIRES_NEW` 진입점을 통해 처리한다. 자기 호출로 트랜잭션 프록시를 우회하지 않는다.
|
||||
- 권한/노출 판정 → 현재 원문/유효 번역 선택 → 기존 유료 마스킹 → 응답 조립 순서로 적용한다.
|
||||
- 목록·채널 홈·홈 추천·팔로잉은 일괄 읽기 메서드만 호출한다. 추천 스냅샷에는 언어별 본문을 넣지 않고
|
||||
기존 후보 ID로 얻은 상세 레코드의 본문만 응답 조립 전에 치환한다.
|
||||
- 원문을 담은 관리자 편집 응답, 댓글·답글, 푸시 이벤트와 정산 조회는 그대로 둔다.
|
||||
|
||||
### Phase 1: 번역 저장과 원문 언어 감지 기반
|
||||
|
||||
#### 구현 항목
|
||||
|
||||
- [x] **Task 1.1: 개정 번호에 대응하는 본문 번역 저장·조회·메모리 복원**
|
||||
|
||||
**Goal ID / objective:** `P1-T1` — 현재 본문과 일치하는 번역만 저장·조회할 수 있다.
|
||||
|
||||
- 시작 조건: PRD `CCT-001`, `CCT-007`, `CCT-010` 확인, 사용자 구현 요청.
|
||||
- 완료 증거: 아래 파일과 local automatic gate, 원문 복원/오래된 번역 차단 검증 기록.
|
||||
실제 MySQL DDL·HTTP·동시성 확인은 테스트 브랜치 배포 후 수동 체크리스트로 이관한다.
|
||||
- 범위 밖: 언어 감지 이벤트 연결, HTTP 진입점 변경.
|
||||
- 생산 인터페이스: 설계 계약의 `findDisplayContents`, 언어 확정 상태의 `requestTranslations`, 커뮤니티 source/materializer 분기.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityRepository.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationSourceExtractor.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationReadModelMaterializer.kt` |
|
||||
| 생성 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/adapter/out/persistence/CreatorCommunityTranslation.kt` |
|
||||
| 생성 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/adapter/out/persistence/CreatorCommunityTranslationRepository.kt` |
|
||||
| 생성 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt` |
|
||||
| 생성 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/CreatorCommunityTranslationServiceTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorkerTest.kt` |
|
||||
| 생성 | `docs/20260910_크리에이터커뮤니티게시물본문번역/schema.sql` |
|
||||
|
||||
- [x] **RED:** 번역 없음/동일 언어/현재 개정 일치/이전 개정/다른 언어 결과의 표시 테스트와 본문 A→B→A 복원 테스트를 작성한다.
|
||||
materializer는 작업 실행 도중 원문 변경·비활성화 시 오래된 번역을 저장/노출하지 않는 테스트로 보호한다.
|
||||
- [x] **RED 확인:** 아래 focused 명령에서 해당 동작의 assertion 실패를 확인한다. 컴파일 오류만으로 RED를 완료하지 않는다.
|
||||
- [x] **GREEN:** 기존 메모리/워커를 연결하고 일괄 읽기, 개정 검사, 현재 메모리 복원을 최소 구현한다.
|
||||
MySQL DDL은 기존 행 NULL/0 보존, locale 유일 키, 모든 컬럼 COMMENT, 테이블 COMMENT를 포함한다.
|
||||
시각은 `TIMESTAMP`, created/updated 기본값은 `docs/agent-guides/문서유지보수.md`를 따른다.
|
||||
- [x] **GREEN 확인:** focused test에서 원문 보존·중복 키·동시 upsert·이전 결과 차단·캐시 복원 통과를 확인한다.
|
||||
- [x] **REFACTOR:** 이번 변경의 중복만 정리하고 focused test와 공통 번역 회귀를 실행한다. 자동 검증 결과와 배포 후 DDL 확인 항목을 이 Task 아래에 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobWorkerTest'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
```
|
||||
|
||||
DDL은 테스트 브랜치 배포 후 격리된 MySQL 테스트 DB에 적용해 기존 행의 언어 NULL/개정 0, 원문 유지, locale 유일 키를 확인한다.
|
||||
운영 DB 적용은 이 Task의 검증으로 실행하지 않는다.
|
||||
|
||||
#### P1-T1 검증 기록 — 2026-09-10
|
||||
|
||||
- RED: `CreatorCommunityTranslationServiceTest.shouldRequireCreatorCommunityTranslationPersistenceContract`를 먼저 작성했다.
|
||||
`./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobWorkerTest'`는
|
||||
exit 1로 실패했고, `CreatorCommunityTranslationServiceTest.kt:13`의 `languageCode` 누락 assertion이 원인이었다.
|
||||
- GREEN/REFACTOR: `CreatorCommunity`의 nullable 언어·0 시작 개정, 현재 개정/해시/언어/활성 상태를 잠금으로 재확인하는
|
||||
materializer, 메모리 우선 복원 후 기존 scheduler를 쓰는 서비스, 일괄 표시 조회와 locale 유일 번역 저장을 추가했다.
|
||||
같은 focused 명령은 exit 0, `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 동시성: H2 MySQL 모드 `@DataJpaTest`에서 두 worker를 같은 원문 메모리 재료화 지점에 동시에 진입시켜,
|
||||
게시물 `PESSIMISTIC_WRITE` 잠금과 locale 유일 키 후 번역 행 1개·오류 0개를 확인했다.
|
||||
- 공통 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.*'` — exit 0, `BUILD SUCCESSFUL`.
|
||||
`./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- DDL: `schema.sql`에 기존 행의 `language_code` NULL 및 `content_revision` 0 기본값, locale 유일 키, 전 컬럼/테이블 COMMENT,
|
||||
BaseEntity 시각 컬럼 규칙을 기록했다. 운영 DB에는 적용하지 않았다.
|
||||
- 전체 회귀/수동 HTTP/Papago/MySQL 적용 검증은 이 Task의 지정 focused/common Gradle 검증 범위를 넘어 실행하지 않았다.
|
||||
|
||||
#### P1-T1 검토 수정 — 2026-09-10
|
||||
|
||||
- 검토 확인 RED: `CreatorCommunityTranslationServiceTest.shouldRejectStaleManagedCreatorCommunityAfterConcurrentCommit`를
|
||||
실제 JPA transaction으로 추가했다. focused 명령은 exit 1, 20개 테스트 중 1개 실패였고,
|
||||
stale managed `CreatorCommunity`로 이전 번역을 저장한 `CreatorCommunityTranslationServiceTest.kt:358` assertion이 원인이었다.
|
||||
- GREEN: `EntityManager.refresh(..., PESSIMISTIC_WRITE)`로 잠긴 게시물의 최신 상태를 다시 읽고,
|
||||
번역 행도 `findByCreatorCommunityIdAndLocaleForUpdate`의 잠금 현재 읽기로 조회하게 했다. provider 호출은 기존처럼
|
||||
materializer transaction 밖의 worker에 남는다. 같은 focused 명령은 exit 0, `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 테스트 보강: 번역 없음·원문 언어 불일치·원문 해시 불일치의 원문 폴백, 실제 JPA stale persistence-context 차단,
|
||||
A→B→A 기존 번역 행 갱신, repeatable-read snapshot 뒤 동시 upsert를 확인했다.
|
||||
- H2 설정: `@AutoConfigureTestDatabase(replace = NONE)`와 `jdbc:h2:mem:creator-community-translation;MODE=MySQL`을
|
||||
사용한다. 이는 H2 호환 모드일 뿐 실제 MySQL DDL·REPEATABLE READ 검증 증거가 아니다.
|
||||
- 기록 정정: 이전 검증 기록의 H2 MySQL 모드 표기는 datasource replacement를 끄기 전에는 성립하지 않았다.
|
||||
현재 H2 설정을 바로잡았지만, 과거 결과를 실제 MySQL 결과로 소급하지 않는다.
|
||||
- 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.*'` — exit 0, `BUILD SUCCESSFUL`.
|
||||
`./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 사용자 검증 정책 변경: local acceptance는 automatic Gradle test와 ktlint으로 한정한다. 위 자동 검증이 통과했으므로
|
||||
Task 1.1과 GREEN 확인/REFACTOR 체크박스를 완료로 표시한다. Docker, 서버, HTTP, MySQL은 local에서 실행하지 않는다.
|
||||
- 배포 후 보류: `schema.sql` 적용, 기존 행 NULL/0, locale 유일 키, 실제 MySQL REPEATABLE READ 동작은
|
||||
테스트 브랜치 배포 후 아래 수동 체크리스트에서만 확인하며 아직 통과로 기록하지 않는다.
|
||||
|
||||
- [x] **Task 1.2: 미확정 원문 언어의 비동기 감지와 목표 언어 전달**
|
||||
|
||||
**Goal ID / objective:** `P1-T2` — 본문 언어가 없으면 현재 본문을 감지하고 요청 범위의 번역으로 이어진다.
|
||||
|
||||
- 시작 조건: `P1-T1` 완료.
|
||||
- 완료 증거: 한국어/영어/일본어 감지, 단일/전체 목표 언어 전달, 지연 감지·실패 테스트 통과.
|
||||
- 범위 밖: 사용자 언어 입력 필드, 다른 리소스의 감지 정책 변경.
|
||||
- 소비/생산: 기존 `LanguageDetectionCacheService.detectWithCache`를 소비하고 커뮤니티 감지 분기와 완성된 `requestTranslations`를 제공한다.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityRepository.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt` |
|
||||
| 생성 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt` |
|
||||
| 확인 | `src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt` |
|
||||
|
||||
- [x] **RED:** 언어 NULL인 본문 감지 후 `targetLanguage=ja`는 일본어만, NULL은 원문 외 두 언어로 이어지는 실패 테스트를 작성한다.
|
||||
빈 본문·미지원 원문 언어·감지 실패·감지 중 본문 수정·중복 이벤트도 포함한다.
|
||||
- [x] **RED 확인:** 아래 focused 명령으로 목표 언어 유실과 오래된 감지 결과 적용의 assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** 기존 감지 이벤트에 기본값이 있는 커뮤니티 메타데이터를 추가하고 개정 조건부 저장·커밋 후 예약을 연결한다.
|
||||
다른 리소스 이벤트와 동작은 유지한다.
|
||||
- [x] **GREEN 확인:** 외부 감지 provider 경계만 통제한 통합 테스트로 실제 커밋 후 이벤트/별도 트랜잭션을 확인한다.
|
||||
테스트를 감싼 트랜잭션이 커밋되지 않아 AFTER_COMMIT이 실행되지 않는 문제를 피한다.
|
||||
- [x] **REFACTOR:** focused test와 기존 감지 캐시/번역 scheduler 회귀 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest'
|
||||
```
|
||||
|
||||
#### P1-T2 검증 기록 — 2026-09-10
|
||||
|
||||
- RED: `CreatorCommunityLanguageDetectTest.shouldKeepLegacyDefaultsAndDeclareCreatorCommunityMetadata`를 먼저 작성했다.
|
||||
`./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'`는 exit 1,
|
||||
`CreatorCommunityLanguageDetectTest.kt:14`의 `CREATOR_COMMUNITY` 누락 assertion으로 실패했다.
|
||||
- 행위 RED: 커밋 후 단일/전체 목표 언어 전달, 빈 본문·미지원 감지값·provider 실패·stale 본문·중복 이벤트를 추가했다.
|
||||
같은 명령은 exit 1, `CreatorCommunityLanguageDetectTest.kt:85`, `:106`, `:179`의
|
||||
`CreatorCommunityTranslationService.requestTranslations(...)` `WantedButNotInvoked` assertion으로 실패했다.
|
||||
- GREEN: `LanguageDetectEvent`의 기본값 있는 커뮤니티 메타데이터, 언어 미확정 본문의 현재 content/revision 이벤트 발행,
|
||||
감지 외부 호출 전 무잠금 상태 확인과 잠금 후 active/revision/content 재확인, 저장 커밋 뒤 기존 번역 서비스 호출을 연결했다.
|
||||
감지 언어가 지원 범위 밖이거나 비어 있으면 원문·작업 상태를 유지한다.
|
||||
- AFTER_COMMIT: `@Transactional(propagation = Propagation.NOT_SUPPORTED)` 테스트와 명시적 `TransactionTemplate` 커밋으로
|
||||
이벤트를 발행했다. 테스트용 감지 provider 경계만 제어하고 실제 행 언어 저장 뒤 `requestTranslations(postId, "ja")` 또는
|
||||
`requestTranslations(postId, null)` 호출을 검증했다.
|
||||
- focused: 위 P1-T2 명령 — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 범위: local automatic 정책에 따라 Docker, HTTP, 실제 Papago, MySQL DDL/동시성은 실행하지 않았다.
|
||||
|
||||
#### Phase 1 Gate
|
||||
|
||||
- [x] **Goal `P1-GATE`: 현재 본문 기반의 감지·번역 저장 경계 판정**
|
||||
|
||||
- 시작 조건: `P1-T1`, `P1-T2` 체크박스와 검증 기록 완료.
|
||||
- 완료 증거: 아래 명령 exit 0, DDL·엔티티 정적 대조, 개정/언어 불일치 미노출 기록.
|
||||
- 범위 밖: API 연결 구현. TDD 예외 사유: 이미 작성한 동작과 DDL의 Gate 검증이며 별도 제품 코드가 아니다.
|
||||
- 대체 검증: 기존 통합 테스트를 실행하고 DB의 현재 개정/번역 개정/언어를 대조한다.
|
||||
- 확인 파일: 두 Task의 source/test 파일 및 `docs/20260910_크리에이터커뮤니티게시물본문번역/schema.sql`.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
```
|
||||
|
||||
#### P1-GATE 검증 기록 — 2026-09-10
|
||||
|
||||
- 자동 Gate: 위 명령 — exit 0, `BUILD SUCCESSFUL`. 현재 개정 번역 저장/메모리 복원/작업 예약과 커밋 후 감지 경계를 함께 회귀했다.
|
||||
- 형식: `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 보류: 사용자 정책에 따라 실제 MySQL DDL·격리 수준, HTTP, Docker, 실제 Papago 검증은 실행하지 않았다.
|
||||
테스트 브랜치 배포 후 수동 체크리스트는 모두 미완료 상태로 유지한다.
|
||||
|
||||
#### Task 1.R: P1-T2 리뷰 회귀 수정
|
||||
|
||||
**Goal ID / objective:** `P1-R1` — 감지 커밋 후 독립 트랜잭션에서 현재 본문만 요청 범위대로 번역 작업으로 예약한다.
|
||||
|
||||
- 시작 조건: `reviews/p1-t2-review.md`의 `REV-P1-T2-001~004` 확정, `P1-T2` 및 `P1-GATE` 기존 기록 확인.
|
||||
- 완료 증거: 실제 Spring 감지 listener와 scheduler를 사용하는 실패 재현 test, 최소 수정 후 focused test와 P1 Gate 재검증, 검증 기록.
|
||||
- 범위 밖: 다른 리소스 감지 정책, 공개 API, Docker·HTTP·실제 Papago·MySQL 수동 검증.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` |
|
||||
| 생성 | `docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/p1-t2-review.md` |
|
||||
|
||||
- [x] **RED:** `REQUIRES_NEW`, 실제 `translation_job` 저장, stale 본문 차단, known-language 다중 target continuation을 실제 Spring 경계로 재현한다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'`에서 7개 중 5개 assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** 감지 listener의 잠금 현재 읽기와 커밋 후 독립 번역 요청 트랜잭션을 최소 수정한다.
|
||||
- [x] **GREEN 확인:** 같은 focused test가 성공하는지 확인한다.
|
||||
- [x] **REFACTOR:** P1-T2 직접 영향 회귀, P1 Gate, ktlint을 실행하고 실제 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
#### P1-R1 진행 기록 — 2026-09-10
|
||||
|
||||
- RED: 새 `CreatorCommunityLanguageDetectTest`는 초기 Kotlin generic 추론과 `AudioContentThemeQueryRepository` test bean 누락을 바로잡은 뒤 실행했다.
|
||||
focused 명령은 exit 1, 7개 중 5개 assertion 실패였다. `REQUIRES_NEW` annotation 부재, 커밋 뒤 job 미저장,
|
||||
stale 감지 결과의 언어 저장, 이미 언어가 있는 두 target 이벤트의 job 미저장을 확인했다.
|
||||
- GREEN: `requestTranslations`를 `REQUIRES_NEW`로 분리하고, listener는 잠금 조회 뒤 `EntityManager.refresh(..., PESSIMISTIC_WRITE)`로
|
||||
현재 본문을 확인한다. 이미 언어가 있는 이벤트도 감지 없이 after-commit에서 원 target을 요청한다.
|
||||
- GREEN 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` — exit 0, `BUILD SUCCESSFUL`.
|
||||
실제 Spring listener와 scheduler를 통해 단일/전체 target job 저장, stale 본문 차단, known-language 두 target continuation을 확인했다.
|
||||
- REFACTOR/Phase Gate 재검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*' ktlintCheck bootJar`
|
||||
— exit 0, `BUILD SUCCESSFUL`. focused/공통 번역 회귀, ktlint, bootJar를 함께 확인했다.
|
||||
- 범위: 사용자 정책에 따라 Docker, HTTP, 실제 Papago, MySQL 수동 동시성 검증은 실행하지 않았다.
|
||||
|
||||
#### Task 1.R3: P1-T2 동일 게시물 작업 예약 직렬화
|
||||
|
||||
**Goal ID / objective:** `P1-R3` — 같은 게시물·target의 동시 `requestTranslations`가 누락 메모리에서도 하나의 translation job만 예약한다.
|
||||
|
||||
- 시작 조건: `reviews/p1-t2-review.md`의 `REV-P1-T2-006` 확정, `P1-R2` 완료 기록 확인.
|
||||
- 완료 증거: 실제 Spring/JPA service와 job repository의 concurrent missing-memory RED test, 최소 lock 수정 후 두 호출 정상 종료·job 1개, P1 Gate 재검증.
|
||||
- 범위 밖: 공통 scheduler 동작, 다른 resource type, 분산 lock·의존성, P2/P3, Docker·HTTP·실제 Papago·MySQL 수동 검증.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/CreatorCommunityTranslationServiceTest.kt` |
|
||||
| 수정 | `docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/p1-t2-review.md` |
|
||||
|
||||
- [x] **RED:** 같은 언어 확정 게시물·target의 두 요청을 동시에 시작해 실제 `translation_job` unique insert 경합을 재현한다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest'`에서 unique-key 오류 또는 정상 완료/job 1개 위반을 확인한다.
|
||||
- [x] **GREEN:** `REQUIRES_NEW` request transaction 시작에 active post write lock과 refresh를 추가한다.
|
||||
- [x] **GREEN 확인:** 같은 focused test가 두 호출 정상 종료와 job 1개를 확인한다.
|
||||
- [x] **REFACTOR:** P1-T2 직접 영향 회귀, P1 Gate, ktlint을 실행하고 실제 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
#### P1-R3 진행 기록 — 2026-09-10
|
||||
|
||||
- 원인: `requestTranslations`가 post lock 없이 memory lookup과 `TranslationJobScheduler`의 select-then-insert를 수행해,
|
||||
동일 post·target의 두 request transaction이 unique key에 경합할 수 있다.
|
||||
- RED: actual JPA `translation_job` 저장을 사용하는 두 `REQUIRES_NEW` transaction의 missing-job 조회 창을 동기화했다.
|
||||
focused 명령은 exit 1이었고, H2 `SQLState 23505`의 `uk_translation_job_resource_field_target_hash` unique index 충돌을 확인했다.
|
||||
- GREEN: `requestTranslations` 시작에서 active post를 `findByIdAndIsActiveTrueForUpdate`로 잠그고
|
||||
`EntityManager.refresh(..., PESSIMISTIC_WRITE)`한 뒤 source extraction, memory lookup, job 조회·저장을 진행한다.
|
||||
- GREEN 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest'`
|
||||
— exit 0, `BUILD SUCCESSFUL`; 두 호출이 정상 종료하고 job 1개만 저장됨을 확인했다.
|
||||
- REFACTOR/Phase Gate 재검증:
|
||||
`./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest'`
|
||||
— exit 0, `BUILD SUCCESSFUL`.
|
||||
`./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'`
|
||||
— exit 0, `BUILD SUCCESSFUL`.
|
||||
`./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 전체 `./gradlew test`는 private scheduling service의 직접 영향 회귀와 P1 Gate가 통과했으므로 실행하지 않았다.
|
||||
Docker, HTTP, 실제 Papago, MySQL 수동 동시성 검증도 사용자 정책에 따라 실행하지 않았다.
|
||||
|
||||
#### Task 1.R2: P1-T2 동시 감지 target 보존
|
||||
|
||||
**Goal ID / objective:** `P1-R2` — 두 언어 미확정 감지가 같은 현재 본문에서 경합해도 각 이벤트의 요청 target을 커밋 후 예약한다.
|
||||
|
||||
- 시작 조건: `reviews/p1-t2-review.md`의 `REV-P1-T2-005` 확정, `P1-R1` 완료 기록 확인.
|
||||
- 완료 증거: 두 감지가 언어 NULL에서 시작한 뒤 한 감지만 먼저 커밋하는 실제 Spring failure test, 최소 수정 후 focused test와 P1 Gate 재검증, 검증 기록.
|
||||
- 범위 밖: 다른 리소스 감지 정책, 공개 API, Docker·HTTP·실제 Papago·MySQL 수동 검증.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` |
|
||||
| 수정 | `docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/p1-t2-review.md` |
|
||||
|
||||
- [x] **RED:** 두 감지가 `languageCode=NULL`에서 시작하고 첫 감지 커밋 뒤 두 번째가 lock을 얻는 실제 `ja`/`en` target 보존 test를 작성한다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'`에서 두 번째 target job 미저장 assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** lock/refresh 뒤 현재 revision/content가 유효하고 언어만 이미 설정된 경우 after-commit에 원 target을 등록한다.
|
||||
- [x] **GREEN 확인:** 같은 focused test가 성공하는지 확인한다.
|
||||
- [x] **REFACTOR:** P1-T2 직접 영향 회귀, P1 Gate, ktlint을 실행하고 실제 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
#### P1-R2 진행 기록 — 2026-09-10
|
||||
|
||||
- 원인: `handleCreatorCommunityLanguageDetect`는 lock과 refresh 뒤 다른 감지가 저장한 languageCode를 발견하면,
|
||||
현재 revision/content가 일치해도 return하여 두 번째 이벤트의 target을 잃는다.
|
||||
- RED: `shouldPreserveSecondTargetAfterConcurrentDetectionSetsLanguage`는 두 감지가 languageCode NULL에서 시작한 뒤,
|
||||
첫 감지의 `ja` 예약·커밋 후 두 번째 감지의 `en` 진행을 순서대로 제어했다. focused 명령은 exit 1, 8개 중 1개 assertion 실패였고,
|
||||
`translation_job`의 `en` 누락이 원인이었다.
|
||||
- GREEN: lock/refresh 뒤 revision/content가 같고 languageCode만 이미 설정되어 있으면 이를 덮어쓰지 않고,
|
||||
기존 `requestTranslationsAfterCommit`에 두 번째 이벤트의 원 target을 등록한다. lock 보유 중 `REQUIRES_NEW` 호출은 하지 않는다.
|
||||
- GREEN 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` — exit 0, `BUILD SUCCESSFUL`.
|
||||
실제 listener/scheduler/repository에서 `ja`와 `en` job 저장 및 source language `ko` 유지를 확인했다.
|
||||
- REFACTOR/Phase Gate 재검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*' ktlintCheck`
|
||||
— exit 0, `BUILD SUCCESSFUL`.
|
||||
- 범위: 사용자 정책에 따라 Docker, HTTP, 실제 Papago, MySQL 수동 동시성 검증은 실행하지 않았다.
|
||||
|
||||
#### Task 1.R4: 최초 언어 감지 캐시 경합에서도 요청 target 보존
|
||||
|
||||
- [x] **Goal `P1-R4`: 동일 본문의 최초 감지 캐시 저장이 경합해도 각 이벤트의 목표 언어 번역을 예약한다**
|
||||
|
||||
- 시작 조건: [Phase 1 리뷰](reviews/phase-1-review.md)의 `REV-P1-007` 확인 및 후속 구현 요청.
|
||||
기존 `P1-R1~R3` 완료 기록을 유지한다.
|
||||
- 관련 요구사항: `CCT-002`, `CCT-005`, `CCT-010`, 기존 `P1-R2`의 요청 target 보존.
|
||||
- 완료 증거: 실제 감지 캐시 INSERT 경합의 실패 회귀 테스트 → 최소 수정 → focused 성공 →
|
||||
P1 감지/번역 및 P2 상세 영향 회귀 → 실행 결과와 리뷰 재판정 기록.
|
||||
- 범위 밖: 외부 감지 중 게시물 행 잠금, 새 분산 잠금/큐/스케줄러, 번역 지원 언어·공개 API 변경,
|
||||
감지 provider 실패를 무조건 성공으로 처리, 다른 리소스의 제품 정책 변경, 운영 DB 작업.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionResultRepository.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionResult.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt` |
|
||||
| 생성 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectionCacheConcurrencyTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt` |
|
||||
| 확인 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` |
|
||||
| 기록 | `docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/phase-1-review.md` |
|
||||
| 기록 | `docs/20260910_크리에이터커뮤니티게시물본문번역/plan-task.md` |
|
||||
|
||||
설계 기준:
|
||||
|
||||
- 캐시 miss → detector → 무조건 INSERT를 같은 키의 원자적 저장으로 바꾸고 저장된 결과로 수렴시켰다.
|
||||
MySQL `ON DUPLICATE KEY UPDATE id=id`와 scalar `SELECT ... FOR UPDATE`를 사용한다. DB 예외를 잡아 무시하지 않는다.
|
||||
- 동일 해시/provider/normalizationVersion의 승자 결과를 사용하며, 반복 읽기 격리에서도 최초 miss 스냅샷에 갇히지 않도록
|
||||
저장 후 current read를 보장한다. 기본 언어를 임의로 지정하거나 감지 결과를 최신 본문에 무조건 적용하지 않는다.
|
||||
- 이미 rollback-only가 된 같은 JPA 트랜잭션에서 예외만 잡고 target 예약을 계속하는 방식은 금지한다.
|
||||
공통 캐시 저장 수정이 다른 리소스의 기존 감지/캐시 재사용 동작을 바꾸지 않도록 회귀 검증한다.
|
||||
- 새 테스트는 실제 `LanguageDetectionCacheService`와 repository를 사용한다. `detectWithCache` 전체를 override하지 않고
|
||||
감지 HTTP 응답/provider 경계만 통제해 두 트랜잭션이 모두 캐시 miss를 읽는 순서를 제어한다.
|
||||
|
||||
- [x] **RED:** `shouldPreserveBothTargetsWhenInitialDetectionCacheWritesRace`를 추가한다.
|
||||
언어 NULL/캐시 없음의 동일 게시물에서 `ja`, `en` 감지를 동시에 시작하고 두 cache miss 이후 첫 저장/커밋,
|
||||
후발 저장 순서로 진행한다. 두 흐름의 정상 완료, 캐시 1행, `ja`/`en` 작업 모두 존재를 assertion으로 검증한다.
|
||||
- [x] **RED 확인:** 아래 focused 명령으로 후발 중복 키 예외 또는 target 누락의 의도한 실패를 확인한다.
|
||||
두 이벤트가 애초에 cache miss에 진입하지 않은 테스트를 경합 재현으로 인정하지 않는다.
|
||||
- [x] **GREEN:** 원자적 캐시 저장과 저장 결과 재사용만 최소 수정한다. 감지 후 기존 개정/본문 검사와
|
||||
커밋 후 요청 target 전달을 유지한다.
|
||||
- [x] **GREEN 확인:** 같은 focused 테스트를 통과시키고, 같은 본문의 서로 다른 게시물 생성도 캐시 1행을 공유하면서
|
||||
각 게시물의 원문 외 두 언어 작업이 보존되는지 검증한다.
|
||||
- [x] **REFACTOR:** 감지 캐시·listener·scheduler·materializer 및 실제 상세 프록시 영향 회귀를 실행한다.
|
||||
선택한 DB 저장 문법의 MySQL 동작과 H2 테스트 지원 범위를 구분하고 실제 MySQL 미검증은 기존 수동 체크리스트에 남긴다.
|
||||
관련 없는 변경 없이 아래 명령 결과와 `REV-P1-007` 수정 판정을 누적한다.
|
||||
|
||||
실행한 focused/영향 회귀 명령이다. focused의 fresh 재실행에는 `--rerun-tasks --no-parallel`을 추가했다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectionCacheConcurrencyTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*'
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
공통 캐시 동작 변경의 영향이 위 회귀로 판정되지 않으면 전체 테스트로 확장한다. 전체 테스트 생략 시 대체 명령과 근거를 기록한다.
|
||||
현재 상태: RED/GREEN·영향 회귀·수정 후 리뷰 완료. 실제 MySQL 검증은 미완료다.
|
||||
|
||||
#### P1-R4 실행 기록 · 2026-09-10
|
||||
|
||||
- RED: 위 focused 명령은 두 provider 진입을 모두 확인한 뒤 exit 1로 실패했다.
|
||||
`ExecutionException → DataIntegrityViolationException → H2 23505`로 감지 캐시 유일 키 충돌을 재현했다.
|
||||
- GREEN: native MySQL `ON DUPLICATE KEY UPDATE id=id`로 승자 행을 보존하고 scalar `SELECT ... FOR UPDATE`로
|
||||
저장 결과를 현재 읽기한다. native INSERT의 audit 시각을 명시하며 DB 예외를 잡고 계속 진행하지 않는다.
|
||||
- GREEN 확인: 실제 캐시 service/repository를 사용해 같은 게시물의 `ja`/`en` 이중 miss 및
|
||||
서로 다른 게시물의 동일 본문 경합을 검증했다. 캐시 1행으로 수렴하며 각 요청의 목표 언어 작업을 보존한다.
|
||||
- focused fresh 재실행: 위 첫 명령 + `--rerun-tasks --no-parallel`, `BUILD SUCCESSFUL` (5분 19초).
|
||||
- P1 listener/scheduler/materializer 회귀: 위 두 번째 명령, `BUILD SUCCESSFUL` (1분 09초).
|
||||
- P2 상세/API 회귀: 위 세 번째 명령, `BUILD SUCCESSFUL` (1분 42초).
|
||||
- 형식: `./gradlew ktlintCheck`, `BUILD SUCCESSFUL` (44초).
|
||||
- 수정 후 리뷰: spec `PASS` (`ses_f7657d350ffe3YUYhEHvHPuikK`), quality `APPROVED`
|
||||
(`ses_f7656994dffeR2odTLswaw6RTo`). `REV-P1-007`은 로컬 자동 검증 범위에서 수정 완료로 재판정했다.
|
||||
- 전체 `./gradlew test`는 생략했다. 공통 캐시 계약, 실제 repository 경합, 모든 직접 listener/scheduler/materializer
|
||||
테스트와 P2 상세/API 소비자가 위 대체 명령으로 통과했고, 미해결 상태로 남은 별도의 공통 경계가 없었다.
|
||||
- 범위: H2 `MODE=MySQL`은 실제 MySQL 8/InnoDB 검증이 아니다. 실제 MySQL/Papago/HTTP, Docker, 배포는
|
||||
실행하지 않았다. 상세 명령·리뷰 출처는 [후속 증거](reviews/review-evidence.md)에 누적한다.
|
||||
|
||||
### Phase 2: 작성·수정·상세에서 번역 처리
|
||||
|
||||
#### 구현 항목
|
||||
|
||||
- [x] **Task 2.1: 작성 커밋과 실제 본문 변경에 감지·번역 연결**
|
||||
|
||||
**Goal ID / objective:** `P2-T1` — 생성과 본문 수정이 현재 원문 기준의 전체 목표 언어 번역으로 이어진다.
|
||||
|
||||
- 시작 조건: `P1-GATE` 완료, `CCT-003~004`, `CCT-007` 확인.
|
||||
- 완료 증거: 신규·실제 변경·동일 본문·롤백·관리자 공용 경로·동시 수정 검증.
|
||||
- 범위 밖: 이미지·오디오 업로드, 알림 발행, 소유권 정책 변경.
|
||||
- 소비 인터페이스: `requestTranslations(postId, targetLanguage = null)`.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityRepository.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt` |
|
||||
| 확인/테스트 보강 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt` |
|
||||
| 확인/테스트 보강 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostUpdateTest.kt` |
|
||||
|
||||
- [x] **RED:** 생성 커밋 후 감지, 본문 변경 때만 언어 NULL/개정 증가, 롤백 시 작업 없음의 실패 테스트를 작성한다.
|
||||
수정 후 새 요청에서 이전 번역 미노출, 실제 언어 변경, 관리자 공용 경로도 포함한다.
|
||||
- [x] **RED 확인:** 아래 focused 명령으로 번역 연결 부재/개정 미변경의 assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** 기존 커밋 후 실행 패턴으로 공유 서비스를 연결하고 본문 문자열이 다른 경우에만 언어·개정 상태를 갱신한다.
|
||||
소유권 조건을 유지한 잠금 조회로 동시 본문 수정의 개정을 직렬화한다.
|
||||
- [x] **GREEN 확인:** 테스트를 다시 실행하고 동일 본문/본문 외 변경은 감지·번역 요청 0건인지 확인한다.
|
||||
- [x] **REFACTOR:** 공용 서비스 호출자 회귀를 확인한다. 관리자 facade의 중복 번역 연결은 추가하지 않는다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostCreateTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostUpdateTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostConcurrencyTest'
|
||||
```
|
||||
|
||||
#### P2-T1 검증 기록 — 2026-09-10
|
||||
|
||||
- RED: focused 명령은 exit 1이었다. 생성 뒤 번역 요청 누락, 본문 변경 뒤 개정/언어 상태 미갱신의 assertion 실패를
|
||||
`CreatorCommunityServiceTest.kt:333`, `AiCharacterAdminCommunityPostCreateTest.kt:93`,
|
||||
`AiCharacterAdminCommunityPostUpdateTest.kt:91`에서 확인했다.
|
||||
- GREEN: `CreatorCommunityService`는 생성과 실제 본문 변경 뒤 기존 after-commit 패턴으로
|
||||
`requestTranslations(postId, null)`을 요청한다. 본문 변경은 `languageCode`를 NULL로 초기화하고
|
||||
`contentRevision`을 증가시키며, 수정 조회는 소유권 조건의 `PESSIMISTIC_WRITE` 잠금 후 refresh한다.
|
||||
- 동시성: 실제 H2/JPA 비관적 잠금 조회를 latch로 제어한 관리자 MockMvc 요청 두 건이 모두 200으로 종료하고,
|
||||
최종 revision 2와 번역 요청 2건을 확인했다.
|
||||
- focused: 위 명령과 `LegacyCommunityPostCharacterizationTest`를 포함한 실행 — exit 0, `BUILD SUCCESSFUL`.
|
||||
생성·수정·동일 본문·롤백·관리자 공용 경로·동시 수정과 변경된 수동 생성자 호출을 함께 확인했다.
|
||||
- P1 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'`
|
||||
— exit 0, `BUILD SUCCESSFUL`.
|
||||
- 형식: `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 범위: local acceptance 정책에 따라 Docker, 서버 HTTP, 실제 Papago, MySQL 수동 동시성 검증은 실행하지 않았다.
|
||||
|
||||
#### Task 2.R: P2-T1 spec-review 실제 트랜잭션 증거
|
||||
|
||||
**Goal ID / objective:** `P2-R1` — 실제 Spring transaction에서 생성 롤백과 본문 변경 뒤 stale 번역 폴백·감지 예약을 증명한다.
|
||||
|
||||
- 시작 조건: `P2-T1` 완료, spec-review의 실제 transaction 증거 보강 요구 확인.
|
||||
- 완료 증거: proxied `CreatorCommunityService` 생성 롤백 뒤 post/job/detection 미영속과, 한글→영문 수정 커밋 뒤
|
||||
revision·stale 번역 폴백·영문 감지·새 target job을 별도 transaction에서 확인한다.
|
||||
- 범위 밖: P2-T2 상세/목록 API, production 리팩터링, Docker·HTTP·실제 Papago/MySQL 검증.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` |
|
||||
| 수정 | `docs/20260910_크리에이터커뮤니티게시물본문번역/plan-task.md` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` |
|
||||
|
||||
- [x] **TEST-FIRST:** 실제 outer transaction rollback 뒤 생성 post와 감지/번역 작업이 남지 않는 test를 작성한다.
|
||||
기존 한글 translation 행이 있는 post의 실제 영문 수정 뒤, 감지 전 stale 번역 폴백과 감지 후 언어·target job을 test한다.
|
||||
- [x] **TEST-FIRST 확인:** 첫 실행에서 async 감지 완료 전 `languageCode`를 읽은 assertion 실패를 확인하고, 기존 `await` 방식의 완료 조건으로 test 경계를 바로잡는다.
|
||||
- [x] **GREEN:** test가 production 결함을 드러내면 실패 test를 유지한 최소 수정만 적용하고, 아니면 실제 경계 증거만 보강한다.
|
||||
- [x] **GREEN 확인:** 실제 post-commit 경로가 완료될 때까지 latch/새 transaction으로 대기한 assertion을 통과시킨다.
|
||||
- [x] **REFACTOR:** P2 focused, P1 번역/감지 회귀, ktlint 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostCreateTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostUpdateTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostConcurrencyTest'
|
||||
```
|
||||
|
||||
#### P2-R1 검증 기록 — 2026-09-10
|
||||
|
||||
- test-first: `CreatorCommunityLanguageDetectTest`에 실제 `CreatorCommunityService` bean을 등록하고 AOP proxy를 확인했다.
|
||||
JPA repository, listener, translation service, scheduler는 실제 bean을 사용하며 S3·FCM 연관 의존성과 external detector만 test 경계로 제어했다.
|
||||
- 생성 rollback: 실제 outer `TransactionTemplate`에서 `createCommunityPost`를 호출하고 rollback-only로 종료했다.
|
||||
새 transaction에서 생성 post 부재, 해당 post ID의 translation read-model 및 `translation_job` 0건, detector query 0건을 확인했다.
|
||||
- 영문 수정: 기존 한글 원문과 유효한 이전 영어 translation 행을 만들고 실제 `modifyCommunityPost`를 commit했다.
|
||||
차단된 detector가 시작된 뒤 새 transaction에서 `languageCode=NULL`, revision 1, 현재 영문 원문 반환과 이전 영어 translation 행의
|
||||
미사용을 확인했다. detector 해제 뒤 `languageCode=en`, `ko`·`ja` job 예약을 대기해 확인했다.
|
||||
- 첫 실행: rollback test는 통과했고 수정 test는 async `@TransactionalEventListener` 완료 전 state를 읽어 `expected en but was null`로 실패했다.
|
||||
이는 production 결함이 아니라 outer service future가 async 감지 완료를 기다리지 않는 test synchronization 문제였으며,
|
||||
기존 조건 기반 `await`로 post-commit 완료를 기다리도록 바로잡았다. production 파일은 변경하지 않았다.
|
||||
- focused: 위 P2-R1 명령에 `--rerun-tasks`를 추가해 재실행 — exit 0, `BUILD SUCCESSFUL`.
|
||||
- P1 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'`
|
||||
에 `--rerun-tasks`를 추가해 재실행 — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 형식: `./gradlew ktlintCheck --rerun-tasks` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 범위: Docker, 서버 HTTP, detail/list API, 실제 Papago/MySQL 검증은 실행하거나 변경하지 않았다.
|
||||
|
||||
#### Task 2.R: P2-T1 quality-review 고정 상태 경쟁 회귀
|
||||
|
||||
**Goal ID / objective:** `P2-R2` — 관리자/레거시 고정 상태 변경이 최신 본문·개정·언어 상태를 덮어쓰지 않는다.
|
||||
|
||||
- 시작 조건: P2-T1 완료, quality-review의 stale flush 및 test pseudo-lock 지적 확정.
|
||||
- 완료 증거: stale admin read 뒤 동시 본문 변경을 commit하고 고정+본문 요청을 재개하는 실제 JPA race test에서
|
||||
최신 본문·revision·language와 fixed 상태를 확인한다. test는 production 소유권 조건 `PESSIMISTIC_WRITE` query를 사용한다.
|
||||
- 범위 밖: P2-T2/P3, API schema, `@DynamicUpdate`/`@Version`, dependency, Docker·HTTP·실제 MySQL 검증.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostConcurrencyTest.kt` |
|
||||
| 수정 | `docs/20260910_크리에이터커뮤니티게시물본문번역/plan-task.md` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityRepository.kt` |
|
||||
|
||||
- [x] **RED:** stale admin read 뒤 별도 transaction의 본문/개정/언어 변경을 commit하고, 재개한 fixed+content 요청이 최신 상태에서 적용돼야 한다는 실제 JPA race test를 작성한다.
|
||||
- [x] **RED 확인:** 현재 구현으로 최신 state 보존 assertion이 실패하는지 확인한다.
|
||||
- [x] **GREEN:** `updateCommunityPostFixed`가 소유권 조건 post lock과 refresh를 통해 최신 행을 얻은 뒤에만 고정 상태를 변경하도록 최소 수정한다.
|
||||
관리자 흐름의 member→post lock 순서는 유지한다.
|
||||
- [x] **GREEN 확인:** production repository lock query를 사용하는 test에서 두 번째 요청의 pre-lock/lock 도착을 latch로 제어하고 executor 종료까지 확인한다.
|
||||
- [x] **REFACTOR:** P2 focused, P1 번역/감지 회귀, ktlint, diff check 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostCreateTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostUpdateTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostConcurrencyTest'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
#### P2-R2 검증 기록 — 2026-09-10
|
||||
|
||||
- RED: `shouldApplyFixedAndContentUpdateAfterConcurrentContentCommit`는 admin facade의 활성 post read 뒤 첫 요청을 멈추고,
|
||||
별도 worker의 실제 `CreatorCommunityService.modifyCommunityPost`가 production `findByIdAndMemberIdForUpdate` 경로로
|
||||
`after concurrent` 본문·revision 1·language NULL을 commit한 뒤 mixed `isFixed=true`/`after fixed` 요청을 재개했다.
|
||||
현재 구현에서 `contentRevision` 2 assertion은 실제 1로 실패했다.
|
||||
- GREEN: `updateCommunityPostFixed`의 비잠금 조회를 소유권 조건 `findByIdAndMemberIdForUpdate`와
|
||||
`EntityManager.refresh(..., PESSIMISTIC_WRITE)`로 교체했다. facade의 기존 member lock 뒤 post lock 순서는 바꾸지 않았다.
|
||||
- 동시성 test: 기존 본문 race의 test-only JPQL lock 대체를 제거하고 두 worker start gate와 실제 production repository query를 사용한다.
|
||||
새 race는 concurrent content worker가 실제 lock 호출 직전임을 latch로 알리고 commit 완료 뒤 mixed request를 재개한다.
|
||||
모든 executor는 release 뒤 `shutdown`과 `awaitTermination`으로 종료를 확인한다.
|
||||
- focused: P2 명령에 `--rerun-tasks`를 추가해 재실행 — 67 tests, exit 0, `BUILD SUCCESSFUL`.
|
||||
- P1 회귀: P1 명령에 `--rerun-tasks`를 추가해 재실행 — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 형식: `./gradlew ktlintCheck --rerun-tasks` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- diff: `GIT_MASTER=1 git diff --check` — exit 0.
|
||||
- 범위: P2-T2/P3, API schema, `@DynamicUpdate`/`@Version`, dependency, Docker, HTTP, 실제 MySQL 검증은 변경하거나 실행하지 않았다.
|
||||
|
||||
- [x] **Task 2.2: 레거시·v2 상세의 저장 번역 표시와 누락 예약**
|
||||
|
||||
**Goal ID / objective:** `P2-T2` — 상세에서 원문을 즉시 제공하고 요청 언어의 누락 번역만 예약한다.
|
||||
|
||||
- 시작 조건: `P2-T1` 완료, PRD §5 상세 계약 확인.
|
||||
- 완료 증거: 두 상세 API의 기존 응답 구조·언어 처리·권한·별도 쓰기 트랜잭션·비동기 폴백 테스트 통과.
|
||||
- 범위 밖: 목록 연결, 댓글 API에서 번역 예약, 번역 상태/원문 필드 추가.
|
||||
- 소비 인터페이스: 권한 확인 후 `findDisplayContents`를 조회하고, 유효 번역이 없을 때만
|
||||
`requestTranslations(postId, langContext.lang.code)`를 호출한다.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/SelectCommunityPostResponse.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicy.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt` |
|
||||
| 생성 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityTranslationEndToEndTest.kt` |
|
||||
|
||||
- [x] **RED:** `Accept-Language: ja`에서 기존 언어 미확정 글의 최초 상세는 원문, 감지 후 일본어만 예약,
|
||||
완료 후 일본어 제공을 테스트한다. 같은 언어·반복 상세·차단·비활성·미구매·댓글 API도 포함한다.
|
||||
- [x] **RED 확인:** 아래 focused 명령에서 저장 번역 미사용/누락 예약 부재를 확인한다.
|
||||
- [x] **GREEN:** 기존 권한 확인 뒤 상세에서 유효 번역이 없을 때만 공유 쓰기 진입점을 호출한다. 읽기 전용 부모 트랜잭션과 분리하고
|
||||
번역 선택은 기존 마스킹 전에 DTO/레코드 `copy(content = ...)`로 반영한다. 원문 엔티티는 조회 중 변경하지 않는다.
|
||||
- [x] **GREEN 확인:** 실제 HTTP 계약 테스트와 중복 상세 경합에서 정상 응답·중복 작업 방지·원문 즉시 응답을 확인한다.
|
||||
- [x] **REFACTOR:** 같은 내부 조회를 사용하는 댓글/답글이 예약 진입점을 호출하지 않는지 회귀 검사한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityTranslationEndToEndTest' --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest'
|
||||
```
|
||||
|
||||
#### P2-T2 검증 기록 — 2026-09-10
|
||||
|
||||
- RED: 상세 경로에 번역 선택과 예약 연결이 없던 상태에서 legacy/v2 focused test의 저장 번역 미사용과 누락 예약 assertion 실패를 확인했다.
|
||||
컴파일 오류는 RED 증거로 사용하지 않았으며, 새 통합 test의 `viewerId` named argument를 실제 `memberId` 계약에 맞춰 바로잡았다.
|
||||
- GREEN: `CreatorCommunityService`와 `CreatorChannelCommunityQueryService`는 권한/차단 확인 뒤
|
||||
`findDisplayContents(...)[postId] ?: content`를 선택하고 `requestTranslations(postId, 요청 언어)`를 호출한다.
|
||||
선택한 본문은 기존 유료 마스킹 전 DTO/record `copy(content = ...)`에만 반영하며, 엔티티 원문은 변경하지 않는다.
|
||||
- 통합: `CreatorCommunityTranslationEndToEndTest`는 저장된 번역의 유료 마스킹 전 선택과, 언어 NULL 원문의 즉시 폴백 및
|
||||
`targetLanguage=ja` 단일 감지 이벤트를 실제 JPA 경계에서 확인했다.
|
||||
- HTTP 계약: legacy/v2 detail test는 번역 존재/누락, 구매자·소유자의 전체 본문, 미구매자의 기존 마스킹,
|
||||
차단·비활성 경로, 댓글·답글의 예약 비진입을 확인했다. 같은 언어 예약 없음과 동시 예약의 유일성은 기존
|
||||
`CreatorCommunityTranslationServiceTest`/통합 test로 회귀했다.
|
||||
- focused: 위 명령 — exit 0, `BUILD SUCCESSFUL`.
|
||||
- P1 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceIntegrationTest'`
|
||||
— exit 0, `BUILD SUCCESSFUL`.
|
||||
- 호출자 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest'`
|
||||
— exit 0, `BUILD SUCCESSFUL`.
|
||||
|
||||
#### Task 2.R: P2-T2 실제 상세 파이프라인 증거 보강
|
||||
|
||||
**Goal ID / objective:** `P2-R3` — 실제 Spring 프록시 상세 조회가 감지, 단일 작업 예약, 재료화와 유효 번역 재사용을 올바르게 연결한다.
|
||||
|
||||
- 시작 조건: `P2-T2` 완료, 상세 경로의 실제 transaction 증거 보강 요구 확인.
|
||||
- 완료 증거: 원문 즉시 반환, 감지 후 요청 언어 하나의 job, 반복·동시 상세의 단일 job, memory 재료화 후 번역 반환,
|
||||
유효 번역 상세의 예약 미호출을 확인한다.
|
||||
- 범위 밖: 목록/홈, 외부 감지·번역 API, Docker, live HTTP, 실제 MySQL 검증.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` |
|
||||
| 확인 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` |
|
||||
| 확인 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt` |
|
||||
|
||||
- [x] **RED:** 병렬 상세 응답 본문 검증은 `ExecutorService.submit { ... }`가 `Runnable` 오버로드를 선택해
|
||||
`Future.get()`이 `null`을 반환하며 실패하는 것을 확인한다. 이는 production 동작이 아닌 수용 테스트의 반환값 폐기 문제다.
|
||||
- [x] **GREEN:** `Callable`을 명시해 두 병렬 상세의 실제 원문 반환과 translation job 1개를 검증한다.
|
||||
- [x] **GREEN 확인:** 실제 `CreatorCommunityService`, `LanguageDetectListener`, `ResourceTranslationJobScheduler`,
|
||||
`TranslationReadModelMaterializer` 경로에서 원문 즉시 반환, `ko` job 하나, memory 재료화 후 번역 반환을 확인한다.
|
||||
legacy/v2 unit test는 유효 번역의 문자열이 원문과 같아도 `requestTranslations`를 호출하지 않음을 확인한다.
|
||||
- [x] **REFACTOR:** P2 focused, P1 번역/감지 회귀, Phase 2 Gate를 실행한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityTranslationEndToEndTest' --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceIntegrationTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*'
|
||||
```
|
||||
|
||||
#### P2-R3 검증 기록 — 2026-09-10
|
||||
|
||||
- RED: 병렬 lambda의 반환값을 확인하는 assertion은 `expected: [detail source content, detail source content] but was: [null, null]`로 실패했다.
|
||||
`submit`의 `Runnable` 오버로드가 반환값을 폐기한 것이 원인이었고, production 상세 응답이나 DB 본문 손실은 아니었다.
|
||||
- GREEN: `Callable` 명시 뒤 실제 두 상세 응답은 원문을 반환하고, 감지 완료 후 `ko` translation job은 하나만 유지됐다.
|
||||
수동으로 저장한 translation memory를 실제 materializer가 적용한 뒤 상세는 번역문을 반환했다.
|
||||
- 유효 저장 번역: legacy/v2 상세 unit test는 `CreatorCommunityDisplayContent.hasValidTranslation`으로 판정하므로,
|
||||
번역문이 원문과 같아도 `requestTranslations`를 호출하지 않는다. 실제 상세 파이프라인 test는 재료화 뒤 번역문을 반환하고
|
||||
job 수가 증가하지 않음을 확인했다.
|
||||
- focused: 첫 번째 명령 — exit 0, `BUILD SUCCESSFUL` (41초).
|
||||
- P1 회귀: 두 번째 명령 — exit 0, `BUILD SUCCESSFUL` (26초).
|
||||
- Phase 2 Gate: 세 번째 명령 — exit 0, `BUILD SUCCESSFUL` (1분 30초).
|
||||
- latch RED: `CreatorCommunityLanguageDetectTest.shouldNotCompleteNextTaskWhenPreviousTaskFinishesAfterLatchIsReassigned`에서 A를 멈춘 뒤 B가 다음 task의 latch를 준비하게 했다.
|
||||
`./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest.shouldNotCompleteNextTaskWhenPreviousTaskFinishesAfterLatchIsReassigned'`는
|
||||
exit 1, `expected: <false> but was: <true>`로 실패했다. A 종료가 `completion.get()`으로 B의 latch를 감소시킨 것이 원인이었다.
|
||||
- latch GREEN: decorator 생성 시점의 `taskCompletion`을 캡처해 해당 task 종료에만 사용하도록 했고, latch regression과 상세 파이프라인 detector는 `finally`에서 release하도록 정리했다.
|
||||
`./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` — exit 0, `BUILD SUCCESSFUL` (20초).
|
||||
- P2 재회귀: 첫 번째 명령 — exit 0, `BUILD SUCCESSFUL` (44초).
|
||||
- 형식: `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL` (17초).
|
||||
|
||||
#### Phase 2 Gate
|
||||
|
||||
- [x] **Goal `P2-GATE`: 생성·수정·기존 글 상세 흐름 판정**
|
||||
|
||||
- 시작 조건: Phase 2 두 Task 완료.
|
||||
- 완료 증거: 생성→커밋→감지→번역 저장과 기존 글 상세→요청 언어만 예약의 실제 통합 결과.
|
||||
- 범위 밖: 목록/홈 구현. TDD 예외 사유: 앞선 Task의 사용자 흐름 검증 Gate다.
|
||||
- 대체 검증: 테스트 HTTP 요청과 실제 커밋 후 DB 상태를 대조하고 롤백 케이스에 작업이 없는지 확인한다.
|
||||
- 확인 파일: Phase 2의 두 EndToEndTest, 서비스 테스트, PRD §4~5.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*'
|
||||
```
|
||||
|
||||
#### P2-GATE 검증 기록 — 2026-09-10
|
||||
|
||||
- gate: 위 명령 — exit 0, `BUILD SUCCESSFUL`. 생성·수정·기존 글 상세, 레거시 서비스/JPA 및 v2 MockMvc 계약, 관리자 공용 경로를 함께 확인했다.
|
||||
- 형식: `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- diff: `GIT_MASTER=1 git diff --check` — exit 0.
|
||||
- 범위: local automatic 정책에 따라 Docker, live server/curl, 실제 Papago, 실제 MySQL DDL·동시성은 실행하지 않았다.
|
||||
HTTP 동작은 자동 MockMvc 통합 테스트로만 검증했다.
|
||||
|
||||
### Phase 3: 목록·홈·미리보기 번역과 최종 검증
|
||||
|
||||
#### 구현 항목
|
||||
|
||||
- [x] **Task 3.1: 레거시 목록과 v2 커뮤니티·채널 홈에 저장 번역 적용**
|
||||
|
||||
**Goal ID / objective:** `P3-T1` — 커뮤니티 목록과 채널 홈이 작업 예약 없이 저장된 번역을 제공한다.
|
||||
|
||||
- 시작 조건: `P2-GATE` 완료.
|
||||
- 완료 증거: 언어별 목록·최신 목록·고정/일반 채널 홈, 유료 마스킹, 추가 N+1 없음, 감지/작업 증가 0건.
|
||||
- 범위 밖: 추천/팔로잉 소식, 정렬·페이지·고정 정책 변경.
|
||||
- 소비 인터페이스: `findDisplayContents`만 호출. 레거시 DTO와 v2 레코드를 표시 직전에 복사해 기존 마스킹을 재사용한다.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityTranslationEndToEndTest.kt` |
|
||||
|
||||
- [x] **RED:** 번역 존재/누락·이전 개정 혼합 페이지와 무료·유료·소유자·구매자 응답을 테스트한다.
|
||||
이모지 포함 유료 본문에서 기존 코드포인트 기준 미리보기를 확인한다.
|
||||
- [x] **RED 확인:** 아래 명령으로 번역 미사용 assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** 페이지별 번역 일괄 조회 후 기존 마스킹 전에 반영한다. 채널 홈은 이미 호출하는
|
||||
`findHomeCommunityPosts` 공통 경로를 통해 고정/일반 게시물을 모두 처리한다.
|
||||
- [x] **GREEN 확인:** 목록만 호출했을 때 감지 이벤트/번역 작업 생성 0건, 페이지·개수·순서 유지,
|
||||
페이지 크기에 비례한 추가 번역 SELECT 증가 없음까지 확인한다.
|
||||
- [x] **REFACTOR:** 기존 권한·홈 응답 회귀와 focused test 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest'
|
||||
```
|
||||
|
||||
##### 실행·검증 기록 — 2026-09-10
|
||||
|
||||
- 무엇을: 레거시 목록·최신 목록, v2 커뮤니티 탭, 채널 홈의 고정/일반 게시물에 저장된 표시 번역을 한 번에 조회해 본문에 복사했다.
|
||||
상세 경로의 작업 예약 정책은 변경하지 않았고, 목록/홈에서는 `findDisplayContents`만 호출한다.
|
||||
- 왜: 목록 응답은 번역 누락 시 원문으로 안전하게 폴백하면서도 감지·번역 작업을 만들지 않아야 한다.
|
||||
- RED 확인: 새 테스트 6개를 대상으로 `./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest.shouldUseStoredTranslationsOnceForLegacyCommunityListBeforePaidMasking' --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest.shouldUseStoredTranslationsForVisibleLatestCommunityPostsWithoutScheduling' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest.shouldUseStoredTranslationsOnceForCommunityTabBeforePaidMasking' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest.shouldUseStoredTranslationsForPinnedAndNormalHomeCommunityPostsWithoutScheduling' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest.shouldUseStoredTranslationsForCommunityTabWithoutScheduling' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest.shouldUseStoredTranslationsForPinnedAndNormalHomeCommunityPosts'`를 실행했다.
|
||||
exit 1의 기대된 assertion 실패로 번역 적용 전 원문이 반환됨을 확인했다.
|
||||
- GREEN/영향 범위 회귀: 위 focused 명령 — exit 0, `BUILD SUCCESSFUL` (1분 3초). 저장 번역·원문 폴백·이전 개정 무시,
|
||||
유료 미구매자/구매자/소유자 마스킹, 차단 필터 후 일괄 조회, 목록/홈 호출의 감지·번역 작업 미생성을 단위·MockMvc 통합 테스트로 확인했다.
|
||||
- 형식: `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL` (45초).
|
||||
- 전체 회귀와 수동 HTTP: 공통 번역 API 또는 모든 Phase를 변경하지 않았으므로 직접 영향받은 package/API 회귀까지만 실행했다.
|
||||
운영과 분리된 DB·Redis·S3·Papago 및 테스트 계정이 준비되지 않아 live server/curl·실제 Papago 검증은 실행하지 않았고,
|
||||
HTTP 응답은 MockMvc 통합 테스트로 검증했다.
|
||||
- 추가 실제 통합 증거: `CreatorCommunityTranslationEndToEndTest`는 실제 `CreatorCommunityTranslationService`와 H2 저장소를 사용한다.
|
||||
영속성 컨텍스트와 Hibernate 통계를 매 배치 전 초기화한 뒤 1개와 4개 ID를 각각 읽어, 게시글 `IN` 조회 1회와
|
||||
`creator_community_translation` 조회 1회의 합계인 prepared statement 2회를 동일하게 확인했다.
|
||||
따라서 페이지 크기는 번역 읽기 SELECT 수를 늘리지 않는다.
|
||||
- 추가 부작용 증거: 같은 실제 읽기 서비스를 레거시 목록의 번역 누락 게시물에 연결하고, 실제 `translation_job` 행 수의 전후 동일성과
|
||||
`ApplicationEventPublisher`·재료화기·resource scheduler의 무상호작용을 확인했다. 읽기 서비스 자체는 mock으로 대체하지 않았다.
|
||||
v2 탭의 mock 경계 테스트는 별도로 반환 50개 ID만 전달하고 51번째 `fetchLimit + 1` ID를 제외함을 확인한다.
|
||||
- TDD 예외: 이번 추가는 이미 올바른 production 동작을 직접 측정하는 증거 테스트뿐이므로 RED assertion을 만들 수 없었다.
|
||||
새 테스트는 즉시 통과했으며 production P3-T1 파일은 변경하지 않았다.
|
||||
- 재검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest'` — exit 0, `BUILD SUCCESSFUL` (1분 18초).
|
||||
`./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL` (17초).
|
||||
|
||||
- [x] **Task 3.2: 홈 추천과 팔로잉 소식 본문에 저장 번역 적용**
|
||||
|
||||
**Goal ID / objective:** `P3-T2` — 홈 추천·팔로잉 소식의 커뮤니티 본문과 파생 미리보기가 요청 언어로 표시된다.
|
||||
|
||||
- 시작 조건: `P3-T1` 완료.
|
||||
- 완료 증거: 두 API의 번역/원문 폴백·일괄 조회·작업 미생성·다른 소식 유형 불변 테스트.
|
||||
- 범위 밖: 추천 스냅샷/순위 변경, FCM·알림 이력·오디오 소식 번역.
|
||||
- 소비 인터페이스: `LangContext`의 언어와 `findDisplayContents`. 추천 상세 레코드/팔로잉 도메인의 커뮤니티 항목만 복사한다.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt` |
|
||||
|
||||
- [x] **RED:** 일본어·영어 요청의 인기 커뮤니티/팔로잉 소식, 누락·이전 개정 폴백,
|
||||
비커뮤니티 항목 유지, 조회만으로 작업이 늘지 않는 테스트를 작성한다.
|
||||
- [x] **RED 확인:** 아래 focused 명령으로 해당 응답이 원문에 머무르는 assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** 이미 필터링된 게시물 ID를 모아 공유 읽기 메서드로 처리한다. 팔로잉 서비스에 기존 `LangContext`를 전달한다.
|
||||
본문은 DB 게시물의 현재 내용에서 가져오므로 과거 소식 발행 문자열을 번역 소스로 쓰지 않는다.
|
||||
- [x] **GREEN 확인:** 언어 전환·익명 허용 추천·인증된 팔로잉 흐름을 검증하고 기존 필터/추천 순서를 대조한다.
|
||||
- [x] **REFACTOR:** 변경된 생성자 호출 테스트를 갱신하고 두 기능의 직접 영향 회귀 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest' --tests 'kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest' --tests 'kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest'
|
||||
```
|
||||
|
||||
##### 실행·검증 기록 — 2026-09-10
|
||||
|
||||
- 무엇을: 인기 커뮤니티 추천의 최종 노출 게시물과 팔로잉 최근 소식의 커뮤니티 게시물만 요청 언어의 저장 본문으로 복사했다.
|
||||
추천 중복 제거·limit와 팔로잉 필터·순서·다른 소식 유형은 변경하지 않았다.
|
||||
- RED 확인: 서비스·API 테스트 4개가 번역 연결 전 원문 본문 assertion으로 실패함을 확인했다.
|
||||
- GREEN/회귀: 관련 5개 테스트 클래스에 `--rerun-tasks`를 적용해 exit 0, `BUILD SUCCESSFUL` (2분 38초)을 확인했다.
|
||||
일본어 비회원 추천, 영어 인증 팔로잉, 현재 원문 폴백, 최종 ID 단일 배치, 비커뮤니티 불변을 검증했다.
|
||||
- 리뷰: 명세 리뷰 `APPROVED` (`ses_f7757315dffeoDl3Ef2bhCO684`), 품질 리뷰 `APPROVED` (`ses_f774b8bf4ffehbjDvQWo6yE84S`).
|
||||
- 형식/diff: `./gradlew ktlintCheck --rerun-tasks` — exit 0, `BUILD SUCCESSFUL` (26초).
|
||||
`GIT_MASTER=1 git diff --check` — exit 0.
|
||||
- 범위: Docker, live HTTP, 실제 Papago/MySQL은 실행하지 않았으며 테스트 브랜치 배포 후 수동 체크리스트에서 확인한다.
|
||||
|
||||
#### Task P3-R1: DDL 해시 컬럼 타입 정합화
|
||||
|
||||
- [x] **Goal `P3-R1`: `source_hash` DDL을 Hibernate 엔티티 매핑과 일치시킨다**
|
||||
|
||||
- 시작 조건: `P3-GATE` 코드 품질 리뷰에서 `CHAR(64)`와 `VARCHAR(64)` 불일치가 차단 이슈로 확정됨.
|
||||
- 완료 증거: `schema.sql`의 `source_hash VARCHAR(64)` 확인, `./gradlew tasks --all`, `git diff --check`, 해당 delta 코드 품질 재검토 통과.
|
||||
- 범위 밖: 엔티티 매핑 변경, 실제 MySQL DDL 적용, 비차단 로그·테스트 종료 개선.
|
||||
- RED: `rg -n 'source_hash VARCHAR\(64\)' schema.sql`이 일치 항목 없이 실패하는지 확인한다.
|
||||
- GREEN: DDL 한 줄만 `VARCHAR(64)`로 변경하고 동일 명령이 성공하는지 확인한다.
|
||||
- REFACTOR: 추가 구조 변경 없이 문서 상태와 Gate 증거만 동기화한다.
|
||||
- 수정 파일: `docs/20260910_크리에이터커뮤니티게시물본문번역/schema.sql`, `prd.md`, `plan-task.md`.
|
||||
|
||||
##### 실행·검증 기록 — 2026-09-10
|
||||
|
||||
- RED: `rg -n 'source_hash VARCHAR\(64\)' schema.sql` — 일치 항목 없이 exit 1.
|
||||
- GREEN: `source_hash`를 엔티티의 `String` + `@Column(length = 64)`와 같은 `VARCHAR(64)`로 변경했고 동일 `rg`가 11행을 반환했다.
|
||||
- 회귀: `./gradlew tasks --all` — exit 0, `BUILD SUCCESSFUL`; `GIT_MASTER=1 git diff --check` — exit 0.
|
||||
- delta 품질 재검토: `PASS` (`ses_f772f064cffeKa532hsf1Pa3GU`). 실제 MySQL 적용과 `ddl-auto: validate` 기동 확인은 수동 검증에 유지한다.
|
||||
|
||||
#### Phase 3 Gate
|
||||
|
||||
- [x] **Goal `P3-GATE`: 전체 노출 표면과 비동기 정합성 최종 판정**
|
||||
|
||||
- 시작 조건: `P3-T1`, `P3-T2` 및 이전 Gate의 증거 완료.
|
||||
- 완료 증거: 아래 자동 검증, PRD `CCT-001~010` 대조 및 5개 리뷰 레인 통과. HTTP·실제 MySQL/Papago는 배포 후 수동 체크리스트로 분리한다.
|
||||
- 범위 밖: 테스트 삭제/완화, 운영 데이터 번역/배포, 다른 기능의 결함 수정.
|
||||
- TDD 예외 사유: 기능 추가가 아닌 전체 수용 기준 검증 Gate다.
|
||||
- 대체 검증: 자동 회귀, 격리된 실행 서버의 HTTP 응답/DB 작업 수 대조, 문서와 실제 동작 대조.
|
||||
- 확인 파일: 모든 Task의 변경 파일, `prd.md`, `plan-task.md`, `schema.sql`.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.recommendation.*' --tests 'kr.co.vividnext.sodalive.v2.home.following.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*'
|
||||
./gradlew ktlintCheck
|
||||
./gradlew bootJar
|
||||
```
|
||||
|
||||
기본 최종 검증은 위 직접 영향 범위다. 이번 변경이 공통 번역 API의 기존 리소스 동작까지 바꾸거나,
|
||||
위 범위로 판정할 수 없는 실패가 발견되거나, 사용자/릴리스 기준이 전체 검증을 요구하면 `./gradlew test`를 추가한다.
|
||||
전체 테스트 생략 시 공통 번역 및 영향받은 각 기능을 위 명령으로 검증했다는 근거와 실제 결과를 기록한다.
|
||||
|
||||
##### 최종 Gate 기록 — 2026-09-10
|
||||
|
||||
- 직접 영향 테스트 — clean 후 `--no-parallel` 순차 실행, exit 0, `BUILD SUCCESSFUL` (5분 20초). 53개 클래스 493개 테스트, 실패·오류·skip 0을 XML에서 확인했다.
|
||||
- `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL` (20초). `./gradlew bootJar` — exit 0, `BUILD SUCCESSFUL` (1분 31초).
|
||||
- `GIT_MASTER=1 git diff --check` — exit 0. 최초 병렬 Gradle의 kapt 출력 경합은 clean 후 순차 성공으로 대체했다.
|
||||
- 최종 리뷰: 목표·제약 PASS, 자동 QA PASS, 보안 PASS, 문맥 PASS. 코드 품질의 DDL 타입 차단 이슈는 `P3-R1`로 수정 후 delta 재검토 PASS.
|
||||
- 비차단 관찰: 번역 예약 실패 로그 분류, 일부 경합 테스트 executor 종료 확인, 언어 감지 완료 전 반복 상세 요청의 호출 증폭은 이번 범위에서 변경하지 않았다.
|
||||
- 간접 소비자: `LiveApiService`, `ExplorerService`도 기존 목록 서비스를 통해 저장 번역을 사용하며 추가 예약은 하지 않는다.
|
||||
- 로컬 자동 Gate는 완료했다. 실제 DDL·MySQL 격리 수준·HTTP·Papago 검증은 아래 테스트 서버 수동 체크리스트에서 미완료로 유지한다.
|
||||
|
||||
### 테스트 브랜치 배포 후 수동 체크리스트
|
||||
|
||||
수동 HTTP 검증은 운영과 분리된 DB·Redis·S3/Papago 설정 및 테스트 계정이 준비된 테스트 서버에서 실행한다.
|
||||
기존 `./gradlew bootRun`을 사용하며 토큰·키·본문 전문은 기록물에서 제거한다.
|
||||
|
||||
1. `schema.sql`을 테스트 DB에 적용한다. 기존 행은 `language_code` NULL, `content_revision` 0, 원문이 유지되고,
|
||||
`creator_community_id + locale` 유일 키가 생성됐는지 확인한다.
|
||||
2. 같은 게시물·locale의 번역 요청을 동시에 보낸다. duplicate-key 오류나 API 실패 없이, 현재 원문과 일치하는 번역 행이 정확히 1개인지 확인한다.
|
||||
3. 언어 NULL인 기존 무료 게시물로 PRD §5의 모든 목록/홈 API를 조회한다. 원문 표시, 감지/번역 작업 증가 0건을 확인한다.
|
||||
4. 같은 글을 `Accept-Language: ja`로 상세 조회한다. 최초 원문 응답, 감지 후 일본어 작업만 예약,
|
||||
워커 완료 후 상세/목록/채널 홈/추천/팔로잉에서 저장 번역 사용을 확인한다.
|
||||
5. 한·영·일 원문의 신규 게시물과 실제 본문 수정을 실행한다. 다른 두 언어 생성,
|
||||
수정 직후 현재 원문 폴백, 지연된 이전 감지/번역 결과 미노출, A→B→A 메모리 복원을 확인한다.
|
||||
6. 유료 글을 미구매자/구매자/소유자로 조회하고 번역 선택 후 기존 마스킹이 적용되는지 확인한다.
|
||||
차단·성인·비활성·댓글/답글 노출과 미디어 URL 규칙은 기존 결과와 대조한다.
|
||||
7. 감지/번역 provider 실패와 반복 상세 요청을 재현한다. 원문 응답, 기존 재시도 정책, 작업 중복 방지,
|
||||
페이지 크기 증가 시 추가 번역 조회 수가 선형으로 늘지 않음을 확인한다.
|
||||
|
||||
#### P1-R4 실제 MySQL 수동 확인 · 2026-09-10 추가
|
||||
|
||||
- [ ] 테스트 서버가 실제 MySQL 8/InnoDB인지 확인하고 버전·테이블 엔진·세션 격리 수준을 기록한다.
|
||||
감지 캐시의 해시/provider/normalizationVersion 유일 키와 audit 컬럼의 타입·NULL/default 조건을 확인한다.
|
||||
native INSERT 후 생성/수정 시각이 채워지고 중복 upsert가 승자 감지값과 생성 시각을 덮어쓰지 않는지 대조한다.
|
||||
- [ ] 언어 NULL이고 해당 캐시 키가 없는 동일 한국어 게시물에 `ja`/`en` 상세 요청을 보낸다.
|
||||
테스트용 provider 제어로 두 miss와 provider 진입을 확인한 뒤 첫 감지 저장·커밋·`ja` 예약을 완료하고
|
||||
후발 감지를 해제한다. 실제 `REPEATABLE READ`에서 예외/rollback 없이 두 흐름이 끝나고 캐시 1행,
|
||||
`ja`/`en` 작업이 모두 남는지 확인한다. 단순 동시 HTTP 요청만으로 이 실행 순서를 증명했다고 보지 않는다.
|
||||
- [ ] 서로 다른 게시물을 동일 본문으로 생성하고 캐시 miss 두 건의 저장 순서를 같은 방식으로 제어한다.
|
||||
공통 캐시 1행과 승자 결과 재사용, 각 게시물의 원문 외 두 언어 작업 보존을 확인한다.
|
||||
- [ ] provider 감지 응답을 차단한 동안 별도 연결에서 해당 게시물 본문 수정을 커밋할 수 있는지 확인한다.
|
||||
수정 후 개정 증가·언어 초기화와 현재 원문 폴백을 확인하고 이전 감지를 해제한다. 오래된 결과가
|
||||
새 본문·개정·언어·번역을 덮어쓰거나 이전 원문의 작업을 새 개정에 적용하지 않는지 확인한다.
|
||||
|
||||
필요한 테스트 환경이 없으면 수동 검증은 미완료로 기록한다. MockMvc/provider 대체 테스트를 실제 Papago 검증으로 표기하지 않는다.
|
||||
실제 Papago 확인은 비민감한 테스트 문장을 사용하며 수동 검증 환경에서만 수행한다.
|
||||
|
||||
## Progress와 검증 기록
|
||||
|
||||
### 문서 작성 — 2026-09-10
|
||||
|
||||
- 상태: PRD·구현 계획 작성. 구현 Task/Gate는 모두 미실행.
|
||||
- 무엇을: 사용자 인터뷰 결정과 레거시/v2 생성·상세·목록·홈 경로를 이 계획의 6개 Task에 연결했다.
|
||||
- 왜: 구현 전 모호한 범위를 없애고 정확한 변경 위치·수용 기준·TDD 순서를 제공하기 위해서다.
|
||||
- 실행 확인: `./gradlew tasks --all` — exit 0, `BUILD SUCCESSFUL`. `test`, `bootRun`, `bootJar`, `ktlintCheck` task를 확인했다.
|
||||
- 관찰: 기존 Gradle 설정에서 Gradle 9.0 비호환 예정 deprecation 안내가 출력됐다. 이번 문서 범위에서 변경하지 않는다.
|
||||
- 미실행: 코드 구현, 신규 테스트 작성/실행, 서버 실행, Papago 호출, DDL 작성/적용, 운영 작업.
|
||||
- 테스트 생략 근거: 제품 코드를 변경하지 않는 문서 작업이다. 미래 테스트 명령을 계획에 적은 것은 테스트 통과 증거가 아니다.
|
||||
- 남은 실행 조건: 사용자의 별도 구현 요청과 각 Task의 검증 환경 확보.
|
||||
|
||||
### 문서 검증 — 2026-09-10
|
||||
|
||||
- `./gradlew tasks --all` — 문서 생성 후 재실행, exit 0, `BUILD SUCCESSFUL`.
|
||||
- 문서 경로·링크·템플릿 잔여값·공백 검사 — exit 0, `Document validation: PASS`.
|
||||
기존 파일 참조가 존재하며 미래 생성 파일 7개는 생성 항목으로 구분했다.
|
||||
- 요구사항/계획 대조 — 요구사항 10개, 구현 Task 6개, Gate 3개가 연결되어 있고 구현 완료 체크는 없다.
|
||||
- `git diff --check` — exit 0. 신규 미추적 문서의 공백은 별도 문서 검사로 확인했다.
|
||||
- `git -c core.quotePath=false status --short --untracked-files=all` — 이 작업 폴더의 `prd.md`, `plan-task.md` 두 파일만 추가됨을 확인했다.
|
||||
|
||||
### P2-R3 최종 검증 — 2026-09-10
|
||||
|
||||
- `./gradlew tasks --all` — exit 0, `BUILD SUCCESSFUL`; `test`, `ktlintCheck`, `build` task가 유효함을 확인했다.
|
||||
- `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`. P2 변경 테스트의 import 순서와 `Callable` 호출 줄바꿈을 정정했다.
|
||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest.shouldRunDetailTranslationPipelineThroughProxyListenerAndScheduler'`
|
||||
— exit 0, `BUILD SUCCESSFUL`. 실제 Spring 상세 파이프라인에서 원문 즉시 반환, 단일 job, 재료화 후 번역 반환을 확인했다.
|
||||
- `./gradlew build` — exit 0, `BUILD SUCCESSFUL` (8분 16초). 컴파일, 전체 테스트, ktlint, 패키징을 포함한다.
|
||||
- `GIT_MASTER=1 git diff --check` — exit 0. Docker, live HTTP, 실제 Papago, 실제 MySQL 검증은 실행하지 않았다.
|
||||
|
||||
### Phase별 후속 정적 리뷰 — 2026-09-10
|
||||
|
||||
- 기준: 인터뷰를 반영한 `prd.md`와 이 계획, 현재 source/test/DDL. HEAD와 작업 트리 지문은
|
||||
[리뷰 증거](reviews/review-evidence.md)에 기록했다.
|
||||
- Phase 1: 감지 캐시 최초 INSERT 경합으로 후발 target 예약이 누락되는 정적 경로 1건 확정.
|
||||
`REV-P1-007`을 신규 `P1-R4`로 추가했다. 기존 완료 기록과 이전 리뷰는 유지했다.
|
||||
- Phase 2: 생성·실제 본문 수정·상세 단일 언어 예약·권한/마스킹 기준 충족. 자체 추가 수정 사항 없음.
|
||||
Phase 1 결함의 상세 영향은 `P1-R4`에서 처리한다.
|
||||
- Phase 3: 목록·채널 홈·추천·팔로잉 저장 번역 및 비예약 기준 충족. 신규 Task 없음.
|
||||
- 실행 제약: 사용자 요청대로 테스트·컴파일·서버·HTTP·Papago·DB 재현은 실행하지 않았다.
|
||||
기존 XML 성공 기록은 과거 실행 증거로만 읽었다.
|
||||
- 검증 한계: 실제 캐시 저장 경합은 새 Task의 RED로 재현할 예정이다. 한·영·일 전체 생성→워커 통합 검증과
|
||||
실제 MySQL/Papago/HTTP는 기존 테스트 서버 수동 확인 범위로 남겼다. 구성요소 테스트 통과를 실환경 전체 흐름 통과로 확대하지 않는다.
|
||||
- 문서 검증: `./gradlew tasks --all` — exit 0(task 목록 조회만 수행, 테스트·컴파일 미실행).
|
||||
`git diff --check`와 문서 링크/공백 검사 통과. 리뷰 전후 source/test/DDL 32개 파일 지문이 같음을 확인했다.
|
||||
|
||||
### P1-R4 문서 동기화 · 2026-09-10
|
||||
|
||||
- 완료된 RED/GREEN·P1/P2 영향 회귀·spec/quality 리뷰를 P1-R4 실행 기록과 리뷰 문서 두 곳에 누적했다.
|
||||
기존 정적 리뷰 지문과 과거 기록은 당시 증거로 보존하며 실제 MySQL 수동 확인 네 항목은 미완료로 추가했다.
|
||||
- 문서 검증: `./gradlew tasks --all`, exit 0, `BUILD SUCCESSFUL` (2초). `test`, `ktlintCheck` task를 확인했다.
|
||||
`rg`로 P1-R4 Goal 및 RED/GREEN/REFACTOR 완료 체크, 실행 시간·리뷰 출처·실환경 검증 보류 표기를 대조했다.
|
||||
- 이번 작업은 지정된 계획·리뷰 문서 3개만 수정했다. PRD·source·test·schema 변경이나 제품 테스트 재실행은 하지 않았다.
|
||||
|
||||
## 변경 관리
|
||||
|
||||
기술 선택은 PRD `DEC-009~010`, 제품 결정은 `DEC-001~008`을 따른다.
|
||||
후속 요구사항은 PRD 결정 기록 → 요구사항/API 계약 → 이 계획의 파일·검증·체크박스 순서로 갱신한다.
|
||||
미래 구현에서는 각 Task 아래에 실행일·무엇/왜/어떻게·정확한 명령·결과·남은 항목을 누적한다.
|
||||
새 테스트 클래스를 만들 때는 이 문서의 focused 실행 예시가 실제 클래스와 일치하는지도 갱신한다.
|
||||
@@ -0,0 +1,176 @@
|
||||
# 크리에이터 커뮤니티 게시물 본문 번역 PRD
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 구현 및 로컬 자동 검증 완료 · 테스트 서버 수동 검증 대기 |
|
||||
| 작성일 / 최종 수정일 | 2026-09-10 |
|
||||
| 결정권자 | 요청 사용자 |
|
||||
| 산출물 범위 | 코드·자동 테스트·MySQL DDL 작성. 실제 DDL 적용·배포·Papago/HTTP 검증은 테스트 서버에서 수행한다. |
|
||||
| API 계약 | 별도 파일을 만들지 않고 이 문서의 API 계약 절에 통합 |
|
||||
| 기준 템플릿 | `docs/sample/sample-prd.md` |
|
||||
|
||||
## 1. 목표와 현재 동작
|
||||
|
||||
크리에이터 커뮤니티 게시물의 본문을 한국어·영어·일본어로 제공한다. 작성한 원문은 보존하고,
|
||||
원문 이외의 지원 언어 번역을 저장한다. 이용자는 요청 언어에 맞는 본문을 상세·목록·미리보기에서 읽는다.
|
||||
|
||||
현재 코드에서 확인한 사실:
|
||||
|
||||
| 근거 파일 | 확인한 동작 |
|
||||
|---|---|
|
||||
| `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt` | 원문 `content`는 있으나 원문 언어와 번역 저장 구조는 없다. |
|
||||
| `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` | 상세 조회에서 요청 언어의 번역을 조회한다. 번역이 없으면 해당 언어 작업을 예약하고 원문 응답을 유지한다. 원문 언어가 없는 경우에는 이 예약 분기에 들어가지 않는다. |
|
||||
| `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt` | 커밋 후 비동기 언어 감지, 언어 저장, 번역 이벤트 발행 흐름이 있다. |
|
||||
| `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/ResourceTranslationJobScheduler.kt` | 리소스의 원문 언어를 제외한 지원 언어 전체 또는 지정한 한 언어의 작업을 예약한다. |
|
||||
| `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobScheduler.kt` | 원문 언어가 `ko`, `en`, `ja`인 비어 있지 않은 텍스트만 예약한다. 동일 리소스·필드·목표 언어·원문 해시 작업은 중복 생성하지 않는다. |
|
||||
| `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorker.kt` | 저장된 작업을 처리하고 번역 메모리와 조회용 번역을 저장한다. 주기 기본값은 600000ms이며 실제 환경 설정에 따라 달라진다. |
|
||||
|
||||
이번 기능은 오디오 상세의 누락 번역 예약 방식을 따른다. 다만 기존 커뮤니티 게시물은 언어 정보 자체가 없으므로,
|
||||
상세 조회에서 비동기 언어 감지까지 연결해야 한다. 오디오 상세가 현재 언어 미확정 데이터도 감지한다고 가정하지 않는다.
|
||||
|
||||
## 2. 포함 범위와 제외 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- 본문 `content`만 번역하며 무료·유료 게시물에 같은 언어 정책을 적용한다.
|
||||
- 신규 게시물 저장 후 자동 감지·번역, 실제 본문 변경 후 언어 재감지·번역 갱신.
|
||||
- 기존 게시물은 상세 조회를 계기로 필요한 언어를 감지하고 요청 언어의 누락 번역을 예약한다.
|
||||
- 기존 커뮤니티 API와 v2 상세·목록, 채널 홈의 고정/일반 게시물, 홈 추천 인기 커뮤니티, 팔로잉 소식의 본문·미리보기.
|
||||
- 공유 생성·수정 서비스를 사용하는 AI 캐릭터 관리자 작성 경로도 같은 저장 처리를 적용한다.
|
||||
이는 별도 관리자 기능 추가가 아니라 동일 게시물의 생성·수정 누락 방지다.
|
||||
|
||||
### 제외
|
||||
|
||||
- 댓글·답글·첫 댓글, 이미지 내부 문자, 첨부 오디오 음성의 번역.
|
||||
- 기존 게시물 일괄 번역, 목록·홈·미리보기·댓글 조회에서 언어 감지 또는 번역 작업 예약.
|
||||
- 번역 수동 편집·언어 선택 입력·원문 보기 토글·번역 상태 API·실시간 완료 알림.
|
||||
- 푸시 알림 문구·알림 이력의 번역, 정산 화면과 관리자 편집용 원문 응답 변경.
|
||||
- 공통 언어 지원 범위, 다른 콘텐츠의 수정 정책, 추천 순위/스냅샷 생성 방식의 변경.
|
||||
|
||||
## 3. 확정 요구사항
|
||||
|
||||
| ID | 요구사항 | 수용 기준 | 연결 Goal |
|
||||
|---|---|---|---|
|
||||
| `CCT-001` | 원문을 보존하고 본문만 지원 언어로 번역해 저장한다. | 한국어·영어·일본어 각각을 원문으로 작성하면 다른 두 언어의 본문 번역이 저장되고 원문은 유지된다. 댓글과 첨부 파일은 바뀌지 않는다. | `P1-T1`, `P1-T2`, `P2-T1` |
|
||||
| `CCT-002` | 원문 언어가 없으면 본문으로 자동 감지한다. | 요청자의 앱 언어를 원문 언어로 추정하지 않는다. 감지 결과를 저장하고 번역으로 이어진다. | `P1-T2` |
|
||||
| `CCT-003` | 신규 게시물은 저장 성공 후 원문 이외의 지원 언어 번역을 예약한다. | 저장 롤백에는 감지/번역이 실행되지 않는다. 저장 응답이 외부 번역 완료를 기다리지 않는다. | `P2-T1` |
|
||||
| `CCT-004` | 실제 본문 수정 시 언어를 다시 감지하고 번역을 갱신한다. | 한국어에서 영어로 변경해도 새 언어와 새 본문으로 처리한다. 동일 본문 제출·이미지·고정·댓글 허용 여부만 변경하면 재번역하지 않는다. | `P2-T1` |
|
||||
| `CCT-005` | 기존 게시물은 상세 조회에서만 누락 번역을 예약한다. | 번역 미완료 상세는 원문을 반환한다. 원문 언어가 없으면 감지 후 해당 상세 요청 언어만 예약한다. 언어가 같으면 번역 작업은 없다. | `P2-T2` |
|
||||
| `CCT-006` | 상세·목록·미리보기에서 저장된 유효한 번역을 사용한다. | 레거시 목록/최신 목록, v2 커뮤니티 목록, 채널 홈, 홈 추천, 팔로잉 소식에 같은 언어의 저장 번역이 적용된다. 목록 계열만 조회하면 감지/번역 작업은 0건이다. | `P2-T2`, `P3-T1`, `P3-T2` |
|
||||
| `CCT-007` | 번역이 없거나 실패했거나 현재 본문에 맞지 않으면 현재 원문을 제공한다. | 수정 커밋 후 새로 시작한 요청에서는 수정 전 번역을 반환하지 않는다. 늦게 도착한 이전 감지/번역 결과도 새 본문을 덮어쓰지 않는다. | `P1-T1`, `P1-T2`, `P2-T1`, `P3-GATE` |
|
||||
| `CCT-008` | 기존 조회 권한과 유료 본문 제한을 번역문에도 적용한다. | 차단·성인·비활성 필터와 구매/소유자 판정은 유지한다. 번역문을 선택한 다음 기존 미리보기 제한을 적용해 미구매자에게 전체 본문이 노출되지 않는다. | `P2-T2`, `P3-T1`, `P3-T2` |
|
||||
| `CCT-009` | 기존 API 구조와 언어 결정 방식을 유지한다. | `Accept-Language`를 처리하는 `LangContext`를 사용한다. 응답 본문 문자열의 언어만 달라지고 필드·상태 코드·인증·페이지 규칙은 유지한다. | `P2-T2`, `P3-T1`, `P3-T2` |
|
||||
| `CCT-010` | 기존 감지 캐시·번역 메모리·큐·워커를 재사용한다. | 반복 상세 조회나 이전 본문으로의 복원에서 불필요한 번역 호출을 만들지 않는다. 목록 번역 조회는 게시물별 쿼리 대신 일괄 조회한다. | `P1-T1`, `P1-T2`, `P3-GATE` |
|
||||
|
||||
## 4. 처리 흐름
|
||||
|
||||
### 4.1 신규 작성과 본문 수정
|
||||
|
||||
1. 기존 검증·소유권 확인을 거쳐 원문을 저장한다. 본문 변경 시 이전 언어를 비우고 본문 개정 번호를 증가시킨다.
|
||||
2. 저장 트랜잭션 커밋 후 해당 본문의 언어를 비동기로 감지한다. 원문 정보가 이미 있으면 감지를 생략한다.
|
||||
3. 감지 결과가 현재 본문의 결과임을 확인하고 원문 언어를 저장한다.
|
||||
4. 지원 언어 중 원문을 제외한 언어를 예약한다. 기존 메모리를 재사용할 수 있으면 조회용 번역을 복원한다.
|
||||
5. 워커가 완료하면 다음 조회부터 번역문을 제공한다. 완료 전에는 현재 원문을 제공한다.
|
||||
|
||||
### 4.2 기존 게시물 상세 조회
|
||||
|
||||
1. 기존 접근 권한과 노출 정책을 확인한다.
|
||||
2. 요청 언어의 현재 본문에 대응하는 번역이 있으면 사용한다.
|
||||
3. 번역이 없으면 원문 응답을 유지하고 누락 처리만 예약한다. 언어 미확정이면 감지 요청에 상세 요청 언어를 전달한다.
|
||||
4. 감지 후 원문과 요청 언어가 다를 때 해당 언어만 예약한다. 신규 작성의 전체 언어 예약과 구분한다.
|
||||
5. 외부 감지/번역은 HTTP 응답을 기다리게 하지 않는다. 다음 조회부터 완성된 번역을 사용한다.
|
||||
|
||||
### 4.3 목록·홈·미리보기
|
||||
|
||||
권한 필터와 페이지 선정 → 페이지 내 게시물의 유효한 번역 일괄 조회 → 번역 또는 현재 원문 선택 →
|
||||
기존 유료 미리보기·표시 규칙 적용 순서로 처리한다. 이 경로는 언어 감지, 메모리에서 조회 모델 재생성,
|
||||
번역 작업 예약을 하지 않는다. 저장된 번역이 없는 기존 게시물은 상세 조회 또는 본문 수정 전까지 원문으로 남는다.
|
||||
|
||||
## 5. API 계약과 조회 표면
|
||||
|
||||
공개 필드는 추가하지 않는다. `content`와 여기서 파생되는 게시물 미리보기 문자열을 요청 언어로 반환한다.
|
||||
언어는 `LangInterceptor` → `Lang.fromAcceptLanguage` → `LangContext`를 그대로 사용한다.
|
||||
지원 언어는 `ko`, `en`, `ja`이고 헤더 누락/미지원 언어 처리는 기존 한국어 기본값을 유지한다.
|
||||
자동 번역문을 원문 `CreatorCommunity.content`에 덮어쓰지 않는다.
|
||||
|
||||
| API | 대상 | 누락 처리 예약 |
|
||||
|---|---|---|
|
||||
| `POST /creator-community`, `PUT /creator-community` | 신규 작성·실제 본문 수정 후 처리. multipart 계약 유지 | 커밋 후 전체 목표 언어 |
|
||||
| `GET /creator-community/{id}` | 레거시 상세 `content` | 요청 언어만 |
|
||||
| `GET /creator-community`, `GET /creator-community/latest` | 레거시 목록·팔로우 최신 목록 `content` | 없음 |
|
||||
| `GET /api/v2/creator-channels/community-posts/{postId}` | v2 상세 게시물 `content` | 요청 언어만 |
|
||||
| `GET /api/v2/creator-channels/{creatorId}/community` | 커뮤니티 탭 게시물 `content` | 없음 |
|
||||
| `GET /api/v2/creator-channels/{creatorId}/home` | 고정/일반 커뮤니티 항목 `content` | 없음 |
|
||||
| `GET /api/v2/home/recommendations` | 인기 커뮤니티 항목 본문·파생 미리보기 | 없음 |
|
||||
| `GET /api/v2/home/following` | `recentNews`의 커뮤니티 게시물 본문 | 없음 |
|
||||
|
||||
관리자 게시물 조회는 편집 원문을 유지한다. 같은 작성 서비스를 호출하는 관리자 생성·수정은 `CCT-003~004`를 따른다.
|
||||
댓글 전용 조회와 구매 응답은 번역 예약 진입점으로 추가하지 않는다.
|
||||
|
||||
## 6. 데이터·비동기 정합성 설계 기준
|
||||
|
||||
다음 기술 설계는 현재 구현과 DDL에 반영됐다. 실제 MySQL 적용 여부는 테스트 서버 수동 검증에서 확인한다.
|
||||
|
||||
| 저장 대상 | 계획 |
|
||||
|---|---|
|
||||
| `creator_community` | nullable `language_code`, 본문 변경에만 증가하는 `content_revision`을 추가한다. 기존 행은 언어 NULL, 개정 번호 0으로 시작한다. |
|
||||
| `creator_community_translation` | 게시물 ID, locale, 번역 본문, 번역 기준 개정 번호, 원문 해시·언어를 저장한다. `(creator_community_id, locale)` 유일성을 보장한다. |
|
||||
| 공통 감지/번역 저장소 | 기존 감지 캐시와 번역 메모리의 정규화·키, `translation_job` 처리 흐름을 유지한다. 커뮤니티 대상 enum과 원문 추출/조회 모델 저장 분기를 추가한다. |
|
||||
|
||||
- 감지 이벤트에 본문 개정 번호와 필요한 경우 목표 언어를 전달한다. 본문이 변경되었거나 비활성화되면 이전 결과를 적용하지 않는다.
|
||||
- 번역문은 현재 본문의 개정 번호·원문 언어가 일치할 때만 조회한다. 오래된 작업의 결과에 현재 개정 번호를 임의로 붙이지 않는다.
|
||||
- 감지 결과 저장과 번역 upsert의 개정 확인은 DB 갱신과 원자적으로 처리한다. 외부 API 호출 동안 행 잠금을 유지하지 않는다.
|
||||
- `updatedAt`은 고정·이미지 등 수정에도 변하므로 본문 개정 판단에 사용하지 않는다.
|
||||
- 원문 A → B → A 복원 시 기존 COMPLETED 작업 때문에 번역이 영구 누락되지 않도록 현재 메모리로 조회 모델을 먼저 복원한다.
|
||||
- 기존 정규화는 공백·개행을 합친다. 원문 표시의 개행은 원문 그대로 보존하고, 번역 결과는 기존 번역기의 출력 정책을 따른다.
|
||||
|
||||
## 7. 예외·권한·성능
|
||||
|
||||
| 상황 | 처리 |
|
||||
|---|---|
|
||||
| 빈 본문 또는 공백만 있는 본문 | 기존 저장 검증은 바꾸지 않는다. 감지·번역은 생략하고 원문을 반환한다. |
|
||||
| 감지 실패 또는 지원하지 않는 원문 언어 | 원문 유지. 지원하지 않는 언어의 번역 작업은 기존 scheduler 정책에 따라 생성하지 않는다. 언어 미확정 게시물의 다음 상세에서 감지를 다시 시도할 수 있다. |
|
||||
| 번역 실패 | 원문 유지. 기존 워커의 재시도/최종 FAILED 정책을 재사용한다. 반복 상세 조회가 최종 실패 작업을 무한 재생성하지 않는다. |
|
||||
| 번역 도중 본문 변경·비활성화 | 오래된 결과를 현재 게시물의 번역으로 노출하지 않는다. 삭제/비활성 게시물은 기존 조회 정책대로 숨긴다. |
|
||||
| 유료 게시물 미구매 | 선택된 번역 또는 원문에 기존 코드포인트 기준 제한을 적용한다. 기존 15자 기준·짧은 본문 절반·말줄임 처리는 유지한다. |
|
||||
| 목록에 여러 게시물 | 페이지 단위 번역 일괄 조회. 기존 정렬·개수·페이지·추천 후보를 변경하지 않고 추가 N+1을 만들지 않는다. |
|
||||
|
||||
외부 감지/번역 실패는 작성·조회 성공을 번역 완료 여부에 종속시키지 않는다. 인증 실패나 DB 장애까지 성공으로 숨기지는 않는다.
|
||||
본문·자격증명·유료 콘텐츠 전문을 새 로그에 남기지 않는다. 기존 감지/번역 저장소의 텍스트 저장 정책은 재사용한다.
|
||||
번역 완료 시간 SLA나 워커 주기 조정은 이번 요청에 포함하지 않는다.
|
||||
|
||||
## 8. 성공 기준과 추적성
|
||||
|
||||
| 수용 시나리오 | 완료 증거 |
|
||||
|---|---|
|
||||
| 한·영·일 원문으로 신규 작성 후 다른 두 언어 제공 | `P2-GATE`의 생성→감지→작업→저장 통합 검증 |
|
||||
| 언어 없는 기존 글: 목록은 원문/작업 0건, 상세는 원문/감지 후 요청 언어 예약 | `P2-T2` 통합 테스트와 배포 후 수동 HTTP 검증 |
|
||||
| 한국어 본문을 영어로 수정하고 이전 비동기 작업을 늦게 완료 | 현재 원문 폴백과 오래된 결과 차단 테스트 |
|
||||
| 상세 번역 저장 후 목록·채널 홈·추천·팔로잉 모두 요청 언어 제공 | `P3-T1~T2`, `P3-GATE`의 표면별 검증 |
|
||||
| 미구매자는 번역 미리보기만, 구매자·소유자는 기존 권한에 따른 본문 제공 | 레거시/v2 권한·코드포인트 경계 회귀 검증 |
|
||||
| 언어 감지/번역 실패, 반복 조회, 본문 복원 | 원문 유지·작업 중복 방지·번역 메모리 재사용 검증 |
|
||||
|
||||
구현 완료는 위 자동 검증 증거가 기록된 뒤에 판정했다. 실제 MySQL·Papago·HTTP 검증은 테스트 서버 수동 검증으로 분리한다.
|
||||
|
||||
## 9. 인터뷰 결과와 Decision Log
|
||||
|
||||
최종 모호성 점수: **0.04**. 차원별 명확성: Goal 1.00, Scope 1.00, Constraints 0.95, Success 0.95, Context 0.85.
|
||||
이는 누락 확인용 판단 지표이며, 코드 검증이나 구현 완료를 의미하지 않는다.
|
||||
열린 제품 질문은 없다. 테스트 환경 자격증명과 DDL 적용 절차는 테스트 서버 배포 단계에서 확인할 실행 조건이다.
|
||||
|
||||
| ID | 상태 | 결정 | 근거 / 영향 |
|
||||
|---|---|---|---|
|
||||
| `DEC-001` | 확정 | 본문만 번역, 댓글 제외 | 사용자 인터뷰 / `CCT-001` |
|
||||
| `DEC-002` | 확정 | 기존 글은 오디오 상세처럼 누락 번역 예약 | 사용자 제안 및 후속 상세 한정 답변 / `CCT-005` |
|
||||
| `DEC-003` | 확정 | 목록 본문·미리보기도 저장된 번역 적용 | 사용자 답변 / `CCT-006` |
|
||||
| `DEC-004` | 확정 | 언어 정보가 없으면 자동 감지 | 사용자 답변 / `CCT-002` |
|
||||
| `DEC-005` | 확정 | 본문 수정 시 언어 재감지·번역 갱신 | 사용자 yes / `CCT-004` |
|
||||
| `DEC-006` | 확정 | 목록에서는 예약하지 않고 상세에서만 예약 | 사용자 답변 / `CCT-005~006` |
|
||||
| `DEC-007` | 확정 | 새 번역 전에는 수정된 원문 표시 | 사용자 yes / `CCT-007` |
|
||||
| `DEC-008` | 확정 | 채널 홈·홈 추천·팔로잉 소식 포함 | 사용자 yes / `CCT-006` |
|
||||
| `DEC-009` | 기술 설계 | 기존 API 구조·권한 유지, 언어별 저장 번역을 본문 필드에 적용 | 저장소 규칙 및 최소 변경 원칙 / `CCT-008~009` |
|
||||
| `DEC-010` | 기술 설계 | 본문 개정 번호와 기존 캐시·큐 재사용 | 수정 직후 원문 표시와 비동기 경합 방지 / `CCT-007`, `CCT-010` |
|
||||
|
||||
결정 날짜는 모두 2026-09-10이다. 범위 변경 시 이 결정 기록과 요구사항을 먼저 갱신하고 `plan-task.md`를 동기화한다.
|
||||
@@ -0,0 +1,118 @@
|
||||
# P1-T2 리뷰 보고서
|
||||
|
||||
## 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 1 / `P1-T2`, `P1-GATE` |
|
||||
| 기준 working tree | P1-T2 구현 및 실제 Spring regression test 추가 상태 |
|
||||
| 리뷰 일자 | 2026-09-10 |
|
||||
| 리뷰어 | automated review |
|
||||
| 기준 문서 | `prd.md`, `plan-task.md` |
|
||||
| 리뷰 상태 | 추가 수정 및 회귀 검증 완료 |
|
||||
|
||||
## 범위
|
||||
|
||||
- 코드: `LanguageDetectEvent.kt`, `CreatorCommunityTranslationService.kt`
|
||||
- 테스트: `CreatorCommunityLanguageDetectTest.kt`
|
||||
- 제외: 다른 리소스 감지, 공개 API, Docker·HTTP·실제 Papago·MySQL 수동 검증
|
||||
|
||||
## 확정 발견 사항
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 후속 goal |
|
||||
|---|---|---|---|---|
|
||||
| `REV-P1-T2-001` | Blocker | 수정 완료 | 커밋 후 번역 예약이 독립 트랜잭션으로 커밋되지 않는다 | `P1-R1` |
|
||||
| `REV-P1-T2-002` | High | 수정 완료 | 잠금 조회가 stale managed 게시물 상태를 다시 읽지 않는다 | `P1-R1` |
|
||||
| `REV-P1-T2-003` | High | 수정 완료 | 이미 언어가 있는 이벤트가 요청 target을 버린다 | `P1-R1` |
|
||||
| `REV-P1-T2-004` | High | 수정 완료 | 이전 mock 검증은 실제 `translation_job` 저장을 증명하지 않는다 | `P1-R1` |
|
||||
| `REV-P1-T2-005` | High | 수정 완료 | 동시 언어 미확정 감지의 두 번째 target이 lock 뒤 유실된다 | `P1-R2` |
|
||||
| `REV-P1-T2-006` | Blocker | 수정 완료 | 동시 missing-memory 예약이 translation job unique key에 경합한다 | `P1-R3` |
|
||||
|
||||
## 근거와 판정
|
||||
|
||||
### REV-P1-T2-001
|
||||
|
||||
- 코드: `CreatorCommunityTranslationService.requestTranslations`는 기본 `@Transactional`이고, listener는 after-commit callback에서 이를 호출한다.
|
||||
- 재현: `shouldExposeProxiedRequiresNewTranslationRequestEntryPoint`는 `Propagation.REQUIRES_NEW`가 아닌 annotation으로 실패했다.
|
||||
- 영향: 감지 언어 저장 뒤에도 job 예약이 실제로 커밋되지 않아 번역 흐름이 진행되지 않는다.
|
||||
- 조치: 공유 서비스의 public 요청 진입점을 `REQUIRES_NEW`로 선언하고 실제 job 저장 test로 검증한다.
|
||||
|
||||
### REV-P1-T2-002
|
||||
|
||||
- 코드: listener는 `findByIdAndIsActiveTrueForUpdate` 뒤 persistence context의 entity를 refresh하지 않는다.
|
||||
- 재현: `shouldRejectStalePostAfterBlockedDetection`는 감지 동안 본문을 변경한 뒤에도 이전 언어가 저장되어 실패했다.
|
||||
- 영향: 오래된 감지 결과가 새 본문 언어로 저장될 수 있다.
|
||||
- 조치: 잠금 획득 뒤 최신 entity 상태를 refresh하고 revision/content를 재검사한다.
|
||||
|
||||
### REV-P1-T2-003
|
||||
|
||||
- 코드: listener는 이미 languageCode가 설정된 커뮤니티 이벤트를 감지 전 return한다.
|
||||
- 재현: `shouldContinueBothRequestedTargetsWhenLanguageIsAlreadyKnown`는 `ja`, `en` target job이 저장되지 않아 실패했다.
|
||||
- 영향: 상세 요청별 번역 예약 범위가 사라진다.
|
||||
- 조치: 현재 본문 검증 뒤 known-language 이벤트도 after-commit에서 원 target으로 shared service를 호출한다.
|
||||
|
||||
### REV-P1-T2-004
|
||||
|
||||
- 코드/테스트: 기존 테스트는 `CreatorCommunityTranslationService` mock의 호출만 검증했다.
|
||||
- 재현: 실제 Spring listener와 scheduler를 연결한 `CreatorCommunityLanguageDetectTest`에서 저장된 `translation_job` target을 확인하자 job이 없었다.
|
||||
- 영향: mock interaction만 통과해도 사용자 흐름의 persistence 실패를 놓친다.
|
||||
- 조치: 감지 provider만 제어하고 scheduler/repository는 실제 bean으로 사용한다.
|
||||
|
||||
### REV-P1-T2-005
|
||||
|
||||
- 코드: lock과 refresh 뒤 languageCode가 이미 있으면 revision/content 재검증 전에 return한다.
|
||||
- 재현: 같은 언어 미확정 본문으로 두 감지를 시작해 첫 감지를 먼저 커밋한 뒤 두 번째 감지를 진행하면 두 번째 target job이 없다.
|
||||
- 영향: 동시 상세 요청이 서로 다른 언어를 요청할 때 한 요청의 번역 예약이 유실된다.
|
||||
- 조치: 현재 revision/content가 유효하면 이미 저장된 언어를 보존하고, 두 번째 이벤트의 원 target을 after-commit에 등록한다.
|
||||
|
||||
### REV-P1-T2-006
|
||||
|
||||
- 코드: `requestTranslations`는 post lock 없이 materializer의 missing-memory 반환 뒤 scheduler의 job 존재 조회와 insert를 실행한다.
|
||||
- 재현: 같은 언어 확정 post와 target으로 두 request transaction을 동시에 시작하면 두 transaction이 missing job을 보고 insert를 시도할 수 있다.
|
||||
- 영향: job unique key 예외가 호출자 transaction까지 전파돼 상세 예약 흐름이 실패할 수 있다.
|
||||
- 조치: request transaction 시작에서 active post write lock과 refresh를 수행해 source extraction, memory lookup, job 존재 조회와 insert를 직렬화한다.
|
||||
|
||||
## 실행 증거
|
||||
|
||||
| 명령 | 결과 | 핵심 증거 |
|
||||
|---|---|---|
|
||||
| `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` | 실패 | 초기 test bean 보정 후 7개 중 5개 assertion 실패 |
|
||||
|
||||
## 결론
|
||||
|
||||
`REV-P1-T2-001~006`은 `plan-task.md`의 `P1-R1~R3` 회귀 수정 goal로 반영했고 모두 수정·검증을 완료했다.
|
||||
|
||||
## 수정 후 검증 기록
|
||||
|
||||
### 1차 수정 검증 — 2026-09-10
|
||||
|
||||
- 무엇을: `REQUIRES_NEW` 번역 요청, stale entity refresh, known-language target continuation과 실제 `translation_job` 검증을 추가했다.
|
||||
- 어떻게:
|
||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*' ktlintCheck bootJar` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 남은 항목: 사용자 정책상 Docker, HTTP, 실제 Papago, MySQL 수동 동시성 검증은 실행하지 않았다.
|
||||
|
||||
### 2차 수정 검증 — 2026-09-10
|
||||
|
||||
- 대상: `REV-P1-T2-005`.
|
||||
- RED: `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` — exit 1, 8개 중 1개 실패.
|
||||
두 감지가 언어 NULL에서 시작한 뒤 첫 감지의 `ja` job 저장 후 두 번째 `en` job이 누락됐다.
|
||||
- GREEN: lock과 refresh 뒤 revision/content가 유효하면 이미 저장된 languageCode를 유지하고,
|
||||
원 target을 after-commit에 등록하도록 수정했다.
|
||||
- 검증:
|
||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*' ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 남은 항목: Docker, HTTP, 실제 Papago, MySQL 수동 동시성 검증은 사용자 정책상 실행하지 않았다.
|
||||
|
||||
### 3차 수정 검증 — 2026-09-10
|
||||
|
||||
- 대상: `REV-P1-T2-006`.
|
||||
- RED: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest'`
|
||||
— exit 1. 실제 `translation_job` insert가 H2 `SQLState 23505`,
|
||||
`uk_translation_job_resource_field_target_hash` unique index 충돌로 실패했다.
|
||||
- GREEN: `requestTranslations` transaction 시작에서 active post write lock과 `PESSIMISTIC_WRITE` refresh를 수행하도록 수정했다.
|
||||
- 검증:
|
||||
- 같은 focused test — exit 0, `BUILD SUCCESSFUL`; 두 호출 정상 종료와 job 1개 저장을 확인했다.
|
||||
- P1 직접 영향 회귀와 P1 Gate — exit 0, `BUILD SUCCESSFUL`.
|
||||
- `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 남은 항목: Docker, HTTP, 실제 Papago, MySQL 수동 동시성 검증은 사용자 정책상 실행하지 않았다.
|
||||
@@ -0,0 +1,134 @@
|
||||
# Phase 1 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 대상 | Phase 1 `P1-T1`, `P1-T2`, `P1-R1~R4`, `P1-GATE` |
|
||||
| 기준 | HEAD `50409e41c0c469529572f5d77033c3cb23d67d22` + 현재 작업 트리 |
|
||||
| 작업 트리 지문 | `3bbb1d90869484073c3af681118a13f5f03c3ccecbe84de16f1568338acd66b7` |
|
||||
| 일자 / 리뷰어 | 2026-09-10 / Codex 및 목표·품질·보안·QA 증거 리뷰어 |
|
||||
| 기준 문서 | [PRD](../prd.md), [구현 계획](../plan-task.md), `docs/sample/sample-review.md` |
|
||||
| 상태 | `P1-R4` 수정·자동 회귀·후속 리뷰 완료 · 실제 MySQL/Papago/HTTP 수동 검증 대기 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
§2~7의 정적 근거와 지문은 최초 리뷰 당시 기록이다. 수정 후 코드를 같은 지문으로 검증했다고 보지 않는다.
|
||||
현재 판정은 §8과 2026-09-10 후속 재판정에 기록하며, 아래 최초 리뷰의 실행 제약은 그대로 보존한다.
|
||||
|
||||
원문 언어 감지, 번역 저장·재사용, 본문 개정 정합성, 동시 요청의 목표 언어 보존을 기준 문서와 대조한다.
|
||||
코드·테스트 소스·기존 검증 기록만 확인한다. 사용자 지시에 따라 테스트/컴파일/서버/HTTP/DB 실행은 하지 않는다.
|
||||
현재 테스트 통과는 사용자 보고 및 기존 기록으로 구분하며 신규 실행 성공으로 표현하지 않는다.
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
확정 요구사항과 실제 코드 경로가 일치하면 충족으로 판정한다. 결함 후보는 발생 순서·예외 전달·기존 테스트의 경계를
|
||||
대조한 후 확정/오탐/보류로 분리한다. 이번 확정 항목은 제한된 동시 요청 조건에서 발생하고 재조회로 복구할 수 있어
|
||||
**Medium**으로 판정한다. 단순 외부 감지 호출 중복 자체는 계획에서 허용한 범위이므로 결함으로 세지 않는다.
|
||||
|
||||
## 4. 검토 근거와 충족 항목
|
||||
|
||||
아래 source 경로는 `src/main/kotlin/kr/co/vividnext/sodalive/` 기준이다.
|
||||
|
||||
| 요구사항 / 경계 | 코드 근거 | 판정 |
|
||||
|---|---|---|
|
||||
| `CCT-001` 원문과 번역 분리 | `v2/creator/channel/community/translation/adapter/out/persistence/CreatorCommunityTranslation.kt:17`, `schema.sql` | 충족 |
|
||||
| `CCT-007` 현재 개정·언어·해시 일치 | `v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt:95`, `i18n/translation/TranslationReadModelMaterializer.kt:194` | 충족 |
|
||||
| 이전 감지 결과 배제 | `content/LanguageDetectEvent.kt:383`, 감지 전 확인 및 잠금/refresh 후 재검사 | 충족 |
|
||||
| `CCT-010` A→B→A 메모리 복원 | `CreatorCommunityTranslationService.requestTranslations`에서 materialize 후 누락 예약 | 충족 |
|
||||
| 같은 게시물 작업/번역 행 저장 직렬화 | 요청 진입점의 post lock, materializer의 post/translation lock | 충족 |
|
||||
| 언어 미확정 상태의 실제 캐시 INSERT 경합 | 최초 근거: `content/LanguageDetectionCacheService.kt:21`, `:29` | 당시 수정 필요, `P1-R4` 자동 검증으로 수정 확인 (§9) |
|
||||
|
||||
읽은 테스트에는 `CreatorCommunityTranslationServiceTest`의
|
||||
`shouldReturnOnlyCurrentTranslationFromBatchedRead`, `shouldFallbackToOriginalWhenTranslationIsMissingOrDoesNotMatchCurrentSource`,
|
||||
`shouldRematerializeAtoBtoAIntoExistingTranslationRow`, `shouldSerializeConcurrentMissingMemoryTranslationJobScheduling`,
|
||||
`shouldUpdateExistingTranslationAfterRepeatableReadSnapshotWaitsForFirstWriter`가 포함된다.
|
||||
기존 P1 리뷰의 수정 사항을 신규 결함으로 중복 등록하지 않았다.
|
||||
|
||||
실행한 검증은 `git diff`, `rg`, `sed`, `nl`을 통한 정적 대조와 파일 SHA256 계산이다.
|
||||
기존 테스트 XML과 리뷰 레인 출처는 [증거 기록](review-evidence.md)에 있다. 새 테스트나 런타임 재현은 실행하지 않았다.
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 Goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P1-007` | Medium | 수정 완료 · `P1-R4` 자동 증거 (§9), 최초 정적 확정 이력 유지 | 최초 감지 캐시 저장 경합으로 후발 목표 언어의 예약 유실 | `P1-T2`, `P1-R2` | `P1-R4` 완료, 실제 MySQL 수동 확인 |
|
||||
|
||||
## 6. REV-P1-007 상세
|
||||
|
||||
- 관련 요구사항: `CCT-005`, `CCT-010`, 계획 `P1-R2`의 각 요청 target 보존 목표.
|
||||
- 관련 계약: PRD §4.2 상세 요청 언어만 예약, §6 기존 감지 캐시 재사용.
|
||||
- 근거 파일:
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt:21`: 캐시를 먼저 조회한다.
|
||||
- 같은 파일 `:28`, `:29`: 외부 감지 후 같은 키에 무조건 `save`한다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionResult.kt:15`: 해시/provider/version 유일 키.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/common/BaseEntity.kt:19`: IDENTITY 저장 식별자.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt:398`: 캐시 예외가 후속 post lock보다 먼저 전파된다.
|
||||
- 같은 파일 `:411`, `:417`: 목표 언어 후속 예약은 감지 성공 이후에만 등록된다.
|
||||
|
||||
### 정적 검증 순서
|
||||
|
||||
이 순서는 코드를 따라 확인한 결함 조건이며, 이번 턴에서 실제로 실행한 재현 결과가 아니다.
|
||||
|
||||
1. 원문 언어 NULL이고 감지 캐시가 없는 동일 한국어 게시물에 일본어·영어 상세 요청을 보낸다.
|
||||
2. 두 감지 트랜잭션이 모두 언어 NULL과 캐시 miss를 읽은 뒤 각 detector 호출을 진행한다.
|
||||
3. 첫 detector가 반환하면 캐시 INSERT → 게시물 언어 저장 → 커밋 → 일본어 작업 예약이 진행된다.
|
||||
4. 뒤늦게 반환한 detector는 캐시를 다시 읽거나 원자적으로 합류하지 않고 같은 유일 키를 INSERT한다.
|
||||
5. 중복 키 예외로 두 번째 감지 트랜잭션이 종료되어 영어 목표를 예약하는 콜백에 도달하지 못한다.
|
||||
|
||||
기대 결과는 양쪽 목표 언어의 작업 보존이다. 코드 경로상 결과는 후발 목표의 작업 누락이다.
|
||||
다음 영어 상세 조회로 다시 예약할 수 있으므로 영구적인 데이터 손실이라고 주장하지 않는다.
|
||||
서로 다른 게시물을 같은 본문으로 동시에 신규 작성할 때도 동일한 전역 캐시 키 경합이 발생할 수 있다.
|
||||
|
||||
### 기존 테스트가 통과하는 이유
|
||||
|
||||
`src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt:335`의
|
||||
`shouldPreserveSecondTargetAfterConcurrentDetectionSetsLanguage`는 `ControlledLanguageDetectionCacheService`를 사용한다.
|
||||
같은 파일 `:743`의 `detectWithCache` override는 실제 캐시 SELECT/INSERT 없이 제어된 언어를 반환한다.
|
||||
따라서 이 테스트의 목표 보존 성공은 실제 캐시 유일 키 저장 경합까지 보장하지 않는다.
|
||||
|
||||
### 권장 조치와 판정
|
||||
|
||||
동일 캐시 키 저장을 원자적으로 처리해 이미 저장한 결과로 수렴시키고, 후발 감지 트랜잭션이 rollback-only가 되지 않게 한다.
|
||||
이미 실패한 트랜잭션 내부에서 예외만 잡고 계속 진행하는 방식은 사용하지 않는다.
|
||||
외부 API 호출 동안 게시물 행 잠금을 잡지 않는 현재 기준을 유지한다.
|
||||
실제 캐시 service/repository를 사용하고 detector만 통제하는 두 cache miss 경합 회귀 테스트를 추가한다.
|
||||
|
||||
2026-09-10 — 품질 리뷰, 목표 리뷰, 루트 리뷰에서 위 정적 경로를 교차 확인하여 확정했다.
|
||||
실행 재현은 사용자 지시에 따라 수행하지 않았으며 `P1-R4`의 RED 단계로 남긴다.
|
||||
|
||||
## 7. 계획 전환
|
||||
|
||||
`plan-task.md`의 Phase 1에 신규 `P1-R4`를 추가한다. 기존 `P1-R2`와 `P1-GATE` 완료 체크 및 검증 기록은 유지한다.
|
||||
새 목표: 실제 최초 감지 캐시 경합에서도 두 요청 target을 보존하고 해당 회귀를 방지한다.
|
||||
상세 검증 명령·파일·RED/GREEN/REFACTOR·재검토 조건은 신규 Task에 기록한다.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| Phase 범위와 기존 수정 확인 | 충족 |
|
||||
| 후보 판정 | 확정 1건. afterCommit 후 재잠금 교착 후보는 DB 커밋 후 잠금 해제로 오탐 제외 |
|
||||
| 확정 항목 계획 반영 | 최초 `P1-R4` 신규 등록, 이후 구현·자동 검증·후속 리뷰 완료 |
|
||||
| 실행 범위 명시 | 최초 리뷰는 테스트 미실행. 이후 `P1-R4` 로컬 자동 검증 증거를 §9에 별도 누적 |
|
||||
|
||||
**최초 결론 기록: 수정 Goal 필요.** 당시 최초 감지 캐시 경합 수정과 수정 후 검증이 필요했다.
|
||||
**현재 최종 결론: `REV-P1-007`은 `P1-R4` 로컬 자동 검증 범위에서 수정 완료.**
|
||||
실제 MySQL/Papago/HTTP 검증은 기존 배포 후 체크리스트에서 미완료로 유지한다.
|
||||
|
||||
## 9. P1-R4 수정 후 재판정 · 2026-09-10
|
||||
|
||||
- RED는 실제 캐시 service/repository와 제어된 provider를 사용했다. 두 provider 진입 후 focused 명령이
|
||||
exit 1로 실패했고 `ExecutionException → DataIntegrityViolationException → H2 23505` 캐시 유일 키 충돌을 확인했다.
|
||||
- GREEN은 native MySQL `ON DUPLICATE KEY UPDATE id=id`와 scalar `SELECT ... FOR UPDATE`로 승자 결과에
|
||||
수렴시킨다. audit 시각을 명시하고 DB 예외를 잡아 같은 트랜잭션에서 계속하는 방식은 사용하지 않는다.
|
||||
- 같은 게시물 `ja`/`en` 이중 miss 및 다른 게시물의 동일 본문 경합에서 캐시 1행과 각 target 작업 보존을 확인했다.
|
||||
focused fresh 재실행은 `--rerun-tasks --no-parallel`로 `BUILD SUCCESSFUL` (5분 19초)이었다.
|
||||
- P1 listener/scheduler/materializer 회귀 (1분 09초), P2 상세/API 회귀 (1분 42초), `ktlintCheck` (44초)는
|
||||
모두 `BUILD SUCCESSFUL`이다. 정확한 명령은 [후속 증거](review-evidence.md)에 기록한다.
|
||||
- spec `PASS`: `ses_f7657d350ffe3YUYhEHvHPuikK`. quality `APPROVED`: `ses_f7656994dffeR2odTLswaw6RTo`.
|
||||
- `REV-P1-007`의 실제 캐시 경합 테스트 공백은 위 자동 증거로 보완됐다. H2 `MODE=MySQL`은 MySQL 8/InnoDB의
|
||||
실제 격리·잠금·DDL 증거가 아니다. 실제 MySQL의 순서 제어 경합, audit 컬럼, 감지 중 수정 가능 여부와 stale 차단,
|
||||
실제 Papago/HTTP는 [수동 체크리스트](../plan-task.md)에서 확인해야 한다.
|
||||
- 전체 `./gradlew test`는 생략했다. 공통 캐시 계약·실제 repository 경합·모든 직접 listener/scheduler/materializer
|
||||
테스트·P2 상세/API 소비자가 통과했고 별도의 미해결 공통 경계가 없다는 영향 범위 판단에 따른다.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Phase 2 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 대상 | Phase 2 `P2-T1`, `P2-T2`, `P2-R1~R3`, `P2-GATE` |
|
||||
| 기준 | HEAD `50409e41c0c469529572f5d77033c3cb23d67d22` + 현재 작업 트리 |
|
||||
| 작업 트리 지문 | `3bbb1d90869484073c3af681118a13f5f03c3ccecbe84de16f1568338acd66b7` |
|
||||
| 일자 / 리뷰어 | 2026-09-10 / Codex 및 목표·품질·보안·QA 증거 리뷰어 |
|
||||
| 기준 문서 | [PRD](../prd.md), [구현 계획](../plan-task.md), `docs/sample/sample-review.md` |
|
||||
| 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
생성 커밋 후 번역, 실제 본문 수정 시 재감지, 상세의 요청 언어 예약, 기존 권한/응답 보존을 점검한다.
|
||||
관리자 공용 mutation 경로와 고정 상태 경합의 기존 수정도 확인한다. 사용자 지시에 따라 테스트/컴파일/HTTP 실행은 하지 않는다.
|
||||
소스·테스트·과거 검증 기록을 기준으로 판단하며 새로운 기능을 요구하거나 미실행을 결함으로 만들지 않는다.
|
||||
|
||||
## 3. 판정 기준과 검토 근거
|
||||
|
||||
source 경로는 `src/main/kotlin/kr/co/vividnext/sodalive/`, test 경로는 같은 패키지의 `src/test/kotlin/` 기준이다.
|
||||
|
||||
| 기준 | 근거 | 판정 |
|
||||
|---|---|---|
|
||||
| `CCT-003` 생성 커밋 후 예약 | `explorer/profile/creatorCommunity/CreatorCommunityService.kt:152`, `:175`, `:192` | 충족. 커밋 후 공유 쓰기 진입점을 호출하고 외부 감지는 비동기 listener에서 실행 |
|
||||
| `CCT-004` 실제 본문 변경만 재감지 | 같은 파일 `:214`, `:221` | 충족. 소유권 잠금 조회/refresh 후 문자열 비교, 언어 NULL, 개정 증가 |
|
||||
| 본문 외 고정 상태 변경의 정합성 | 같은 파일 `:264`의 잠금/refresh 및 관리자 공용 경로 | 기존 `P2-R2` 수정 반영 확인 |
|
||||
| `CCT-005` 상세만 요청 언어 예약 | 같은 파일 `:398`, `v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt:128` | 충족. 유효 번역이 없을 때 요청 locale만 전달 |
|
||||
| `CCT-007` 최신 원문 폴백 | 공유 조회 결과를 DTO에 복사하며 유효하지 않은 번역은 사용하지 않음 | 충족 |
|
||||
| `CCT-008` 권한·유료 미리보기 | 레거시 차단 확인 `CreatorCommunityService.kt:390`, v2 접근 검사 `CreatorChannelCommunityQueryService.kt:121`, 기존 마스킹 `:253` | 충족. 번역 선택 후 마스킹 |
|
||||
| `CCT-009` API/언어 규칙 | 기존 `LangContext`를 사용하고 공개 DTO에는 `content`만 복사 | 충족 |
|
||||
|
||||
관리자 생성/수정은 `v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt:47`, `:75`에서
|
||||
기존 공용 서비스를 호출한다. 관리용 조회는 원문을 유지한다. 댓글/답글/구매를 번역 예약 진입점으로 추가하지 않았다.
|
||||
|
||||
확인한 테스트:
|
||||
|
||||
- `content/CreatorCommunityLanguageDetectTest.kt:192` — `shouldRunDetailTranslationPipelineThroughProxyListenerAndScheduler`.
|
||||
- 같은 파일 `:433` — `shouldLeaveNoPostOrTranslationArtifactsWhenProxiedCreateRollsBack`.
|
||||
- 같은 파일 `:461` — `shouldHideStaleTranslationAndScheduleNewTargetsAfterCommittedEnglishModification`.
|
||||
- `explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` — 동일 본문/본문 외 수정의 비예약, 유료·소유자·구매자·차단 조건.
|
||||
- `v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostConcurrencyTest.kt` — 관리자 공용 mutation과 최신 본문 보존.
|
||||
|
||||
## 4. 실행 및 증거 한계
|
||||
|
||||
`git diff`, `rg`, `sed`와 테스트 소스 읽기로 호출·트랜잭션·권한 경계를 확인했다.
|
||||
기존 XML 성공 기록은 [증거 기록](review-evidence.md)에 분리했다. 테스트/컴파일/서버/외부 호출은 실행하지 않았다.
|
||||
|
||||
`shouldRunDetailTranslationPipelineThroughProxyListenerAndScheduler`는 실제 프록시·listener·scheduler를 연결하지만
|
||||
번역 메모리를 직접 저장한 뒤 materializer를 호출한다. 워커 테스트는 별도의 mock 기반 구성요소 검증이다.
|
||||
따라서 한·영·일 각각의 신규 작성부터 실제 provider/워커/번역 행 저장까지 하나로 연결한 실환경 검증을 완료했다고 해석하지 않는다.
|
||||
이는 새 제품 결함으로 확정하지 않고 기존 테스트 서버 수동 체크리스트의 실행 범위로 유지한다.
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
**Phase 2 자체 구현의 추가 확정 발견 사항 없음.**
|
||||
|
||||
Phase 1의 `REV-P1-007`은 상세를 통한 최초 언어 감지에도 영향을 준다. 같은 원인을 Phase 2 결함으로 중복 등록하지 않고
|
||||
[Phase 1 리뷰](phase-1-review.md)의 `P1-R4`에서 수정한다. 이 의존성까지 포함한 전체 번역 흐름을 무조건 PASS로 표현하지 않는다.
|
||||
|
||||
## 6. 후보 판정
|
||||
|
||||
| 후보 | 판정 / 근거 |
|
||||
|---|---|
|
||||
| afterCommit의 REQUIRES_NEW 진입이 이전 post lock과 교착 | 오탐. DB commit 후 콜백이므로 이전 트랜잭션의 DB 잠금은 해제된 상태 |
|
||||
| 관리자 별도 생성/수정에 번역 연결 누락 | 오탐. 공용 레거시 서비스를 호출하므로 중복 연결 불필요 |
|
||||
| 테스트 재실행이 없으므로 구현 실패 | 해당 없음. 사용자 지시이며 정적 리뷰의 실행 한계로만 기록 |
|
||||
|
||||
## 7. 계획 전환
|
||||
|
||||
**전환 항목 없음.** 자체 수정 Task를 억지로 추가하지 않는다. Phase 1 `P1-R4`의 직접 영향 회귀에 상세 파이프라인을 포함한다.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| Phase 2 자체 요구사항 확인 | 충족 |
|
||||
| 추가 확정 결함 | 없음 |
|
||||
| 다른 Phase 의존 결함 기록 | `REV-P1-007` / `P1-R4` |
|
||||
| 새 수정 Task | 해당 없음 |
|
||||
| 검증 범위 명시 | 충족. 소스·기존 테스트/기록 대조, 직접 실행 없음 |
|
||||
|
||||
**최종 결론: Phase 2 자체 기준 충족, 추가 확정 발견 사항 없음.**
|
||||
남은 항목은 Phase 1 수정의 영향 재검토와 기존 테스트 서버 수동 확인이다. 이번 리뷰에서 수정한 제품 코드는 없다.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Phase 3 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 대상 | Phase 3 `P3-T1`, `P3-T2`, `P3-R1`, `P3-GATE` |
|
||||
| 기준 | HEAD `50409e41c0c469529572f5d77033c3cb23d67d22` + 현재 작업 트리 |
|
||||
| 작업 트리 지문 | `3bbb1d90869484073c3af681118a13f5f03c3ccecbe84de16f1568338acd66b7` |
|
||||
| 일자 / 리뷰어 | 2026-09-10 / Codex 및 문맥·목표·보안·QA 증거 리뷰어 |
|
||||
| 기준 문서 | [PRD](../prd.md), [구현 계획](../plan-task.md), `docs/sample/sample-review.md` |
|
||||
| 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
`CCT-006~010`의 목록·미리보기·홈 표면, 저장 번역만 읽는 경계, 권한·페이지·공개 계약 유지를 확인한다.
|
||||
기존 최종 Gate의 실제 실행 미완료 범위도 대조한다. 사용자 지시에 따라 테스트·컴파일·서버·HTTP는 실행하지 않는다.
|
||||
|
||||
## 3. 판정 기준과 코드 대조
|
||||
|
||||
아래 경로는 `src/main/kotlin/kr/co/vividnext/sodalive/` 기준이다.
|
||||
|
||||
| 표면 / 기준 | 코드 근거 | 판정 |
|
||||
|---|---|---|
|
||||
| 레거시 일반·최신 목록 | `explorer/profile/creatorCommunity/CreatorCommunityService.kt:305`, `:359`, `:641`, `:679` | 충족. 표시할 ID를 모아 번역 조회 후 본문만 복사 |
|
||||
| v2 탭 | `v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt:74` | 충족. 반환 페이지 확정 후 번역 조회, 다음 페이지 확인용 추가 행 제외 |
|
||||
| 채널 홈 고정·일반 | `v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt:104`, `:133` → 공용 `findHomeCommunityPosts` | 충족. 두 그룹 모두 같은 읽기 연결 |
|
||||
| 홈 추천 | `v2/recommendation/application/HomeRecommendationQueryService.kt:158` | 충족. 후보 정렬·중복 제거·limit 이후 최종 ID만 처리 |
|
||||
| 팔로잉 소식 | `v2/home/following/application/HomeFollowingQueryService.kt:32`, `:60` | 충족. 커뮤니티 유형만 변경하고 다른 소식 유지 |
|
||||
| 목록 계열 예약 금지 | `v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt:33` | 충족. `findDisplayContents`는 읽기 전용이며 감지·예약·materialize 호출 없음 |
|
||||
| 공개 스키마 | DTO/도메인 `copy(content = ...)` | 충족. 내부 `CreatorCommunityDisplayContent`를 공개 응답으로 추가하지 않음 |
|
||||
| DDL 타입 정합성 | `schema.sql:11`, `CreatorCommunityTranslation.kt`의 sourceHash 매핑 | 기존 `P3-R1`의 VARCHAR(64) 수정 반영 확인 |
|
||||
|
||||
`LiveApiService`와 `ExplorerService`의 간접 목록 호출도 기존 서비스에 연결되어 저장 번역을 사용한다.
|
||||
팔로잉 본문은 `DefaultHomeFollowingQueryRepository.kt:160`, `:251`에서 현재 게시물 내용을 읽으므로
|
||||
소식 발행 당시 저장한 미리보기를 번역 원문으로 사용하지 않는다.
|
||||
|
||||
## 4. 테스트·기록 대조
|
||||
|
||||
test 경로는 `src/test/kotlin/kr/co/vividnext/sodalive/` 기준이다.
|
||||
|
||||
| 검증 대상 | 읽은 기존 테스트 |
|
||||
|---|---|
|
||||
| 배치 크기 1/4에서 SELECT 2회 유지 | `explorer/profile/creatorCommunity/CreatorCommunityTranslationEndToEndTest.kt:66`, `shouldReadTranslationsWithConstantSelectCountAcrossBatchSizes` |
|
||||
| 목록 누락 번역의 작업/이벤트 미생성 | 같은 파일 `:98`, `shouldNotPublishOrScheduleWhenLegacyListReadsMissingTranslation` |
|
||||
| 레거시 필터·마스킹·일괄 조회 | `explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt:579` 이후 관련 테스트 |
|
||||
| v2 페이지 추가 행 제외·홈 두 그룹 | `v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt:180` 이후 관련 테스트 |
|
||||
| 채널 홈 저장 번역 | `v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt:89`, `shouldUseStoredTranslationsForPinnedAndNormalHomeCommunityPosts` |
|
||||
| 추천 최종 배치·팔로잉 커뮤니티 유형만 처리 | `v2/recommendation/application/HomeRecommendationQueryServiceTest.kt:705`, `v2/home/following/application/HomeFollowingQueryServiceTest.kt:123` |
|
||||
|
||||
`git diff`, `rg`, `sed`로 호출부와 테스트를 대조했다. 기존 XML과 레인 결과는 [증거 기록](review-evidence.md)에 있다.
|
||||
이 표는 테스트를 새로 실행했다는 의미가 아니다. 기존 계획의 로컬 성공 기록과 실제 MySQL/Papago/HTTP 미완료 표기는
|
||||
서로 구분되어 있으며, 후자를 완료한 것으로 평가하지 않는다.
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
**확정 발견 사항 없음.** 목록·채널 홈·추천·팔로잉의 저장 번역 적용은 인터뷰에서 확정한 범위에 부합한다.
|
||||
|
||||
## 6. 후보 판정
|
||||
|
||||
| 후보 | 판정 / 근거 |
|
||||
|---|---|
|
||||
| 추천·팔로잉 본문 복사가 유료 전체 본문을 노출 | 오탐. `DefaultHomeRecommendationQueryRepository.kt:893`, `DefaultHomeFollowingQueryRepository.kt:461`의 기존 무료 조건 유지 |
|
||||
| 레거시/v2 마스킹 전에 원문 길이를 사용 | 오탐. 번역 본문을 복사한 뒤 `SelectCommunityPostResponse.kt:44`, `CreatorChannelCommunityQueryService.kt:253`의 기존 마스킹 실행 |
|
||||
| 번역 배치 조회가 작성자별 N+1을 추가 | 오탐. `member` LAZY이며 읽기에서 접근하지 않는다. 서로 다른 작성자의 배치 크기 테스트가 기존에 존재 |
|
||||
| 목록 번역 누락을 처리하기 위해 자동 작업 예약 | 오탐. 공용 읽기 메서드만 사용하고 예약 진입점을 호출하지 않음 |
|
||||
|
||||
## 7. 계획 전환
|
||||
|
||||
**전환 항목 없음.** 추가 수정 Task는 만들지 않는다. Phase 1 결함으로 번역이 아직 생성되지 않은 경우에는
|
||||
이 Phase의 요구대로 원문을 표시한다. 예약 결함은 `P1-R4`에서 처리한다.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| 모든 합의된 목록·미리보기 표면 확인 | 충족 |
|
||||
| 확정 결함 | 없음 |
|
||||
| 후보 판정 완료 | 충족, 위 후보는 오탐 |
|
||||
| 확정 항목 계획 반영 | 해당 없음 |
|
||||
| 실행 범위·기존 미완료 검증 구분 | 충족 |
|
||||
|
||||
**최종 결론: Phase 3 기준 충족, 확정 발견 사항 없음.**
|
||||
실제 DDL·MySQL 동시성·HTTP·Papago 확인은 기존 수동 체크리스트대로 남긴다. 이번 리뷰에서 제품 코드를 수정하지 않았다.
|
||||
@@ -0,0 +1,121 @@
|
||||
# 2026-09-10 Phase별 정적 리뷰 증거
|
||||
|
||||
## 대상과 검증 범위
|
||||
|
||||
아래 지문부터 리뷰 산출물 검증까지는 최초 정적 리뷰 당시 기록이다. `P1-R4` 이후 변경에는 이 지문과 PASS를
|
||||
소급 적용하지 않는다. 현재 최종 판정과 후속 자동 증거는 문서 끝에 별도로 누적한다.
|
||||
|
||||
- 기준 HEAD: `50409e41c0c469529572f5d77033c3cb23d67d22`.
|
||||
- 리뷰 대상은 HEAD 자체가 아닌 현재 수정/미추적 source·test와 `schema.sql` 32개 파일이다.
|
||||
- 위 파일의 경로순 `경로 + NUL + SHA256 + LF`를 결합한 SHA256:
|
||||
`3bbb1d90869484073c3af681118a13f5f03c3ccecbe84de16f1568338acd66b7`.
|
||||
- 사용자 지시: 컴파일·테스트는 이미 통과했으므로 테스트를 직접 실행하지 않는다.
|
||||
- 이번 판정은 소스·테스트·기존 기록의 정적 대조다. 테스트/컴파일/서버/HTTP/Papago/MySQL 실행 판정이 아니다.
|
||||
- 이전 리뷰의 PASS를 재사용하지 않고 현재 작업 트리를 다시 읽었다. 아래 PASS는 정적 리뷰 범위에만 적용한다.
|
||||
|
||||
## 레인 기록
|
||||
|
||||
모든 기록의 기준은 위 전체 HEAD와 작업 트리 지문이다. 소스가 바뀌면 이 결과를 그대로 재사용하지 않는다.
|
||||
|
||||
| 레인 | 판정 | 근거 / 결과 출처 |
|
||||
|---|---|---|
|
||||
| 보안·권한 `security_review` | PASS | 현재 본문 정합성, 번역 선택 후 유료 마스킹, 추천/팔로잉 무료 필터, 관리자 원문 보존 확인. `/root/security_review` 최종 보고, 2026-09-10. |
|
||||
| 목표·제약 `goal_review` | 수정 필요 | Phase 1 캐시 경합으로 요청 target 유실을 독립 확인. Phase 2·3 추가 결함 없음. `/root/goal_review` 최종 보고, 2026-09-10. |
|
||||
| 코드 품질·동시성 `code_review` | 수정 필요 | 감지 캐시 최초 INSERT 경합으로 후발 target 예약 유실. `/root/code_review` 최종 보고, 2026-09-10. Phase 1 보고서에 상세 근거를 기록한다. |
|
||||
| 기존 QA 증거 `qa_evidence_review` | 정적 증거 확인 완료 | 기존 테스트 소스/XML 성공 기록을 확인했다. 실제 캐시 INSERT 경합과 한·영·일 전체 파이프라인의 직접 통합 증거 한계는 별도로 남긴다. `/root/qa_evidence_review` 최종 보고, 2026-09-10. |
|
||||
| 문맥·통합 `context_review` | PASS — Phase 3 범위 | 목록/홈 및 간접 소비자, 공개 스키마·무료 필터·읽기 전용 경계 확인. `/root/context_review` 최종 보고, 2026-09-10. Phase 1·2 전체 판정으로 확대하지 않는다. |
|
||||
|
||||
동적 QA/debugging 감사는 사용자 지시에 따라 재실행하지 않았다. 이를 PASS나 신규 실패로 분류하지 않는다.
|
||||
|
||||
## 기존 실행 증거 표본
|
||||
|
||||
아래는 `build/test-results/test/`의 기존 XML을 읽은 결과이며 현재 턴에서 실행한 테스트가 아니다.
|
||||
|
||||
| XML 파일명 | 테스트 수 | 실패 / 오류 / skip | 기록 timestamp |
|
||||
|---|---:|---|---|
|
||||
| `TEST-kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest.xml` | 12 | 0 / 0 / 0 | 2026-09-10T02:43:16 |
|
||||
| `TEST-kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest.xml` | 16 | 0 / 0 / 0 | 2026-09-10T02:50:05 |
|
||||
| `TEST-kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityTranslationEndToEndTest.xml` | 4 | 0 / 0 / 0 | 2026-09-10T02:44:03 |
|
||||
| `TEST-kr.co.vividnext.sodalive.i18n.translation.TranslationJobWorkerTest.xml` | 7 | 0 / 0 / 0 | 2026-09-10T02:44:03 |
|
||||
| `TEST-kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest.xml` | 15 | 0 / 0 / 0 | 2026-09-10T02:48:44 |
|
||||
| `TEST-kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest.xml` | 2 | 0 / 0 / 0 | 2026-09-10T02:49:09 |
|
||||
|
||||
XML에 source 지문이 포함되어 있다고 가정하지 않는다. 사용자 보고와 계획의 과거 성공 기록을 보조하는 자료로만 사용한다.
|
||||
|
||||
## 최종 정적 판정
|
||||
|
||||
- [Phase 1](phase-1-review.md): 확정 수정 사항 1건 → `P1-R4`.
|
||||
- [Phase 2](phase-2-review.md): 자체 구현 범위 충족, 추가 확정 발견 사항 없음. Phase 1 결함의 상세 예약 영향은 남는다.
|
||||
- [Phase 3](phase-3-review.md): 저장 번역 표시·목록 비예약 범위 충족, 추가 확정 발견 사항 없음.
|
||||
- 이번 리뷰는 테스트·제품 코드·DDL을 변경하지 않았다. Phase 1 수정과 실제 실행 검증은 후속 작업이다.
|
||||
|
||||
## 리뷰 산출물 검증
|
||||
|
||||
- `./gradlew tasks --all` — exit 0, `BUILD SUCCESSFUL`. 저장소 문서 가이드에 따른 task 목록 확인만 수행했다.
|
||||
이 명령으로 컴파일이나 테스트를 실행하지 않았다.
|
||||
- `git diff --check` — exit 0. 새 미추적 리뷰 문서는 별도 공백/개행 검사로 확인했다.
|
||||
- 문서 링크·공백·템플릿 잔여값 검사 — 5개 문서 PASS. `P1-R4`는 Phase 1 안에 한 번만 미완료 Goal로 등록됐다.
|
||||
- 리뷰 전후 source/test/DDL 32개 파일 SHA256 대조 — 동일 지문으로 PASS. 제품 코드·테스트·DDL 수정 없음.
|
||||
|
||||
## P1-R4 수정 후 증거 · 2026-09-10
|
||||
|
||||
이 절은 완료된 구현의 RED/GREEN·자동 회귀·리뷰 결과를 전달받아 동기화한 기록이다.
|
||||
이번 문서 작업에서 제품 테스트를 다시 실행한 결과가 아니며, 위 최초 정적 리뷰의 지문을 새 코드의 지문으로 재사용하지 않는다.
|
||||
|
||||
### 구현과 RED/GREEN
|
||||
|
||||
- RED: 아래 focused 명령은 두 provider 진입 이후 exit 1로 실패했다.
|
||||
`ExecutionException → DataIntegrityViolationException → H2 23505`로 감지 캐시 유일 키 충돌을 확인했다.
|
||||
- GREEN: native MySQL `ON DUPLICATE KEY UPDATE id=id`와 scalar `SELECT ... FOR UPDATE`로 승자 행을
|
||||
현재 읽기한다. native INSERT에 audit 시각을 명시하며 DB 예외를 잡고 후속 예약을 계속하지 않는다.
|
||||
- 실제 캐시 service/repository를 통한 같은 게시물 `ja`/`en` 이중 miss와 다른 게시물의 동일 본문 경합을 검증했다.
|
||||
캐시 1행으로 수렴하고 각 요청의 목표 언어 작업을 보존한다.
|
||||
|
||||
### 자동 검증 명령과 결과
|
||||
|
||||
RED focused 명령 (exit 1, 위 유일 키 충돌):
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectionCacheConcurrencyTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest'
|
||||
```
|
||||
|
||||
GREEN focused fresh 재실행, `BUILD SUCCESSFUL` (5분 19초):
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectionCacheConcurrencyTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --rerun-tasks --no-parallel
|
||||
```
|
||||
|
||||
P1 listener/scheduler/materializer 영향 회귀, `BUILD SUCCESSFUL` (1분 09초):
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
```
|
||||
|
||||
P2 상세/API 소비자 회귀, `BUILD SUCCESSFUL` (1분 42초):
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*'
|
||||
```
|
||||
|
||||
형식 검사, `BUILD SUCCESSFUL` (44초):
|
||||
|
||||
```bash
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
전체 `./gradlew test`는 생략했다. 공통 캐시 계약, 실제 repository 경합, 모든 직접 listener/scheduler/materializer
|
||||
테스트와 P2 상세/API 소비자가 위 명령으로 통과했고 별도로 미해결된 공통 경계가 없었다.
|
||||
과거 전체 빌드 성공을 이번 변경의 전체 테스트 실행으로 간주하지 않는다.
|
||||
|
||||
### 수정 후 리뷰와 현재 최종 판정
|
||||
|
||||
| 리뷰 | 판정 | 출처 |
|
||||
|---|---|---|
|
||||
| spec | PASS | `ses_f7657d350ffe3YUYhEHvHPuikK` |
|
||||
| quality | APPROVED | `ses_f7656994dffeR2odTLswaw6RTo` |
|
||||
|
||||
**현재 최종 판정: 기존 Phase 1 결함 `REV-P1-007`은 `P1-R4` 로컬 자동 검증 범위에서 수정 완료다.**
|
||||
H2 `MODE=MySQL` 결과는 실제 MySQL 8/InnoDB의 유일 키·격리 수준·audit 컬럼·잠금 동작 검증이 아니다.
|
||||
테스트 서버의 순서 제어 이중 miss, 다른 게시물의 공유 캐시 수렴, provider 차단 중 본문 수정과 stale 결과 차단은
|
||||
[계획의 수동 체크리스트](../plan-task.md)에 미완료로 남긴다. 실제 MySQL/Papago/live HTTP, Docker, 배포는
|
||||
검증하지 않았으며 기존 정적 리뷰의 실환경 검증 한계도 유지한다.
|
||||
@@ -0,0 +1,19 @@
|
||||
ALTER TABLE creator_community
|
||||
ADD COLUMN language_code VARCHAR(10) NULL COMMENT '본문 원문 언어 코드',
|
||||
ADD COLUMN content_revision BIGINT NOT NULL DEFAULT 0 COMMENT '본문 개정 번호';
|
||||
|
||||
CREATE TABLE creator_community_translation (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '식별자',
|
||||
creator_community_id BIGINT NOT NULL COMMENT '크리에이터 커뮤니티 게시물 식별자',
|
||||
locale VARCHAR(10) NOT NULL COMMENT '표시 대상 언어 코드',
|
||||
content TEXT NOT NULL COMMENT '번역된 본문',
|
||||
source_revision BIGINT NOT NULL COMMENT '번역 기준 본문 개정 번호',
|
||||
source_hash VARCHAR(64) NOT NULL COMMENT '번역 기준 정규화 원문 해시',
|
||||
source_language VARCHAR(10) NOT NULL COMMENT '번역 기준 원문 언어 코드',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT uk_creator_community_translation_post_locale UNIQUE (creator_community_id, locale),
|
||||
CONSTRAINT fk_creator_community_translation_post
|
||||
FOREIGN KEY (creator_community_id) REFERENCES creator_community (id)
|
||||
) COMMENT = '크리에이터 커뮤니티 게시물 본문 번역';
|
||||
@@ -7,8 +7,11 @@ import kr.co.vividnext.sodalive.content.category.CategoryRepository
|
||||
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
|
||||
import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
@@ -22,8 +25,12 @@ import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.event.TransactionPhase
|
||||
import org.springframework.transaction.event.TransactionalEventListener
|
||||
import org.springframework.transaction.support.TransactionSynchronization
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
import org.springframework.util.LinkedMultiValueMap
|
||||
import org.springframework.web.client.RestTemplate
|
||||
import javax.persistence.EntityManager
|
||||
import javax.persistence.LockModeType
|
||||
|
||||
/**
|
||||
* 텍스트 기반 데이터(콘텐츠, 댓글 등)에 대해 파파고 언어 감지를 요청하기 위한 이벤트.
|
||||
@@ -37,13 +44,16 @@ enum class LanguageDetectTargetType {
|
||||
SERIES,
|
||||
ORIGINAL_WORK,
|
||||
|
||||
CREATOR_CONTENT_CATEGORY
|
||||
CREATOR_CONTENT_CATEGORY,
|
||||
CREATOR_COMMUNITY
|
||||
}
|
||||
|
||||
class LanguageDetectEvent(
|
||||
val id: Long,
|
||||
val query: String,
|
||||
val targetType: LanguageDetectTargetType = LanguageDetectTargetType.CONTENT
|
||||
val targetType: LanguageDetectTargetType = LanguageDetectTargetType.CONTENT,
|
||||
val sourceRevision: Long? = null,
|
||||
val targetLanguage: String? = null
|
||||
)
|
||||
|
||||
data class PapagoLanguageDetectResponse(
|
||||
@@ -60,7 +70,10 @@ class LanguageDetectListener(
|
||||
private val seriesRepository: ContentSeriesRepository,
|
||||
private val originalWorkRepository: OriginalWorkRepository,
|
||||
private val categoryRepository: CategoryRepository,
|
||||
private val creatorCommunityRepository: CreatorCommunityRepository,
|
||||
private val languageDetectionCacheService: LanguageDetectionCacheService,
|
||||
private val creatorCommunityTranslationService: CreatorCommunityTranslationService,
|
||||
private val entityManager: EntityManager,
|
||||
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
|
||||
@@ -95,6 +108,7 @@ class LanguageDetectListener(
|
||||
LanguageDetectTargetType.SERIES -> handleSeriesLanguageDetect(event)
|
||||
LanguageDetectTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageDetect(event)
|
||||
LanguageDetectTargetType.CREATOR_CONTENT_CATEGORY -> handleCreatorContentCategoryLanguageDetect(event)
|
||||
LanguageDetectTargetType.CREATOR_COMMUNITY -> handleCreatorCommunityLanguageDetect(event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,6 +380,53 @@ class LanguageDetectListener(
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleCreatorCommunityLanguageDetect(event: LanguageDetectEvent) {
|
||||
val sourceRevision = event.sourceRevision ?: return
|
||||
val creatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrue(event.id) ?: return
|
||||
if (
|
||||
creatorCommunity.contentRevision != sourceRevision ||
|
||||
creatorCommunity.content != event.query
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!creatorCommunity.languageCode.isNullOrBlank()) {
|
||||
requestTranslationsAfterCommit(event)
|
||||
return
|
||||
}
|
||||
|
||||
val languageCode = detectLanguageCode(event, event.id) ?: return
|
||||
if (languageCode !in getTranslatableLanguageCodes(null)) return
|
||||
|
||||
val currentCreatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(event.id) ?: return
|
||||
entityManager.refresh(currentCreatorCommunity, LockModeType.PESSIMISTIC_WRITE)
|
||||
if (
|
||||
currentCreatorCommunity.contentRevision != sourceRevision ||
|
||||
currentCreatorCommunity.content != event.query
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentCreatorCommunity.languageCode.isNullOrBlank()) {
|
||||
requestTranslationsAfterCommit(event)
|
||||
return
|
||||
}
|
||||
|
||||
currentCreatorCommunity.languageCode = languageCode
|
||||
creatorCommunityRepository.save(currentCreatorCommunity)
|
||||
requestTranslationsAfterCommit(event)
|
||||
}
|
||||
|
||||
private fun requestTranslationsAfterCommit(event: LanguageDetectEvent) {
|
||||
TransactionSynchronizationManager.registerSynchronization(
|
||||
object : TransactionSynchronization {
|
||||
override fun afterCommit() {
|
||||
creatorCommunityTranslationService.requestTranslations(event.id, event.targetLanguage)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun detectLanguageCode(event: LanguageDetectEvent, targetIdForLog: Long): String? {
|
||||
return languageDetectionCacheService.detectWithCache(event.query) {
|
||||
requestPapagoLanguageCode(event.query, targetIdForLog)
|
||||
|
||||
@@ -25,18 +25,22 @@ class LanguageDetectionCacheService(
|
||||
)
|
||||
if (cached != null) return cached.detectedLanguage
|
||||
|
||||
val detectedLanguage = detector()?.takeIf { it.isNotBlank() } ?: return null
|
||||
languageDetectionResultRepository.save(
|
||||
LanguageDetectionResult(
|
||||
val detectedLanguage = detector()?.takeIf { it.isNotBlank() }?.lowercase() ?: return null
|
||||
languageDetectionResultRepository.insertIfAbsent(
|
||||
sourceHash = sourceHash,
|
||||
sourceTextSample = normalizedQuery.take(MAX_SAMPLE_LENGTH),
|
||||
detectedLanguage = detectedLanguage,
|
||||
provider = provider,
|
||||
confidence = null,
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
return requireNotNull(
|
||||
languageDetectionResultRepository.findDetectedLanguageForUpdate(
|
||||
sourceHash = sourceHash,
|
||||
sourceTextSample = normalizedQuery.take(MAX_SAMPLE_LENGTH),
|
||||
detectedLanguage = detectedLanguage.lowercase(),
|
||||
provider = provider,
|
||||
confidence = null,
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
)
|
||||
return detectedLanguage.lowercase()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package kr.co.vividnext.sodalive.content
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Modifying
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
|
||||
interface LanguageDetectionResultRepository : JpaRepository<LanguageDetectionResult, Long> {
|
||||
fun findBySourceHashAndProviderAndNormalizationVersion(
|
||||
@@ -8,4 +11,55 @@ interface LanguageDetectionResultRepository : JpaRepository<LanguageDetectionRes
|
||||
provider: String,
|
||||
normalizationVersion: String
|
||||
): LanguageDetectionResult?
|
||||
|
||||
@Modifying
|
||||
@Query(
|
||||
value = """
|
||||
INSERT INTO language_detection_result (
|
||||
source_hash,
|
||||
source_text_sample,
|
||||
detected_language,
|
||||
provider,
|
||||
confidence,
|
||||
normalization_version,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
:sourceHash,
|
||||
:sourceTextSample,
|
||||
:detectedLanguage,
|
||||
:provider,
|
||||
:confidence,
|
||||
:normalizationVersion,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
) ON DUPLICATE KEY UPDATE id = id
|
||||
""",
|
||||
nativeQuery = true
|
||||
)
|
||||
fun insertIfAbsent(
|
||||
@Param("sourceHash") sourceHash: String,
|
||||
@Param("sourceTextSample") sourceTextSample: String,
|
||||
@Param("detectedLanguage") detectedLanguage: String,
|
||||
@Param("provider") provider: String,
|
||||
@Param("confidence") confidence: Double?,
|
||||
@Param("normalizationVersion") normalizationVersion: String
|
||||
): Int
|
||||
|
||||
@Query(
|
||||
value = """
|
||||
SELECT detected_language
|
||||
FROM language_detection_result
|
||||
WHERE source_hash = :sourceHash
|
||||
AND provider = :provider
|
||||
AND normalization_version = :normalizationVersion
|
||||
FOR UPDATE
|
||||
""",
|
||||
nativeQuery = true
|
||||
)
|
||||
fun findDetectedLanguageForUpdate(
|
||||
@Param("sourceHash") sourceHash: String,
|
||||
@Param("provider") provider: String,
|
||||
@Param("normalizationVersion") normalizationVersion: String
|
||||
): String?
|
||||
}
|
||||
|
||||
+5
-1
@@ -24,7 +24,11 @@ class CreatorCommunity(
|
||||
var isActive: Boolean = true,
|
||||
var isFixed: Boolean = false,
|
||||
@Column(nullable = true)
|
||||
var fixedAt: LocalDateTime? = null
|
||||
var fixedAt: LocalDateTime? = null,
|
||||
@Column(name = "language_code", nullable = true, length = 10)
|
||||
var languageCode: String? = null,
|
||||
@Column(name = "content_revision", nullable = false)
|
||||
var contentRevision: Long = 0
|
||||
) : BaseEntity() {
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "member_id", nullable = false)
|
||||
|
||||
+17
@@ -7,12 +7,29 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommun
|
||||
import kr.co.vividnext.sodalive.member.QMember.member
|
||||
import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Lock
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import javax.persistence.LockModeType
|
||||
|
||||
interface CreatorCommunityRepository : JpaRepository<CreatorCommunity, Long>, CreatorCommunityQueryRepository {
|
||||
fun countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(memberId: Long): Long
|
||||
|
||||
fun findByIdAndIsActiveTrue(postId: Long): CreatorCommunity?
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select c from CreatorCommunity c where c.id = :postId and c.isActive = true")
|
||||
fun findByIdAndIsActiveTrueForUpdate(@Param("postId") postId: Long): CreatorCommunity?
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select c from CreatorCommunity c where c.id = :postId and c.member.id = :memberId")
|
||||
fun findByIdAndMemberIdForUpdate(
|
||||
@Param("postId") postId: Long,
|
||||
@Param("memberId") memberId: Long
|
||||
): CreatorCommunity?
|
||||
}
|
||||
|
||||
interface CreatorCommunityQueryRepository {
|
||||
|
||||
+37
-10
@@ -27,6 +27,7 @@ import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
import kr.co.vividnext.sodalive.utils.validateImage
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
@@ -39,6 +40,8 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import javax.persistence.EntityManager
|
||||
import javax.persistence.LockModeType
|
||||
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
@@ -58,6 +61,8 @@ class CreatorCommunityService(
|
||||
private val messageSource: SodaMessageSource,
|
||||
private val langContext: LangContext,
|
||||
private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService,
|
||||
private val creatorCommunityTranslationService: CreatorCommunityTranslationService,
|
||||
private val entityManager: EntityManager,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val imageBucket: String,
|
||||
@@ -144,6 +149,7 @@ class CreatorCommunityService(
|
||||
)
|
||||
)
|
||||
publishCommunityPostCreatedAfterCommit(post, member)
|
||||
requestTranslationsAfterCommit(post.id!!)
|
||||
}
|
||||
|
||||
private fun publishCommunityPostCreatedAfterCommit(post: CreatorCommunity, member: Member) {
|
||||
@@ -166,6 +172,12 @@ class CreatorCommunityService(
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestTranslationsAfterCommit(postId: Long) {
|
||||
afterCommit {
|
||||
creatorCommunityTranslationService.requestTranslations(postId, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CreatorCommunity.newsContentPreview(): String {
|
||||
if (price <= 0) {
|
||||
return content
|
||||
@@ -199,13 +211,17 @@ class CreatorCommunityService(
|
||||
fun modifyCommunityPost(postImage: MultipartFile?, requestString: String, member: Member) {
|
||||
val request = objectMapper.readValue(requestString, ModifyCommunityPostRequest::class.java)
|
||||
|
||||
val post = repository.findByIdAndMemberId(id = request.creatorCommunityId, memberId = member.id!!)
|
||||
val post = repository.findByIdAndMemberIdForUpdate(postId = request.creatorCommunityId, memberId = member.id!!)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
entityManager.refresh(post, LockModeType.PESSIMISTIC_WRITE)
|
||||
|
||||
postImage?.let { validateImage(it, post.price > 0) }
|
||||
|
||||
if (request.content != null) {
|
||||
if (request.content != null && request.content != post.content) {
|
||||
post.content = request.content
|
||||
post.languageCode = null
|
||||
post.contentRevision += 1
|
||||
requestTranslationsAfterCommit(post.id!!)
|
||||
}
|
||||
|
||||
if (request.isCommentAvailable != null) {
|
||||
@@ -246,8 +262,9 @@ class CreatorCommunityService(
|
||||
|
||||
@Transactional
|
||||
fun updateCommunityPostFixed(request: UpdateCommunityPostFixedRequest, member: Member) {
|
||||
val post = repository.findByIdAndMemberId(id = request.postId, memberId = member.id!!)
|
||||
val post = repository.findByIdAndMemberIdForUpdate(postId = request.postId, memberId = member.id!!)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
entityManager.refresh(post, LockModeType.PESSIMISTIC_WRITE)
|
||||
|
||||
if (request.isFixed) {
|
||||
if (!post.isFixed) {
|
||||
@@ -285,6 +302,8 @@ class CreatorCommunityService(
|
||||
limit = limit,
|
||||
isAdult = isAdult
|
||||
)
|
||||
val displayContents = creatorCommunityTranslationService
|
||||
.findDisplayContents(postList.map { it.id }, langContext.lang.code)
|
||||
|
||||
return postList
|
||||
.map {
|
||||
@@ -337,7 +356,7 @@ class CreatorCommunityService(
|
||||
null
|
||||
}
|
||||
|
||||
it.toCommunityPostListResponse(
|
||||
it.copy(content = displayContents[it.id]?.content ?: it.content).toCommunityPostListResponse(
|
||||
imageHost = imageHost,
|
||||
audioUrl = audioUrl,
|
||||
date = it.date.getTimeAgoString(),
|
||||
@@ -376,6 +395,12 @@ class CreatorCommunityService(
|
||||
throw SodaException(message = String.format(messageTemplate, post.creatorNickname))
|
||||
}
|
||||
|
||||
val displayContent = creatorCommunityTranslationService
|
||||
.findDisplayContents(listOf(post.id), langContext.lang.code)[post.id]
|
||||
if (displayContent?.hasValidTranslation != true) {
|
||||
creatorCommunityTranslationService.requestTranslations(post.id, langContext.lang.code)
|
||||
}
|
||||
|
||||
val isLike = likeRepository.findByPostIdAndMemberId(postId = post.id, memberId = memberId)?.isActive ?: false
|
||||
val likeCount = likeRepository.totalCountCommunityPostLikeByPostId(post.id)
|
||||
val commentCount = if (post.isCommentAvailable) {
|
||||
@@ -417,7 +442,7 @@ class CreatorCommunityService(
|
||||
null
|
||||
}
|
||||
|
||||
return post.toCommunityPostListResponse(
|
||||
return post.copy(content = displayContent?.content ?: post.content).toCommunityPostListResponse(
|
||||
imageHost = imageHost,
|
||||
audioUrl = audioUrl,
|
||||
date = post.date.getTimeAgoString(),
|
||||
@@ -610,11 +635,13 @@ class CreatorCommunityService(
|
||||
isAdult: Boolean
|
||||
): List<GetCommunityPostListResponse> {
|
||||
val postList = repository.getLatestPostListFromCreatorsYouFollow(memberId = memberId, isAdult = isAdult)
|
||||
val visiblePostList = postList.filter {
|
||||
!isBlockedBetweenMembers(memberId = memberId, creatorId = it.creatorId)
|
||||
}
|
||||
val displayContents = creatorCommunityTranslationService
|
||||
.findDisplayContents(visiblePostList.map { it.id }, langContext.lang.code)
|
||||
|
||||
return postList
|
||||
.filter {
|
||||
!isBlockedBetweenMembers(memberId = memberId, creatorId = it.creatorId)
|
||||
}
|
||||
return visiblePostList
|
||||
.map {
|
||||
val isLike =
|
||||
likeRepository.findByPostIdAndMemberId(postId = it.id, memberId = memberId)?.isActive ?: false
|
||||
@@ -649,7 +676,7 @@ class CreatorCommunityService(
|
||||
|
||||
val existOrdered = useCanRepository.isExistCommunityPostOrdered(postId = it.id, memberId = memberId)
|
||||
|
||||
it.toCommunityPostListResponse(
|
||||
it.copy(content = displayContents[it.id]?.content ?: it.content).toCommunityPostListResponse(
|
||||
imageHost = imageHost,
|
||||
audioUrl = null,
|
||||
date = it.date.getTimeAgoString(),
|
||||
|
||||
+2
-1
@@ -18,7 +18,8 @@ enum class LanguageTranslationTargetType {
|
||||
|
||||
ORIGINAL_WORK,
|
||||
|
||||
CREATOR_CONTENT_CATEGORY
|
||||
CREATOR_CONTENT_CATEGORY,
|
||||
CREATOR_COMMUNITY
|
||||
}
|
||||
|
||||
class LanguageTranslationEvent(
|
||||
|
||||
+53
-1
@@ -18,8 +18,13 @@ import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslatio
|
||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
|
||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslationRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import javax.persistence.EntityManager
|
||||
import javax.persistence.LockModeType
|
||||
|
||||
@Service
|
||||
class TranslationReadModelMaterializer(
|
||||
@@ -31,7 +36,10 @@ class TranslationReadModelMaterializer(
|
||||
private val seriesTranslationRepository: SeriesTranslationRepository,
|
||||
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
|
||||
private val originalWorkTranslationRepository: OriginalWorkTranslationRepository,
|
||||
private val categoryTranslationRepository: CategoryTranslationRepository
|
||||
private val categoryTranslationRepository: CategoryTranslationRepository,
|
||||
private val creatorCommunityRepository: CreatorCommunityRepository,
|
||||
private val creatorCommunityTranslationRepository: CreatorCommunityTranslationRepository,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@Transactional
|
||||
fun materialize(resourceType: LanguageTranslationTargetType, resourceId: Long, targetLanguage: String): Boolean {
|
||||
@@ -46,6 +54,9 @@ class TranslationReadModelMaterializer(
|
||||
LanguageTranslationTargetType.SERIES_GENRE -> upsertSeriesGenre(resourceId, targetLanguage, translations)
|
||||
LanguageTranslationTargetType.ORIGINAL_WORK -> upsertOriginalWork(resourceId, targetLanguage, translations)
|
||||
LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY -> upsertCategory(resourceId, targetLanguage, translations)
|
||||
LanguageTranslationTargetType.CREATOR_COMMUNITY -> {
|
||||
return upsertCreatorCommunity(source, targetLanguage.lowercase(), translations)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -180,6 +191,47 @@ class TranslationReadModelMaterializer(
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertCreatorCommunity(
|
||||
source: TranslationSource,
|
||||
targetLanguage: String,
|
||||
translations: Map<String, String>
|
||||
): Boolean {
|
||||
val sourceRevision = source.sourceRevision ?: return false
|
||||
val sourceHash = SourceTextNormalizer.hash(source.fields.single().sourceText)
|
||||
val creatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(source.resourceId) ?: return false
|
||||
entityManager.refresh(creatorCommunity, LockModeType.PESSIMISTIC_WRITE)
|
||||
if (
|
||||
!creatorCommunity.isActive ||
|
||||
creatorCommunity.contentRevision != sourceRevision ||
|
||||
creatorCommunity.languageCode?.lowercase() != source.sourceLanguage.lowercase() ||
|
||||
SourceTextNormalizer.hash(creatorCommunity.content) != sourceHash
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
val existing = creatorCommunityTranslationRepository
|
||||
.findByCreatorCommunityIdAndLocaleForUpdate(source.resourceId, targetLanguage)
|
||||
if (existing == null) {
|
||||
creatorCommunityTranslationRepository.save(
|
||||
CreatorCommunityTranslation(
|
||||
creatorCommunityId = source.resourceId,
|
||||
locale = targetLanguage,
|
||||
content = translations["content"].orEmpty(),
|
||||
sourceRevision = sourceRevision,
|
||||
sourceHash = sourceHash,
|
||||
sourceLanguage = source.sourceLanguage.lowercase()
|
||||
)
|
||||
)
|
||||
} else {
|
||||
existing.content = translations["content"].orEmpty()
|
||||
existing.sourceRevision = sourceRevision
|
||||
existing.sourceHash = sourceHash
|
||||
existing.sourceLanguage = source.sourceLanguage.lowercase()
|
||||
creatorCommunityTranslationRepository.save(existing)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_PROVIDER = "papago"
|
||||
}
|
||||
|
||||
+18
-2
@@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
|
||||
import kr.co.vividnext.sodalive.content.AudioContentRepository
|
||||
import kr.co.vividnext.sodalive.content.category.CategoryRepository
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@@ -19,7 +20,8 @@ data class TranslationSource(
|
||||
val resourceType: LanguageTranslationTargetType,
|
||||
val resourceId: Long,
|
||||
val sourceLanguage: String,
|
||||
val fields: List<TranslationSourceField>
|
||||
val fields: List<TranslationSourceField>,
|
||||
val sourceRevision: Long? = null
|
||||
)
|
||||
|
||||
@Component
|
||||
@@ -30,7 +32,8 @@ class TranslationSourceExtractor(
|
||||
private val seriesRepository: AdminContentSeriesRepository,
|
||||
private val seriesGenreRepository: AdminContentSeriesGenreRepository,
|
||||
private val originalWorkRepository: OriginalWorkRepository,
|
||||
private val categoryRepository: CategoryRepository
|
||||
private val categoryRepository: CategoryRepository,
|
||||
private val creatorCommunityRepository: CreatorCommunityRepository
|
||||
) {
|
||||
fun extract(resourceType: LanguageTranslationTargetType, resourceId: Long): TranslationSource? {
|
||||
return when (resourceType) {
|
||||
@@ -41,6 +44,7 @@ class TranslationSourceExtractor(
|
||||
LanguageTranslationTargetType.SERIES_GENRE -> extractSeriesGenre(resourceId)
|
||||
LanguageTranslationTargetType.ORIGINAL_WORK -> extractOriginalWork(resourceId)
|
||||
LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY -> extractCategory(resourceId)
|
||||
LanguageTranslationTargetType.CREATOR_COMMUNITY -> extractCreatorCommunity(resourceId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,4 +156,16 @@ class TranslationSourceExtractor(
|
||||
fields = listOf(TranslationSourceField("category", category.title))
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractCreatorCommunity(resourceId: Long): TranslationSource? {
|
||||
val creatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrue(resourceId) ?: return null
|
||||
val sourceLanguage = creatorCommunity.languageCode?.takeIf { it.isNotBlank() } ?: return null
|
||||
return TranslationSource(
|
||||
resourceType = LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
||||
resourceId = resourceId,
|
||||
sourceLanguage = sourceLanguage,
|
||||
fields = listOf(TranslationSourceField("content", creatorCommunity.content)),
|
||||
sourceRevision = creatorCommunity.contentRevision
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+23
-4
@@ -21,6 +21,7 @@ import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorCha
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -36,6 +37,7 @@ class CreatorChannelCommunityQueryService(
|
||||
private val audioContentCloudFront: AudioContentCloudFront,
|
||||
private val messageSource: SodaMessageSource,
|
||||
private val langContext: LangContext,
|
||||
private val creatorCommunityTranslationService: CreatorCommunityTranslationService,
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val cloudFrontHost: String
|
||||
) {
|
||||
@@ -69,6 +71,9 @@ class CreatorChannelCommunityQueryService(
|
||||
offset = communityPage.offset,
|
||||
limit = communityPage.fetchLimit
|
||||
)
|
||||
val communityPosts = queryPolicy.limitItems(fetchedPosts, communityPage)
|
||||
val displayContents = creatorCommunityTranslationService
|
||||
.findDisplayContents(communityPosts.map { it.postId }, langContext.lang.code)
|
||||
|
||||
return CreatorChannelCommunityTab(
|
||||
communityPostCount = queryPort.countCommunityPosts(
|
||||
@@ -76,7 +81,9 @@ class CreatorChannelCommunityQueryService(
|
||||
viewerId = viewerId,
|
||||
canViewAdultContent = canViewAdultContent
|
||||
),
|
||||
communityPosts = queryPolicy.limitItems(fetchedPosts, communityPage).map { it.toDomain(viewerId) },
|
||||
communityPosts = communityPosts.map {
|
||||
it.copy(content = displayContents[it.postId]?.content ?: it.content).toDomain(viewerId)
|
||||
},
|
||||
page = communityPage,
|
||||
hasNext = queryPolicy.hasNext(fetchedPosts, communityPage)
|
||||
)
|
||||
@@ -89,7 +96,7 @@ class CreatorChannelCommunityQueryService(
|
||||
canViewAdultContent: Boolean,
|
||||
limit: Int
|
||||
): List<CreatorChannelCommunityPost> {
|
||||
return queryPortProvider.getObject()
|
||||
val posts = queryPortProvider.getObject()
|
||||
.findHomeCommunityPosts(
|
||||
creatorId = creatorId,
|
||||
viewerId = viewerId,
|
||||
@@ -97,7 +104,12 @@ class CreatorChannelCommunityQueryService(
|
||||
canViewAdultContent = canViewAdultContent,
|
||||
limit = limit
|
||||
)
|
||||
.map { it.toDomain(viewerId) }
|
||||
val displayContents = creatorCommunityTranslationService
|
||||
.findDisplayContents(posts.map { it.postId }, langContext.lang.code)
|
||||
|
||||
return posts.map {
|
||||
it.copy(content = displayContents[it.postId]?.content ?: it.content).toDomain(viewerId)
|
||||
}
|
||||
}
|
||||
|
||||
fun getCommunityPostDetail(
|
||||
@@ -113,8 +125,15 @@ class CreatorChannelCommunityQueryService(
|
||||
|
||||
validateNotBlocked(queryPort, viewerId, postRecord.creatorId)
|
||||
|
||||
val locale = langContext.lang.code
|
||||
val displayContent = creatorCommunityTranslationService
|
||||
.findDisplayContents(listOf(postRecord.postId), locale)[postRecord.postId]
|
||||
if (displayContent?.hasValidTranslation != true) {
|
||||
creatorCommunityTranslationService.requestTranslations(postRecord.postId, locale)
|
||||
}
|
||||
|
||||
return CreatorChannelCommunityPostDetail(
|
||||
post = postRecord.toDomain(viewerId),
|
||||
post = postRecord.copy(content = displayContent?.content ?: postRecord.content).toDomain(viewerId),
|
||||
comments = getCommunityComments(postId, viewer, page = 0, size = 20, now = now)
|
||||
)
|
||||
}
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "creator_community_translation",
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(
|
||||
name = "uk_creator_community_translation_post_locale",
|
||||
columnNames = ["creator_community_id", "locale"]
|
||||
)
|
||||
]
|
||||
)
|
||||
class CreatorCommunityTranslation(
|
||||
@Column(name = "creator_community_id", nullable = false)
|
||||
val creatorCommunityId: Long,
|
||||
|
||||
@Column(nullable = false, length = 10)
|
||||
val locale: String,
|
||||
|
||||
@Column(nullable = false, columnDefinition = "text")
|
||||
var content: String,
|
||||
|
||||
@Column(name = "source_revision", nullable = false)
|
||||
var sourceRevision: Long,
|
||||
|
||||
@Column(name = "source_hash", nullable = false, length = 64)
|
||||
var sourceHash: String,
|
||||
|
||||
@Column(name = "source_language", nullable = false, length = 10)
|
||||
var sourceLanguage: String
|
||||
) : BaseEntity()
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Lock
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import javax.persistence.LockModeType
|
||||
|
||||
interface CreatorCommunityTranslationRepository : JpaRepository<CreatorCommunityTranslation, Long> {
|
||||
fun findByCreatorCommunityIdAndLocale(creatorCommunityId: Long, locale: String): CreatorCommunityTranslation?
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query(
|
||||
"select t from CreatorCommunityTranslation t " +
|
||||
"where t.creatorCommunityId = :creatorCommunityId and t.locale = :locale"
|
||||
)
|
||||
fun findByCreatorCommunityIdAndLocaleForUpdate(
|
||||
@Param("creatorCommunityId") creatorCommunityId: Long,
|
||||
@Param("locale") locale: String
|
||||
): CreatorCommunityTranslation?
|
||||
|
||||
fun findByCreatorCommunityIdInAndLocale(
|
||||
creatorCommunityIds: Collection<Long>,
|
||||
locale: String
|
||||
): List<CreatorCommunityTranslation>
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application
|
||||
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationReadModelMaterializer
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceExtractor
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslationRepository
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import javax.persistence.EntityManager
|
||||
import javax.persistence.LockModeType
|
||||
|
||||
@Service
|
||||
class CreatorCommunityTranslationService(
|
||||
private val creatorCommunityRepository: CreatorCommunityRepository,
|
||||
private val creatorCommunityTranslationRepository: CreatorCommunityTranslationRepository,
|
||||
private val sourceExtractor: TranslationSourceExtractor,
|
||||
private val materializer: TranslationReadModelMaterializer,
|
||||
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun findDisplayContents(postIds: List<Long>, locale: String): Map<Long, CreatorCommunityDisplayContent> {
|
||||
if (postIds.isEmpty()) return emptyMap()
|
||||
|
||||
val normalizedLocale = locale.lowercase()
|
||||
val posts = creatorCommunityRepository.findAllById(postIds).filter { it.isActive }
|
||||
val translations = creatorCommunityTranslationRepository
|
||||
.findByCreatorCommunityIdInAndLocale(posts.mapNotNull { it.id }, normalizedLocale)
|
||||
.associateBy { it.creatorCommunityId }
|
||||
|
||||
return posts.associate { post ->
|
||||
post.id!! to translations[post.id].displayContentOr(post, normalizedLocale)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
fun requestTranslations(postId: Long, targetLanguage: String? = null) {
|
||||
val creatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(postId) ?: return
|
||||
entityManager.refresh(creatorCommunity, LockModeType.PESSIMISTIC_WRITE)
|
||||
|
||||
val source = sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId)
|
||||
if (source == null) {
|
||||
requestLanguageDetection(postId, targetLanguage)
|
||||
return
|
||||
}
|
||||
|
||||
val sourceLanguage = source.sourceLanguage.lowercase()
|
||||
val targetLanguages = targetLanguage?.let { setOf(it.lowercase()) }
|
||||
?: getTranslatableLanguageCodes(sourceLanguage)
|
||||
|
||||
targetLanguages
|
||||
.filter { it != sourceLanguage }
|
||||
.forEach { language ->
|
||||
if (!materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, language)) {
|
||||
resourceTranslationJobScheduler.scheduleResourceTranslation(
|
||||
resourceType = LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
||||
resourceId = postId,
|
||||
targetLanguage = language
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestLanguageDetection(postId: Long, targetLanguage: String?) {
|
||||
val creatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrue(postId) ?: return
|
||||
if (!creatorCommunity.languageCode.isNullOrBlank() || creatorCommunity.content.isBlank()) return
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageDetectEvent(
|
||||
id = postId,
|
||||
query = creatorCommunity.content,
|
||||
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||
sourceRevision = creatorCommunity.contentRevision,
|
||||
targetLanguage = targetLanguage
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun CreatorCommunityTranslation?.displayContentOr(
|
||||
creatorCommunity: CreatorCommunity,
|
||||
locale: String
|
||||
): CreatorCommunityDisplayContent {
|
||||
val sourceLanguage = creatorCommunity.languageCode?.lowercase()
|
||||
return if (
|
||||
this != null &&
|
||||
sourceLanguage != null &&
|
||||
sourceLanguage != locale &&
|
||||
sourceRevision == creatorCommunity.contentRevision &&
|
||||
sourceLanguage == this.sourceLanguage.lowercase() &&
|
||||
sourceHash == SourceTextNormalizer.hash(creatorCommunity.content)
|
||||
) {
|
||||
CreatorCommunityDisplayContent(content = content, hasValidTranslation = true)
|
||||
} else {
|
||||
CreatorCommunityDisplayContent(content = creatorCommunity.content, hasValidTranslation = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class CreatorCommunityDisplayContent(
|
||||
val content: String,
|
||||
val hasValidTranslation: Boolean
|
||||
)
|
||||
+22
-1
@@ -1,9 +1,12 @@
|
||||
package kr.co.vividnext.sodalive.v2.home.following.application
|
||||
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.member.Gender
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowing
|
||||
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingQueryPort
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -16,6 +19,8 @@ import java.time.LocalDateTime
|
||||
class HomeFollowingQueryService(
|
||||
private val queryPort: HomeFollowingQueryPort,
|
||||
private val memberContentPreferenceService: MemberContentPreferenceService,
|
||||
private val langContext: LangContext,
|
||||
private val creatorCommunityTranslationService: CreatorCommunityTranslationService,
|
||||
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(Clock.systemUTC()) }
|
||||
) {
|
||||
fun findHomeFollowing(member: Member): HomeFollowing {
|
||||
@@ -24,6 +29,15 @@ class HomeFollowingQueryService(
|
||||
val effectiveViewerGender = member.effectiveGender()
|
||||
val isViewerCreator = member.role == MemberRole.CREATOR
|
||||
val now = nowProvider()
|
||||
val recentNews = queryPort.findRecentNews(memberId, canViewAdultContent, now, RECENT_NEWS_LIMIT)
|
||||
val communityPostIds = recentNews.mapNotNull { news ->
|
||||
if (news.type == FollowingNewsType.COMMUNITY_POST) news.communityPost?.postId else null
|
||||
}
|
||||
val displayContents = if (communityPostIds.isEmpty()) {
|
||||
emptyMap()
|
||||
} else {
|
||||
creatorCommunityTranslationService.findDisplayContents(communityPostIds, langContext.lang.code)
|
||||
}
|
||||
|
||||
return HomeFollowing(
|
||||
followingCreators = queryPort.findFollowingCreators(memberId, FOLLOWING_CREATORS_LIMIT),
|
||||
@@ -43,7 +57,14 @@ class HomeFollowingQueryService(
|
||||
now,
|
||||
MONTHLY_SCHEDULES_LIMIT
|
||||
),
|
||||
recentNews = queryPort.findRecentNews(memberId, canViewAdultContent, now, RECENT_NEWS_LIMIT)
|
||||
recentNews = recentNews.map { news ->
|
||||
if (news.type != FollowingNewsType.COMMUNITY_POST) return@map news
|
||||
|
||||
val communityPost = news.communityPost ?: return@map news
|
||||
displayContents[communityPost.postId]
|
||||
?.let { displayContent -> news.copy(communityPost = communityPost.copy(content = displayContent.content)) }
|
||||
?: news
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+15
-2
@@ -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<Long>()
|
||||
|
||||
return snapshots.mapNotNull { snapshot ->
|
||||
val recommendations = snapshots.mapNotNull { snapshot ->
|
||||
detailsById[snapshot.targetId]?.takeIf { selectedCreatorIds.add(it.creatorId) }
|
||||
}.take(limit)
|
||||
if (recommendations.isEmpty()) return emptyList()
|
||||
|
||||
val displayContents = creatorCommunityTranslationService.findDisplayContents(
|
||||
recommendations.map { it.communityId },
|
||||
langContext.lang.code
|
||||
)
|
||||
return recommendations.map { recommendation ->
|
||||
displayContents[recommendation.communityId]
|
||||
?.let { displayContent -> recommendation.copy(content = displayContent.content) }
|
||||
?: recommendation
|
||||
}
|
||||
}
|
||||
|
||||
private fun findPopularCommunitySnapshotsWithFallback(): List<RecommendationSnapshotRecord> {
|
||||
|
||||
+813
@@ -0,0 +1,813 @@
|
||||
package kr.co.vividnext.sodalive.content
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||
import kr.co.vividnext.sodalive.can.use.UseCanRepository
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityCommentRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLikeRepository
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobRepository
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationMemory
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationMemoryRepository
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationReadModelMaterializer
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceExtractor
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslationRepository
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.aop.support.AopUtils
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.boot.test.context.TestConfiguration
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.context.annotation.Primary
|
||||
import org.springframework.scheduling.annotation.EnableAsync
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
|
||||
import org.springframework.test.util.AopTestUtils
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.Callable
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@DataJpaTest(
|
||||
properties = [
|
||||
"spring.cache.type=none",
|
||||
"spring.datasource.url=" +
|
||||
"jdbc:h2:mem:creator-community-language-detect;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE",
|
||||
"cloud.naver.papago-client-id=test-client-id",
|
||||
"cloud.naver.papago-client-secret=test-client-secret"
|
||||
]
|
||||
)
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import(
|
||||
QueryDslConfig::class,
|
||||
AudioContentThemeQueryRepository::class,
|
||||
LanguageDetectListener::class,
|
||||
CreatorCommunityTranslationService::class,
|
||||
TranslationSourceExtractor::class,
|
||||
TranslationReadModelMaterializer::class,
|
||||
ResourceTranslationJobScheduler::class,
|
||||
TranslationJobScheduler::class,
|
||||
CreatorCommunityLanguageDetectTest.AsyncConfiguration::class
|
||||
)
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
class CreatorCommunityLanguageDetectTest @Autowired constructor(
|
||||
private val creatorCommunityRepository: CreatorCommunityRepository,
|
||||
private val memberRepository: MemberRepository,
|
||||
private val translationJobRepository: TranslationJobRepository,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
private val languageDetectListener: LanguageDetectListener,
|
||||
private val creatorCommunityTranslationService: CreatorCommunityTranslationService,
|
||||
private val creatorCommunityTranslationRepository: CreatorCommunityTranslationRepository,
|
||||
private val creatorCommunityService: CreatorCommunityService,
|
||||
private val languageDetectionCacheService: ControlledLanguageDetectionCacheService,
|
||||
private val translationMemoryRepository: TranslationMemoryRepository,
|
||||
private val translationReadModelMaterializer: TranslationReadModelMaterializer,
|
||||
private val detailPipelineTaskExecutor: DetailPipelineTaskExecutor,
|
||||
transactionManager: PlatformTransactionManager
|
||||
) {
|
||||
private val transactionTemplate = TransactionTemplate(transactionManager)
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
languageDetectionCacheService.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 감지 이벤트는 기존 기본값을 유지하면서 현재 본문 메타데이터를 전달한다")
|
||||
fun shouldKeepLegacyDefaultsAndDeclareCreatorCommunityMetadata() {
|
||||
val fields = LanguageDetectEvent::class.java.declaredFields.map { it.name }
|
||||
|
||||
assertTrue(LanguageDetectTargetType.values().any { it.name == "CREATOR_COMMUNITY" })
|
||||
assertTrue(fields.contains("sourceRevision"))
|
||||
assertTrue(fields.contains("targetLanguage"))
|
||||
assertEquals(LanguageDetectTargetType.CONTENT, LanguageDetectEvent(1L, "legacy").targetType)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("감지 후 번역 요청 서비스는 독립 프록시 트랜잭션을 사용한다")
|
||||
fun shouldExposeProxiedRequiresNewTranslationRequestEntryPoint() {
|
||||
val annotation = AopUtils.getTargetClass(creatorCommunityTranslationService)
|
||||
.getMethod("requestTranslations", Long::class.javaPrimitiveType, String::class.java)
|
||||
.getAnnotation(Transactional::class.java)
|
||||
|
||||
assertTrue(AopUtils.isAopProxy(creatorCommunityTranslationService))
|
||||
assertEquals(Propagation.REQUIRES_NEW, annotation.propagation)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("상세 파이프라인 executor는 이전 작업 완료로 다음 작업 latch를 해제하지 않는다")
|
||||
fun shouldNotCompleteNextTaskWhenPreviousTaskFinishesAfterLatchIsReassigned() {
|
||||
val executor = DetailPipelineTaskExecutor()
|
||||
val firstStarted = CountDownLatch(1)
|
||||
val firstRelease = CountDownLatch(1)
|
||||
val secondStarted = CountDownLatch(1)
|
||||
val secondRelease = CountDownLatch(1)
|
||||
executor.initialize()
|
||||
|
||||
try {
|
||||
executor.prepareNextTask()
|
||||
executor.execute {
|
||||
firstStarted.countDown()
|
||||
check(firstRelease.await(5, TimeUnit.SECONDS))
|
||||
}
|
||||
assertTrue(firstStarted.await(5, TimeUnit.SECONDS))
|
||||
|
||||
executor.prepareNextTask()
|
||||
executor.execute {
|
||||
secondStarted.countDown()
|
||||
check(secondRelease.await(5, TimeUnit.SECONDS))
|
||||
}
|
||||
firstRelease.countDown()
|
||||
assertTrue(secondStarted.await(5, TimeUnit.SECONDS))
|
||||
|
||||
assertEquals(false, executor.awaitTaskCompletion(0, TimeUnit.MILLISECONDS))
|
||||
} finally {
|
||||
firstRelease.countDown()
|
||||
secondRelease.countDown()
|
||||
executor.shutdown()
|
||||
assertTrue(executor.threadPoolExecutor.awaitTermination(5, TimeUnit.SECONDS))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커밋 후 감지는 요청 언어의 번역 작업을 실제로 저장한다")
|
||||
fun shouldPersistRequestedTargetJobAfterCommittedDetection() {
|
||||
val postId = persistPost(content = "current source", contentRevision = 3)
|
||||
givenDetectedLanguage("current source", "ko")
|
||||
|
||||
publishAfterCommit(
|
||||
LanguageDetectEvent(
|
||||
id = postId,
|
||||
query = "current source",
|
||||
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||
sourceRevision = 3,
|
||||
targetLanguage = "ja"
|
||||
)
|
||||
)
|
||||
|
||||
assertJobTargets(postId, setOf("ja"))
|
||||
assertPost(postId, content = "current source", languageCode = "ko")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("상세 조회는 실제 프록시 감지와 작업 저장 뒤 재료화한 요청 언어 번역문을 반환한다")
|
||||
fun shouldRunDetailTranslationPipelineThroughProxyListenerAndScheduler() {
|
||||
val sourceContent = "detail source content"
|
||||
val postId = persistPost(content = sourceContent, contentRevision = 0, isCommentAvailable = false)
|
||||
val blockingDetection = languageDetectionCacheService.block(sourceContent, "en")
|
||||
detailPipelineTaskExecutor.prepareNextTask()
|
||||
|
||||
try {
|
||||
assertTrue(AopUtils.isAopProxy(creatorCommunityService))
|
||||
assertTrue(AopUtils.isAopProxy(creatorCommunityTranslationService))
|
||||
|
||||
val initialDetail = creatorCommunityService.getCommunityPostDetail(
|
||||
postId = postId,
|
||||
memberId = 999L,
|
||||
timezone = "Asia/Seoul",
|
||||
isAdult = true
|
||||
)
|
||||
|
||||
assertEquals(sourceContent, initialDetail.content)
|
||||
assertTrue(blockingDetection.started.await(5, TimeUnit.SECONDS))
|
||||
assertEquals(emptySet<String>(), jobTargets(postId))
|
||||
} finally {
|
||||
blockingDetection.release.countDown()
|
||||
}
|
||||
|
||||
assertTrue(detailPipelineTaskExecutor.awaitTaskCompletion(5, TimeUnit.SECONDS))
|
||||
assertPost(postId, content = sourceContent, languageCode = "en")
|
||||
assertEquals(setOf("ko"), jobTargets(postId))
|
||||
assertEquals(1, jobCount(postId))
|
||||
|
||||
val repeatedDetail = creatorCommunityService.getCommunityPostDetail(
|
||||
postId = postId,
|
||||
memberId = 999L,
|
||||
timezone = "Asia/Seoul",
|
||||
isAdult = true
|
||||
)
|
||||
assertEquals(sourceContent, repeatedDetail.content)
|
||||
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
try {
|
||||
val repeatedDetails = listOf(
|
||||
executor.submit(
|
||||
Callable {
|
||||
creatorCommunityService.getCommunityPostDetail(postId, 999L, "Asia/Seoul", true).content
|
||||
}
|
||||
),
|
||||
executor.submit(
|
||||
Callable {
|
||||
creatorCommunityService.getCommunityPostDetail(postId, 999L, "Asia/Seoul", true).content
|
||||
}
|
||||
)
|
||||
).map { it.get(5, TimeUnit.SECONDS) }
|
||||
|
||||
assertEquals(listOf(sourceContent, sourceContent), repeatedDetails)
|
||||
} finally {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
assertEquals(1, jobCount(postId))
|
||||
|
||||
transactionTemplate.executeWithoutResult {
|
||||
translationMemoryRepository.save(
|
||||
TranslationMemory(
|
||||
sourceHash = SourceTextNormalizer.hash(sourceContent),
|
||||
sourceText = sourceContent,
|
||||
sourceLanguage = "en",
|
||||
targetLanguage = "ko",
|
||||
translatedText = "translated detail content",
|
||||
provider = TranslationReadModelMaterializer.DEFAULT_PROVIDER,
|
||||
providerVersion = "nmt-v1"
|
||||
)
|
||||
)
|
||||
}
|
||||
assertTrue(
|
||||
translationReadModelMaterializer.materialize(
|
||||
LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
||||
postId,
|
||||
"ko"
|
||||
)
|
||||
)
|
||||
|
||||
val translatedDetail = creatorCommunityService.getCommunityPostDetail(
|
||||
postId = postId,
|
||||
memberId = 999L,
|
||||
timezone = "Asia/Seoul",
|
||||
isAdult = true
|
||||
)
|
||||
|
||||
assertEquals("translated detail content", translatedDetail.content)
|
||||
assertEquals(1, jobCount(postId))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("전체 범위 감지는 원문 언어를 제외한 실제 번역 작업을 저장한다")
|
||||
fun shouldPersistAllNonSourceTargetJobsAfterCommittedDetection() {
|
||||
val postId = persistPost(content = "English source", contentRevision = 1)
|
||||
givenDetectedLanguage("English source", "en")
|
||||
|
||||
publishAfterCommit(
|
||||
LanguageDetectEvent(
|
||||
id = postId,
|
||||
query = "English source",
|
||||
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||
sourceRevision = 1
|
||||
)
|
||||
)
|
||||
|
||||
assertJobTargets(postId, setOf("ja", "ko"))
|
||||
assertPost(postId, content = "English source", languageCode = "en")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("감지 중 변경된 본문은 refresh 뒤에도 이전 언어와 작업을 적용하지 않는다")
|
||||
fun shouldRejectStalePostAfterBlockedDetection() {
|
||||
val postId = persistPost(content = "before", contentRevision = 0)
|
||||
val blockingDetection = languageDetectionCacheService.block("before", "ko")
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
|
||||
try {
|
||||
val completion = executor.submit {
|
||||
detectInTransaction(
|
||||
LanguageDetectEvent(
|
||||
id = postId,
|
||||
query = "before",
|
||||
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||
sourceRevision = 0,
|
||||
targetLanguage = "ja"
|
||||
)
|
||||
)
|
||||
}
|
||||
assertTrue(blockingDetection.started.await(5, TimeUnit.SECONDS))
|
||||
updatePost(postId, content = "after", contentRevision = 1)
|
||||
blockingDetection.release.countDown()
|
||||
completion.get(5, TimeUnit.SECONDS)
|
||||
|
||||
assertPost(postId, content = "after", languageCode = null)
|
||||
assertEquals(emptySet<String>(), jobTargets(postId))
|
||||
} finally {
|
||||
blockingDetection.release.countDown()
|
||||
executor.shutdownNow()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("동시 감지 중 첫 감지가 언어를 저장해도 두 번째 요청 언어를 예약한다")
|
||||
fun shouldPreserveSecondTargetAfterConcurrentDetectionSetsLanguage() {
|
||||
val postId = persistPost(content = "concurrent source", contentRevision = 0)
|
||||
val concurrentDetection = languageDetectionCacheService.blockConcurrent("concurrent source", "ko")
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
|
||||
try {
|
||||
val first = executor.submit {
|
||||
detectInTransaction(
|
||||
LanguageDetectEvent(
|
||||
id = postId,
|
||||
query = "concurrent source",
|
||||
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||
sourceRevision = 0,
|
||||
targetLanguage = "ja"
|
||||
)
|
||||
)
|
||||
}
|
||||
assertTrue(concurrentDetection.firstStarted.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val second = executor.submit {
|
||||
detectInTransaction(
|
||||
LanguageDetectEvent(
|
||||
id = postId,
|
||||
query = "concurrent source",
|
||||
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||
sourceRevision = 0,
|
||||
targetLanguage = "en"
|
||||
)
|
||||
)
|
||||
}
|
||||
assertTrue(concurrentDetection.secondStarted.await(5, TimeUnit.SECONDS))
|
||||
|
||||
concurrentDetection.firstRelease.countDown()
|
||||
first.get(5, TimeUnit.SECONDS)
|
||||
concurrentDetection.secondRelease.countDown()
|
||||
second.get(5, TimeUnit.SECONDS)
|
||||
|
||||
assertPost(postId, content = "concurrent source", languageCode = "ko")
|
||||
assertJobTargets(postId, setOf("en", "ja"))
|
||||
} finally {
|
||||
concurrentDetection.firstRelease.countDown()
|
||||
concurrentDetection.secondRelease.countDown()
|
||||
executor.shutdownNow()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("언어가 이미 확정된 동시 목표 이벤트는 둘 다 번역 작업으로 이어진다")
|
||||
fun shouldContinueBothRequestedTargetsWhenLanguageIsAlreadyKnown() {
|
||||
val postId = persistPost(content = "known source", contentRevision = 2, languageCode = "ko")
|
||||
|
||||
publishAfterCommit(
|
||||
LanguageDetectEvent(
|
||||
id = postId,
|
||||
query = "known source",
|
||||
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||
sourceRevision = 2,
|
||||
targetLanguage = "ja"
|
||||
)
|
||||
)
|
||||
publishAfterCommit(
|
||||
LanguageDetectEvent(
|
||||
id = postId,
|
||||
query = "known source",
|
||||
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||
sourceRevision = 2,
|
||||
targetLanguage = "en"
|
||||
)
|
||||
)
|
||||
|
||||
assertJobTargets(postId, setOf("en", "ja"))
|
||||
assertTrue(languageDetectionCacheService.queries.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("빈 본문과 미지원 감지값과 감지 실패는 원문과 작업 상태를 유지한다")
|
||||
fun shouldKeepOriginalWhenDetectionCannotProduceSupportedLanguage() {
|
||||
val blankPostId = persistPost(content = " ", contentRevision = 0)
|
||||
val unsupportedPostId = persistPost(content = "unsupported", contentRevision = 0)
|
||||
val failedPostId = persistPost(content = "provider failure", contentRevision = 0)
|
||||
givenDetectedLanguage("unsupported", "fr")
|
||||
|
||||
publishAfterCommit(communityEvent(blankPostId, " ", 0))
|
||||
publishAfterCommit(communityEvent(unsupportedPostId, "unsupported", 0))
|
||||
publishAfterCommit(communityEvent(failedPostId, "provider failure", 0))
|
||||
|
||||
assertTrue(await { languageDetectionCacheService.queries.contains("unsupported") })
|
||||
assertTrue(await { languageDetectionCacheService.queries.contains("provider failure") })
|
||||
assertPost(blankPostId, content = " ", languageCode = null)
|
||||
assertPost(unsupportedPostId, content = "unsupported", languageCode = null)
|
||||
assertPost(failedPostId, content = "provider failure", languageCode = null)
|
||||
assertEquals(emptySet<String>(), jobTargets(blankPostId))
|
||||
assertEquals(emptySet<String>(), jobTargets(unsupportedPostId))
|
||||
assertEquals(emptySet<String>(), jobTargets(failedPostId))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("실제 프록시 생성 트랜잭션이 롤백되면 게시글과 번역 후속 작업을 남기지 않는다")
|
||||
fun shouldLeaveNoPostOrTranslationArtifactsWhenProxiedCreateRollsBack() {
|
||||
val creator = persistCreator()
|
||||
val content = "rolled back community ${UUID.randomUUID()}"
|
||||
val postId = transactionTemplate.execute { status ->
|
||||
creatorCommunityService.createCommunityPost(
|
||||
audioFile = null,
|
||||
postImage = null,
|
||||
requestString = createRequest(content),
|
||||
member = creator
|
||||
)
|
||||
val post = creatorCommunityRepository.findAll().single { it.content == content }
|
||||
status.setRollbackOnly()
|
||||
post.id!!
|
||||
}!!
|
||||
|
||||
assertTrue(AopUtils.isAopProxy(creatorCommunityService))
|
||||
assertTrue(transactionTemplate.execute { creatorCommunityRepository.findById(postId).isEmpty }!!)
|
||||
assertEquals(emptySet<String>(), jobTargets(postId))
|
||||
assertTrue(
|
||||
transactionTemplate.execute {
|
||||
creatorCommunityTranslationRepository.findAll().none { it.creatorCommunityId == postId }
|
||||
}!!
|
||||
)
|
||||
assertTrue(languageDetectionCacheService.queries.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("실제 영문 본문 수정은 이전 번역을 숨기고 커밋 후 영문 감지와 새 작업으로 이어진다")
|
||||
fun shouldHideStaleTranslationAndScheduleNewTargetsAfterCommittedEnglishModification() {
|
||||
val creator = persistCreator()
|
||||
val koreanContent = "기존 한국어 본문"
|
||||
val englishContent = "current English community source"
|
||||
val postId = transactionTemplate.execute {
|
||||
val post = creatorCommunityRepository.save(
|
||||
CreatorCommunity(
|
||||
content = koreanContent,
|
||||
price = 0,
|
||||
isCommentAvailable = true,
|
||||
isAdult = false,
|
||||
languageCode = "ko",
|
||||
contentRevision = 0
|
||||
).apply {
|
||||
member = creator
|
||||
}
|
||||
)
|
||||
creatorCommunityTranslationRepository.save(
|
||||
CreatorCommunityTranslation(
|
||||
creatorCommunityId = post.id!!,
|
||||
locale = "en",
|
||||
content = "old English translation",
|
||||
sourceRevision = 0,
|
||||
sourceHash = SourceTextNormalizer.hash(koreanContent),
|
||||
sourceLanguage = "ko"
|
||||
)
|
||||
)
|
||||
post.id!!
|
||||
}!!
|
||||
val blockingDetection = languageDetectionCacheService.block(englishContent, "en")
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
|
||||
try {
|
||||
val modification = executor.submit {
|
||||
transactionTemplate.executeWithoutResult {
|
||||
creatorCommunityService.modifyCommunityPost(
|
||||
postImage = null,
|
||||
requestString = modifyRequest(postId, englishContent),
|
||||
member = creator
|
||||
)
|
||||
}
|
||||
}
|
||||
assertTrue(blockingDetection.started.await(5, TimeUnit.SECONDS))
|
||||
|
||||
transactionTemplate.executeWithoutResult {
|
||||
val post = creatorCommunityRepository.findById(postId).orElseThrow()
|
||||
assertEquals(englishContent, post.content)
|
||||
assertNull(post.languageCode)
|
||||
assertEquals(1, post.contentRevision)
|
||||
assertEquals(
|
||||
englishContent,
|
||||
creatorCommunityTranslationService.findDisplayContents(listOf(postId), "en")[postId]?.content
|
||||
)
|
||||
assertEquals(
|
||||
"old English translation",
|
||||
creatorCommunityTranslationRepository
|
||||
.findByCreatorCommunityIdAndLocale(postId, "en")
|
||||
?.content
|
||||
)
|
||||
assertEquals(emptySet<String>(), jobTargets(postId))
|
||||
}
|
||||
|
||||
blockingDetection.release.countDown()
|
||||
modification.get(5, TimeUnit.SECONDS)
|
||||
|
||||
assertTrue(
|
||||
await {
|
||||
transactionTemplate.execute {
|
||||
creatorCommunityRepository.findById(postId).orElseThrow().languageCode == "en"
|
||||
} == true
|
||||
}
|
||||
)
|
||||
assertPost(postId, content = englishContent, languageCode = "en")
|
||||
assertJobTargets(postId, setOf("ja", "ko"))
|
||||
} finally {
|
||||
blockingDetection.release.countDown()
|
||||
executor.shutdownNow()
|
||||
}
|
||||
}
|
||||
|
||||
private fun communityEvent(postId: Long, content: String, sourceRevision: Long): LanguageDetectEvent {
|
||||
return LanguageDetectEvent(
|
||||
id = postId,
|
||||
query = content,
|
||||
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||
sourceRevision = sourceRevision
|
||||
)
|
||||
}
|
||||
|
||||
private fun givenDetectedLanguage(content: String, detectedLanguage: String) {
|
||||
languageDetectionCacheService.detections[content] = detectedLanguage
|
||||
}
|
||||
|
||||
private fun createRequest(content: String): String {
|
||||
return """{"content":"$content","price":0,"isCommentAvailable":true,"isAdult":false}"""
|
||||
}
|
||||
|
||||
private fun modifyRequest(postId: Long, content: String): String {
|
||||
return """{"creatorCommunityId":$postId,"content":"$content"}"""
|
||||
}
|
||||
|
||||
private fun publishAfterCommit(event: LanguageDetectEvent) {
|
||||
transactionTemplate.executeWithoutResult {
|
||||
applicationEventPublisher.publishEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
private fun detectInTransaction(event: LanguageDetectEvent) {
|
||||
val listener: LanguageDetectListener = AopTestUtils.getTargetObject(languageDetectListener)
|
||||
transactionTemplate.executeWithoutResult {
|
||||
listener.detectLanguage(event)
|
||||
}
|
||||
}
|
||||
|
||||
private fun persistPost(
|
||||
content: String,
|
||||
contentRevision: Long,
|
||||
languageCode: String? = null,
|
||||
isCommentAvailable: Boolean = true
|
||||
): Long {
|
||||
return transactionTemplate.execute {
|
||||
val member = memberRepository.save(
|
||||
Member(
|
||||
password = "password",
|
||||
nickname = "language-detect-${UUID.randomUUID()}",
|
||||
role = MemberRole.CREATOR
|
||||
)
|
||||
)
|
||||
creatorCommunityRepository.save(
|
||||
CreatorCommunity(
|
||||
content = content,
|
||||
price = 0,
|
||||
isCommentAvailable = isCommentAvailable,
|
||||
isAdult = false,
|
||||
languageCode = languageCode,
|
||||
contentRevision = contentRevision
|
||||
).apply {
|
||||
this.member = member
|
||||
}
|
||||
).id!!
|
||||
}!!
|
||||
}
|
||||
|
||||
private fun persistCreator(): Member {
|
||||
return transactionTemplate.execute {
|
||||
memberRepository.save(
|
||||
Member(
|
||||
password = "password",
|
||||
nickname = "community-service-${UUID.randomUUID()}",
|
||||
role = MemberRole.CREATOR
|
||||
)
|
||||
)
|
||||
}!!
|
||||
}
|
||||
|
||||
private fun updatePost(postId: Long, content: String, contentRevision: Long) {
|
||||
transactionTemplate.executeWithoutResult {
|
||||
val post = creatorCommunityRepository.findById(postId).orElseThrow()
|
||||
post.content = content
|
||||
post.contentRevision = contentRevision
|
||||
post.languageCode = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertPost(postId: Long, content: String, languageCode: String?) {
|
||||
val post = transactionTemplate.execute {
|
||||
creatorCommunityRepository.findById(postId).orElseThrow()
|
||||
}!!
|
||||
|
||||
assertEquals(content, post.content)
|
||||
if (languageCode == null) {
|
||||
assertNull(post.languageCode)
|
||||
} else {
|
||||
assertEquals(languageCode, post.languageCode)
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertJobTargets(postId: Long, expectedTargets: Set<String>) {
|
||||
assertTrue(await { jobTargets(postId) == expectedTargets })
|
||||
}
|
||||
|
||||
private fun jobTargets(postId: Long): Set<String> {
|
||||
return transactionTemplate.execute {
|
||||
translationJobRepository.findAll()
|
||||
.filter {
|
||||
it.resourceType == LanguageTranslationTargetType.CREATOR_COMMUNITY && it.resourceId == postId
|
||||
}
|
||||
.map { it.targetLanguage }
|
||||
.toSet()
|
||||
}.orEmpty()
|
||||
}
|
||||
|
||||
private fun jobCount(postId: Long): Int {
|
||||
return transactionTemplate.execute {
|
||||
translationJobRepository.findAll().count {
|
||||
it.resourceType == LanguageTranslationTargetType.CREATOR_COMMUNITY && it.resourceId == postId
|
||||
}
|
||||
}!!
|
||||
}
|
||||
|
||||
private fun await(condition: () -> Boolean): Boolean {
|
||||
repeat(50) {
|
||||
if (condition()) return true
|
||||
Thread.sleep(100)
|
||||
}
|
||||
return condition()
|
||||
}
|
||||
|
||||
@TestConfiguration
|
||||
@EnableAsync
|
||||
class AsyncConfiguration {
|
||||
@Bean(name = ["taskExecutor"])
|
||||
fun taskExecutor(): DetailPipelineTaskExecutor {
|
||||
return DetailPipelineTaskExecutor()
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
fun languageDetectionCacheService(
|
||||
languageDetectionResultRepository: LanguageDetectionResultRepository
|
||||
): ControlledLanguageDetectionCacheService {
|
||||
return ControlledLanguageDetectionCacheService(languageDetectionResultRepository)
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun creatorCommunityService(
|
||||
repository: CreatorCommunityRepository,
|
||||
blockMemberRepository: BlockMemberRepository,
|
||||
likeRepository: CreatorCommunityLikeRepository,
|
||||
commentRepository: CreatorCommunityCommentRepository,
|
||||
useCanRepository: UseCanRepository,
|
||||
applicationEventPublisher: ApplicationEventPublisher,
|
||||
creatorCommunityTranslationService: CreatorCommunityTranslationService,
|
||||
entityManager: EntityManager
|
||||
): CreatorCommunityService {
|
||||
return CreatorCommunityService(
|
||||
canPaymentService = Mockito.mock(CanPaymentService::class.java),
|
||||
repository = repository,
|
||||
blockMemberRepository = blockMemberRepository,
|
||||
likeRepository = likeRepository,
|
||||
commentRepository = commentRepository,
|
||||
useCanRepository = useCanRepository,
|
||||
s3Uploader = Mockito.mock(S3Uploader::class.java),
|
||||
objectMapper = ObjectMapper().registerModule(KotlinModule.Builder().build()),
|
||||
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java),
|
||||
applicationEventPublisher = applicationEventPublisher,
|
||||
messageSource = SodaMessageSource(),
|
||||
langContext = LangContext(),
|
||||
homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java),
|
||||
creatorCommunityTranslationService = creatorCommunityTranslationService,
|
||||
entityManager = entityManager,
|
||||
imageBucket = "test-image-bucket",
|
||||
contentBucket = "test-content-bucket",
|
||||
imageHost = "https://test.cloudfront.net"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ControlledLanguageDetectionCacheService(
|
||||
languageDetectionResultRepository: LanguageDetectionResultRepository
|
||||
) : LanguageDetectionCacheService(languageDetectionResultRepository) {
|
||||
val detections = ConcurrentHashMap<String, String>()
|
||||
val queries = ConcurrentLinkedQueue<String>()
|
||||
private val blockingDetections = ConcurrentHashMap<String, BlockingDetection>()
|
||||
private val concurrentDetections = ConcurrentHashMap<String, ConcurrentDetection>()
|
||||
|
||||
fun clear() {
|
||||
detections.clear()
|
||||
queries.clear()
|
||||
blockingDetections.clear()
|
||||
concurrentDetections.clear()
|
||||
}
|
||||
|
||||
fun block(query: String, detectedLanguage: String): BlockingDetection {
|
||||
return BlockingDetection(detectedLanguage).also { blockingDetections[query] = it }
|
||||
}
|
||||
|
||||
fun blockConcurrent(query: String, detectedLanguage: String): ConcurrentDetection {
|
||||
return ConcurrentDetection(detectedLanguage).also { concurrentDetections[query] = it }
|
||||
}
|
||||
|
||||
override fun detectWithCache(query: String, provider: String, detector: () -> String?): String? {
|
||||
queries.add(query)
|
||||
val concurrentDetection = concurrentDetections[query]
|
||||
if (concurrentDetection != null) {
|
||||
return concurrentDetection.detect()
|
||||
}
|
||||
val blockingDetection = blockingDetections[query]
|
||||
if (blockingDetection != null) {
|
||||
blockingDetection.started.countDown()
|
||||
check(blockingDetection.release.await(5, TimeUnit.SECONDS))
|
||||
return blockingDetection.detectedLanguage
|
||||
}
|
||||
return detections[query]
|
||||
}
|
||||
}
|
||||
|
||||
class BlockingDetection(val detectedLanguage: String) {
|
||||
val started = CountDownLatch(1)
|
||||
val release = CountDownLatch(1)
|
||||
}
|
||||
|
||||
class ConcurrentDetection(private val detectedLanguage: String) {
|
||||
private val order = AtomicInteger()
|
||||
val firstStarted = CountDownLatch(1)
|
||||
val secondStarted = CountDownLatch(1)
|
||||
val firstRelease = CountDownLatch(1)
|
||||
val secondRelease = CountDownLatch(1)
|
||||
|
||||
fun detect(): String {
|
||||
when (order.incrementAndGet()) {
|
||||
1 -> {
|
||||
firstStarted.countDown()
|
||||
check(firstRelease.await(5, TimeUnit.SECONDS))
|
||||
}
|
||||
|
||||
2 -> {
|
||||
secondStarted.countDown()
|
||||
check(secondRelease.await(5, TimeUnit.SECONDS))
|
||||
}
|
||||
}
|
||||
return detectedLanguage
|
||||
}
|
||||
}
|
||||
|
||||
class DetailPipelineTaskExecutor : ThreadPoolTaskExecutor() {
|
||||
private val completion = AtomicReference(CountDownLatch(0))
|
||||
|
||||
init {
|
||||
corePoolSize = 1
|
||||
maxPoolSize = 1
|
||||
setThreadNamePrefix("detail-pipeline-")
|
||||
setTaskDecorator { task ->
|
||||
val taskCompletion = completion.get()
|
||||
Runnable {
|
||||
try {
|
||||
task.run()
|
||||
} finally {
|
||||
taskCompletion.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareNextTask() {
|
||||
completion.set(CountDownLatch(1))
|
||||
}
|
||||
|
||||
fun awaitTaskCompletion(timeout: Long, unit: TimeUnit): Boolean {
|
||||
return completion.get().await(timeout, unit)
|
||||
}
|
||||
}
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
package kr.co.vividnext.sodalive.content
|
||||
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobRepository
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobStatus
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationReadModelMaterializer
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceExtractor
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.aop.support.AopUtils
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.client.ClientHttpRequest
|
||||
import org.springframework.http.client.ClientHttpRequestFactory
|
||||
import org.springframework.http.client.ClientHttpResponse
|
||||
import org.springframework.test.util.AopTestUtils
|
||||
import org.springframework.test.util.ReflectionTestUtils
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import org.springframework.web.client.RestTemplate
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.URI
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.Callable
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.ExecutorCompletionService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@DataJpaTest(
|
||||
properties = [
|
||||
"spring.cache.type=none",
|
||||
"spring.datasource.url=" +
|
||||
"jdbc:h2:mem:creator-community-language-detection-cache-concurrency;" +
|
||||
"MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE",
|
||||
"cloud.naver.papago-client-id=test-client-id",
|
||||
"cloud.naver.papago-client-secret=test-client-secret"
|
||||
]
|
||||
)
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import(
|
||||
QueryDslConfig::class,
|
||||
AudioContentThemeQueryRepository::class,
|
||||
LanguageDetectionCacheService::class,
|
||||
LanguageDetectListener::class,
|
||||
CreatorCommunityTranslationService::class,
|
||||
TranslationSourceExtractor::class,
|
||||
TranslationReadModelMaterializer::class,
|
||||
ResourceTranslationJobScheduler::class,
|
||||
TranslationJobScheduler::class
|
||||
)
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
class CreatorCommunityLanguageDetectionCacheConcurrencyTest @Autowired constructor(
|
||||
private val creatorCommunityRepository: CreatorCommunityRepository,
|
||||
private val memberRepository: MemberRepository,
|
||||
private val languageDetectionResultRepository: LanguageDetectionResultRepository,
|
||||
private val translationJobRepository: TranslationJobRepository,
|
||||
private val languageDetectionCacheService: LanguageDetectionCacheService,
|
||||
private val languageDetectListener: LanguageDetectListener,
|
||||
transactionManager: PlatformTransactionManager
|
||||
) {
|
||||
private val requiresNewTransactionTemplate = TransactionTemplate(transactionManager).apply {
|
||||
propagationBehavior = Propagation.REQUIRES_NEW.value()
|
||||
}
|
||||
private lateinit var papagoRequests: ControlledPapagoRequestFactory
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
papagoRequests = ControlledPapagoRequestFactory()
|
||||
val listener: LanguageDetectListener = AopTestUtils.getTargetObject(languageDetectListener)
|
||||
ReflectionTestUtils.setField(listener, "restTemplate", RestTemplate(papagoRequests))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최초 감지 캐시 저장 경합에서도 두 요청 언어의 번역 작업을 보존한다")
|
||||
fun shouldPreserveBothTargetsWhenInitialDetectionCacheWritesRace() {
|
||||
val content = "initial detection cache race ${UUID.randomUUID()}"
|
||||
val contentRevision = 7L
|
||||
val postId = persistPost(content, contentRevision)
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
val completions = ExecutorCompletionService<Unit>(executor)
|
||||
|
||||
try {
|
||||
assertTrue(AopUtils.isAopProxy(languageDetectionCacheService))
|
||||
completions.submit(detectionTask(postId, content, contentRevision, "ja"))
|
||||
completions.submit(detectionTask(postId, content, contentRevision, "en"))
|
||||
|
||||
assertTrue(
|
||||
papagoRequests.firstProviderEntry.await(5, TimeUnit.SECONDS),
|
||||
"first cache miss did not reach the provider boundary"
|
||||
)
|
||||
assertTrue(
|
||||
papagoRequests.secondProviderEntry.await(5, TimeUnit.SECONDS),
|
||||
"second cache miss did not reach the provider boundary"
|
||||
)
|
||||
|
||||
papagoRequests.firstProviderRelease.countDown()
|
||||
val firstCompletion = completions.poll(5, TimeUnit.SECONDS)
|
||||
?: throw AssertionError("first detection transaction did not complete")
|
||||
firstCompletion.get(5, TimeUnit.SECONDS)
|
||||
|
||||
papagoRequests.secondProviderRelease.countDown()
|
||||
val secondCompletion = completions.poll(5, TimeUnit.SECONDS)
|
||||
?: throw AssertionError("second detection transaction did not complete")
|
||||
secondCompletion.get(5, TimeUnit.SECONDS)
|
||||
|
||||
assertCacheRow(content)
|
||||
assertPostAndJobs(postId, content, contentRevision)
|
||||
} finally {
|
||||
papagoRequests.releaseAll()
|
||||
executor.shutdownNow()
|
||||
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("서로 다른 게시물의 동일 본문 최초 감지 경합에서도 각각의 전체 번역 작업을 보존한다")
|
||||
fun shouldPreserveAllNonSourceTargetsForDifferentPostsWithSameContentDuringInitialDetectionRace() {
|
||||
val content = "shared initial detection cache race ${UUID.randomUUID()}"
|
||||
val contentRevision = 7L
|
||||
val firstPostId = persistPost(content, contentRevision)
|
||||
val secondPostId = persistPost(content, contentRevision)
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
val completions = ExecutorCompletionService<Unit>(executor)
|
||||
|
||||
try {
|
||||
assertTrue(AopUtils.isAopProxy(languageDetectionCacheService))
|
||||
completions.submit(detectionTask(firstPostId, content, contentRevision, null))
|
||||
completions.submit(detectionTask(secondPostId, content, contentRevision, null))
|
||||
|
||||
assertTrue(
|
||||
papagoRequests.firstProviderEntry.await(5, TimeUnit.SECONDS),
|
||||
"first cache miss did not reach the provider boundary"
|
||||
)
|
||||
assertTrue(
|
||||
papagoRequests.secondProviderEntry.await(5, TimeUnit.SECONDS),
|
||||
"second cache miss did not reach the provider boundary"
|
||||
)
|
||||
|
||||
papagoRequests.firstProviderRelease.countDown()
|
||||
val firstCompletion = completions.poll(5, TimeUnit.SECONDS)
|
||||
?: throw AssertionError("first detection transaction did not complete")
|
||||
firstCompletion.get(5, TimeUnit.SECONDS)
|
||||
|
||||
papagoRequests.secondProviderRelease.countDown()
|
||||
val secondCompletion = completions.poll(5, TimeUnit.SECONDS)
|
||||
?: throw AssertionError("second detection transaction did not complete")
|
||||
secondCompletion.get(5, TimeUnit.SECONDS)
|
||||
|
||||
assertCacheRow(content)
|
||||
assertPostAndJobs(firstPostId, content, contentRevision)
|
||||
assertPostAndJobs(secondPostId, content, contentRevision)
|
||||
} finally {
|
||||
papagoRequests.releaseAll()
|
||||
executor.shutdownNow()
|
||||
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS))
|
||||
}
|
||||
}
|
||||
|
||||
private fun detectionTask(
|
||||
postId: Long,
|
||||
content: String,
|
||||
contentRevision: Long,
|
||||
targetLanguage: String?
|
||||
): Callable<Unit> {
|
||||
return Callable {
|
||||
val listener: LanguageDetectListener = AopTestUtils.getTargetObject(languageDetectListener)
|
||||
requiresNewTransactionTemplate.executeWithoutResult {
|
||||
listener.detectLanguage(
|
||||
LanguageDetectEvent(
|
||||
id = postId,
|
||||
query = content,
|
||||
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
|
||||
sourceRevision = contentRevision,
|
||||
targetLanguage = targetLanguage
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun persistPost(content: String, contentRevision: Long): Long {
|
||||
return requiresNewTransactionTemplate.execute {
|
||||
val member = memberRepository.save(
|
||||
Member(
|
||||
password = "password",
|
||||
nickname = "cache-race-${UUID.randomUUID()}",
|
||||
role = MemberRole.CREATOR
|
||||
)
|
||||
)
|
||||
creatorCommunityRepository.save(
|
||||
CreatorCommunity(
|
||||
content = content,
|
||||
price = 0,
|
||||
isCommentAvailable = true,
|
||||
isAdult = false,
|
||||
languageCode = null,
|
||||
contentRevision = contentRevision
|
||||
).apply {
|
||||
this.member = member
|
||||
}
|
||||
).id!!
|
||||
}!!
|
||||
}
|
||||
|
||||
private fun assertCacheRow(content: String) {
|
||||
val rows = requiresNewTransactionTemplate.execute {
|
||||
languageDetectionResultRepository.findAll().filter {
|
||||
it.sourceHash == SourceTextNormalizer.hash(content) &&
|
||||
it.provider == LanguageDetectionCacheService.DEFAULT_PROVIDER &&
|
||||
it.normalizationVersion == SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
}
|
||||
}.orEmpty()
|
||||
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals("ko", rows.single().detectedLanguage)
|
||||
}
|
||||
|
||||
private fun assertPostAndJobs(postId: Long, content: String, contentRevision: Long) {
|
||||
val post = requiresNewTransactionTemplate.execute {
|
||||
creatorCommunityRepository.findById(postId).orElseThrow()
|
||||
}!!
|
||||
val jobs = requiresNewTransactionTemplate.execute {
|
||||
translationJobRepository.findAll().filter {
|
||||
it.resourceType == LanguageTranslationTargetType.CREATOR_COMMUNITY &&
|
||||
it.resourceId == postId &&
|
||||
it.fieldKey == "content"
|
||||
}
|
||||
}.orEmpty()
|
||||
|
||||
assertEquals(content, post.content)
|
||||
assertEquals(contentRevision, post.contentRevision)
|
||||
assertEquals("ko", post.languageCode)
|
||||
assertEquals(2, jobs.size)
|
||||
assertEquals(setOf("ja", "en"), jobs.map { it.targetLanguage }.toSet())
|
||||
assertTrue(
|
||||
jobs.all { job ->
|
||||
job.sourceHash == SourceTextNormalizer.hash(content) &&
|
||||
job.sourceLanguage == "ko"
|
||||
}
|
||||
)
|
||||
assertTrue(jobs.all { it.status == TranslationJobStatus.PENDING })
|
||||
}
|
||||
|
||||
private class ControlledPapagoRequestFactory : ClientHttpRequestFactory {
|
||||
private val requestOrder = AtomicInteger()
|
||||
val firstProviderEntry = CountDownLatch(1)
|
||||
val secondProviderEntry = CountDownLatch(1)
|
||||
val firstProviderRelease = CountDownLatch(1)
|
||||
val secondProviderRelease = CountDownLatch(1)
|
||||
|
||||
override fun createRequest(uri: URI, httpMethod: HttpMethod): ClientHttpRequest {
|
||||
return object : ClientHttpRequest {
|
||||
private val headers = HttpHeaders()
|
||||
private val body = ByteArrayOutputStream()
|
||||
|
||||
override fun getMethodValue(): String = httpMethod.name
|
||||
|
||||
override fun getURI(): URI = uri
|
||||
|
||||
override fun getHeaders(): HttpHeaders = headers
|
||||
|
||||
override fun getBody(): OutputStream = body
|
||||
|
||||
override fun execute(): ClientHttpResponse {
|
||||
val languageCode = when (requestOrder.incrementAndGet()) {
|
||||
1 -> {
|
||||
firstProviderEntry.countDown()
|
||||
firstProviderRelease.await()
|
||||
"ko"
|
||||
}
|
||||
|
||||
2 -> {
|
||||
secondProviderEntry.countDown()
|
||||
secondProviderRelease.await()
|
||||
"en"
|
||||
}
|
||||
|
||||
else -> error("unexpected provider request")
|
||||
}
|
||||
return languageDetectionResponse(languageCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun releaseAll() {
|
||||
firstProviderRelease.countDown()
|
||||
secondProviderRelease.countDown()
|
||||
}
|
||||
|
||||
private fun languageDetectionResponse(languageCode: String): ClientHttpResponse {
|
||||
return object : ClientHttpResponse {
|
||||
private val headers = HttpHeaders().apply {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
}
|
||||
|
||||
override fun getStatusCode(): HttpStatus = HttpStatus.OK
|
||||
|
||||
override fun getRawStatusCode(): Int = HttpStatus.OK.value()
|
||||
|
||||
override fun getStatusText(): String = "OK"
|
||||
|
||||
override fun getHeaders(): HttpHeaders = headers
|
||||
|
||||
override fun getBody() = ByteArrayInputStream("{\"langCode\":\"$languageCode\"}".toByteArray())
|
||||
|
||||
override fun close() = Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
-1
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.content
|
||||
|
||||
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
|
||||
@@ -37,6 +38,67 @@ class LanguageDetectionCacheServiceTest {
|
||||
|
||||
assertEquals("en", detected)
|
||||
assertEquals(0, providerCalls)
|
||||
Mockito.verify(repository, Mockito.never()).save(Mockito.any(LanguageDetectionResult::class.java))
|
||||
Mockito.verify(repository).findBySourceHashAndProviderAndNormalizationVersion(
|
||||
sourceHash = sourceHash,
|
||||
provider = "papago",
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
Mockito.verifyNoMoreInteractions(repository)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최초 캐시 저장 경합에서는 저장된 승자의 감지 언어를 반환한다")
|
||||
fun shouldReturnStoredWinnerAfterInitialCacheInsert() {
|
||||
val repository = Mockito.mock(LanguageDetectionResultRepository::class.java)
|
||||
val service = LanguageDetectionCacheService(repository)
|
||||
val sourceHash = SourceTextNormalizer.hash("Hello world")
|
||||
|
||||
Mockito.`when`(
|
||||
repository.findBySourceHashAndProviderAndNormalizationVersion(
|
||||
sourceHash = sourceHash,
|
||||
provider = "papago",
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
).thenReturn(null)
|
||||
Mockito.`when`(
|
||||
repository.insertIfAbsent(
|
||||
sourceHash = sourceHash,
|
||||
sourceTextSample = "Hello world",
|
||||
detectedLanguage = "en",
|
||||
provider = "papago",
|
||||
confidence = null,
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
).thenReturn(0)
|
||||
Mockito.`when`(
|
||||
repository.findDetectedLanguageForUpdate(
|
||||
sourceHash = sourceHash,
|
||||
provider = "papago",
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
).thenReturn("ko")
|
||||
|
||||
val detected = service.detectWithCache("Hello world") { "en" }
|
||||
|
||||
assertEquals("ko", detected)
|
||||
Mockito.verify(repository).findBySourceHashAndProviderAndNormalizationVersion(
|
||||
sourceHash = sourceHash,
|
||||
provider = "papago",
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
Mockito.verify(repository).insertIfAbsent(
|
||||
sourceHash = sourceHash,
|
||||
sourceTextSample = "Hello world",
|
||||
detectedLanguage = "en",
|
||||
provider = "papago",
|
||||
confidence = null,
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
Mockito.verify(repository).findDetectedLanguageForUpdate(
|
||||
sourceHash = sourceHash,
|
||||
provider = "papago",
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
Mockito.verifyNoMoreInteractions(repository)
|
||||
}
|
||||
}
|
||||
|
||||
+344
-5
@@ -17,11 +17,14 @@ import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
|
||||
import kr.co.vividnext.sodalive.i18n.Lang
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityDisplayContent
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
@@ -36,10 +39,13 @@ import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.support.TransactionSynchronization
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import java.io.InputStream
|
||||
import java.time.LocalDateTime
|
||||
import java.util.Optional
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
class CreatorCommunityServiceTest {
|
||||
private lateinit var repository: CreatorCommunityRepository
|
||||
@@ -50,6 +56,9 @@ class CreatorCommunityServiceTest {
|
||||
private lateinit var s3Uploader: S3Uploader
|
||||
private lateinit var applicationEventPublisher: ApplicationEventPublisher
|
||||
private lateinit var homeFollowingNewsPublishService: HomeFollowingNewsPublishService
|
||||
private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService
|
||||
private lateinit var entityManager: EntityManager
|
||||
private lateinit var langContext: LangContext
|
||||
private lateinit var service: CreatorCommunityService
|
||||
|
||||
@BeforeEach
|
||||
@@ -62,6 +71,9 @@ class CreatorCommunityServiceTest {
|
||||
s3Uploader = Mockito.mock(S3Uploader::class.java)
|
||||
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||
homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
|
||||
creatorCommunityTranslationService = Mockito.mock(CreatorCommunityTranslationService::class.java)
|
||||
entityManager = Mockito.mock(EntityManager::class.java)
|
||||
langContext = LangContext()
|
||||
|
||||
service = CreatorCommunityService(
|
||||
canPaymentService = Mockito.mock(CanPaymentService::class.java),
|
||||
@@ -75,8 +87,10 @@ class CreatorCommunityServiceTest {
|
||||
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java),
|
||||
applicationEventPublisher = applicationEventPublisher,
|
||||
messageSource = SodaMessageSource(),
|
||||
langContext = LangContext(),
|
||||
langContext = langContext,
|
||||
homeFollowingNewsPublishService = homeFollowingNewsPublishService,
|
||||
creatorCommunityTranslationService = creatorCommunityTranslationService,
|
||||
entityManager = entityManager,
|
||||
imageBucket = "image-bucket",
|
||||
contentBucket = "content-bucket",
|
||||
imageHost = "https://cdn.test"
|
||||
@@ -252,7 +266,7 @@ class CreatorCommunityServiceTest {
|
||||
post.id = 501L
|
||||
post.member = creator
|
||||
|
||||
Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post)
|
||||
Mockito.`when`(repository.findByIdAndMemberIdForUpdate(post.id!!, creator.id!!)).thenReturn(post)
|
||||
Mockito.`when`(repository.countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(creator.id!!)).thenReturn(3L)
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
@@ -273,7 +287,7 @@ class CreatorCommunityServiceTest {
|
||||
post.id = 601L
|
||||
post.member = creator
|
||||
|
||||
Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post)
|
||||
Mockito.`when`(repository.findByIdAndMemberIdForUpdate(post.id!!, creator.id!!)).thenReturn(post)
|
||||
Mockito.`when`(repository.countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(creator.id!!)).thenReturn(2L)
|
||||
|
||||
service.updateCommunityPostFixed(
|
||||
@@ -295,7 +309,7 @@ class CreatorCommunityServiceTest {
|
||||
post.isFixed = true
|
||||
post.fixedAt = LocalDateTime.now()
|
||||
|
||||
Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post)
|
||||
Mockito.`when`(repository.findByIdAndMemberIdForUpdate(post.id!!, creator.id!!)).thenReturn(post)
|
||||
|
||||
service.updateCommunityPostFixed(
|
||||
request = UpdateCommunityPostFixedRequest(postId = post.id!!, isFixed = false),
|
||||
@@ -306,6 +320,120 @@ class CreatorCommunityServiceTest {
|
||||
assertNull(post.fixedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("본문이 실제로 변경되면 언어를 초기화하고 본문 개정을 한 번 증가시킨다")
|
||||
fun shouldResetLanguageAndIncrementRevisionWhenContentChanges() {
|
||||
val creator = createMember(id = 304L, role = MemberRole.CREATOR, nickname = "creator")
|
||||
val post = CreatorCommunity(
|
||||
content = "before",
|
||||
price = 0,
|
||||
isCommentAvailable = true,
|
||||
isAdult = false,
|
||||
languageCode = "ko",
|
||||
contentRevision = 7
|
||||
).apply {
|
||||
id = 304L
|
||||
member = creator
|
||||
}
|
||||
Mockito.`when`(repository.findByIdAndMemberIdForUpdate(post.id!!, creator.id!!)).thenReturn(post)
|
||||
|
||||
service.modifyCommunityPost(
|
||||
postImage = null,
|
||||
requestString = """{"creatorCommunityId":304,"content":"after"}""",
|
||||
member = creator
|
||||
)
|
||||
|
||||
assertEquals("after", post.content)
|
||||
assertNull(post.languageCode)
|
||||
assertEquals(8, post.contentRevision)
|
||||
Mockito.verify(creatorCommunityTranslationService).requestTranslations(post.id!!, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("동일 본문과 본문 외 수정은 언어와 개정 및 번역 요청을 유지한다")
|
||||
fun shouldNotResetLanguageOrRequestTranslationsWhenContentIsUnchanged() {
|
||||
val creator = createMember(id = 305L, role = MemberRole.CREATOR, nickname = "creator")
|
||||
val post = CreatorCommunity(
|
||||
content = "same",
|
||||
price = 0,
|
||||
isCommentAvailable = true,
|
||||
isAdult = false,
|
||||
languageCode = "ko",
|
||||
contentRevision = 7
|
||||
).apply {
|
||||
id = 305L
|
||||
member = creator
|
||||
}
|
||||
Mockito.`when`(repository.findByIdAndMemberIdForUpdate(post.id!!, creator.id!!)).thenReturn(post)
|
||||
Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post)
|
||||
|
||||
service.modifyCommunityPost(
|
||||
postImage = null,
|
||||
requestString = """
|
||||
{"creatorCommunityId":305,"content":"same","isCommentAvailable":false,"isAdult":true,"isActive":false}
|
||||
""".trimIndent(),
|
||||
member = creator
|
||||
)
|
||||
service.updateCommunityPostFixed(UpdateCommunityPostFixedRequest(post.id!!, isFixed = false), creator)
|
||||
|
||||
assertEquals("ko", post.languageCode)
|
||||
assertEquals(7, post.contentRevision)
|
||||
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("게시글 생성은 커밋 후에만 번역을 요청한다")
|
||||
fun shouldRequestTranslationsAfterCommunityPostCreationCommits() {
|
||||
val creator = createMember(id = 306L, role = MemberRole.CREATOR, nickname = "creator")
|
||||
Mockito.`when`(repository.save(Mockito.any(CreatorCommunity::class.java))).thenAnswer { invocation ->
|
||||
invocation.getArgument<CreatorCommunity>(0).apply { id = 306L }
|
||||
}
|
||||
TransactionSynchronizationManager.initSynchronization()
|
||||
|
||||
try {
|
||||
service.createCommunityPost(
|
||||
audioFile = null,
|
||||
postImage = null,
|
||||
requestString = """{"content":"created","price":0,"isCommentAvailable":true,"isAdult":false}""",
|
||||
member = creator
|
||||
)
|
||||
|
||||
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
|
||||
TransactionSynchronizationManager.getSynchronizations().forEach { it.afterCommit() }
|
||||
|
||||
Mockito.verify(creatorCommunityTranslationService).requestTranslations(306L, null)
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("게시글 생성 롤백은 번역을 요청하지 않는다")
|
||||
fun shouldNotRequestTranslationsWhenCommunityPostCreationRollsBack() {
|
||||
val creator = createMember(id = 307L, role = MemberRole.CREATOR, nickname = "creator")
|
||||
Mockito.`when`(repository.save(Mockito.any(CreatorCommunity::class.java))).thenAnswer { invocation ->
|
||||
invocation.getArgument<CreatorCommunity>(0).apply { id = 307L }
|
||||
}
|
||||
TransactionSynchronizationManager.initSynchronization()
|
||||
|
||||
try {
|
||||
service.createCommunityPost(
|
||||
audioFile = null,
|
||||
postImage = null,
|
||||
requestString = """{"content":"rolled back","price":0,"isCommentAvailable":true,"isAdult":false}""",
|
||||
member = creator
|
||||
)
|
||||
|
||||
TransactionSynchronizationManager.getSynchronizations().forEach {
|
||||
it.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK)
|
||||
}
|
||||
|
||||
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 게시글 생성 성공 후 최근 소식을 게시글 정보로 발행한다")
|
||||
fun shouldPublishNewsAfterCommunityPostCreated() {
|
||||
@@ -329,7 +457,7 @@ class CreatorCommunityServiceTest {
|
||||
Mockito.verify(homeFollowingNewsPublishService).publishCommunityPostCreated(
|
||||
postId = 901L,
|
||||
creatorId = creator.id!!,
|
||||
creatorNickname = creator.nickname!!,
|
||||
creatorNickname = creator.nickname,
|
||||
creatorProfileImagePath = creator.profileImage,
|
||||
title = "커뮤니티 새 게시글 본문입니다",
|
||||
body = "커뮤니티 새 게시글 본문입니다",
|
||||
@@ -369,6 +497,195 @@ class CreatorCommunityServiceTest {
|
||||
Mockito.verifyNoInteractions(homeFollowingNewsPublishService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("레거시 상세는 선택한 번역문에 유료 미리보기를 적용하고 요청 언어만 예약한다")
|
||||
fun shouldSelectTranslatedContentBeforePaidMaskingAndRequestCurrentLanguageForLegacyDetail() {
|
||||
langContext.setLang(Lang.JA)
|
||||
val post = selectCommunityPost(postId = 100L, content = "source paid content", price = 100)
|
||||
Mockito.`when`(repository.getCommunityPost(100L, true)).thenReturn(post)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(100L), "ja"))
|
||||
.thenReturn(mapOf(100L to CreatorCommunityDisplayContent("translated paid content", true)))
|
||||
|
||||
val response = service.getCommunityPostDetail(100L, memberId = 200L, timezone = "Asia/Seoul", isAdult = true)
|
||||
|
||||
assertEquals("translated paid...", response.content)
|
||||
Mockito.verify(creatorCommunityTranslationService).findDisplayContents(listOf(100L), "ja")
|
||||
Mockito.verify(creatorCommunityTranslationService, Mockito.never()).requestTranslations(100L, "ja")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("레거시 상세는 원문과 같은 유효 번역문에도 누락 예약을 요청하지 않는다")
|
||||
fun shouldNotRequestTranslationsWhenLegacyDetailHasValidTranslationMatchingSourceText() {
|
||||
langContext.setLang(Lang.JA)
|
||||
val post = selectCommunityPost(postId = 104L, content = "same content", price = 0)
|
||||
Mockito.`when`(repository.getCommunityPost(104L, true)).thenReturn(post)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(104L), "ja"))
|
||||
.thenReturn(mapOf(104L to CreatorCommunityDisplayContent("same content", true)))
|
||||
|
||||
service.getCommunityPostDetail(104L, memberId = 200L, timezone = "Asia/Seoul", isAdult = true)
|
||||
|
||||
Mockito.verify(creatorCommunityTranslationService, Mockito.never()).requestTranslations(104L, "ja")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("레거시 상세는 번역이 없으면 현재 원문을 반환하고 요청 언어만 예약한다")
|
||||
fun shouldReturnOriginalAndRequestCurrentLanguageWhenLegacyDetailTranslationIsMissing() {
|
||||
langContext.setLang(Lang.JA)
|
||||
val post = selectCommunityPost(postId = 101L, content = "current original", price = 0)
|
||||
Mockito.`when`(repository.getCommunityPost(101L, true)).thenReturn(post)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(101L), "ja"))
|
||||
.thenReturn(emptyMap())
|
||||
|
||||
val response = service.getCommunityPostDetail(101L, memberId = 200L, timezone = "Asia/Seoul", isAdult = true)
|
||||
|
||||
assertEquals("current original", response.content)
|
||||
Mockito.verify(creatorCommunityTranslationService).requestTranslations(101L, "ja")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("레거시 상세는 구매자에게 선택한 유료 번역문 전체를 반환한다")
|
||||
fun shouldReturnFullTranslatedContentToLegacyDetailBuyer() {
|
||||
langContext.setLang(Lang.JA)
|
||||
val post = selectCommunityPost(postId = 102L, content = "source paid content", price = 100)
|
||||
Mockito.`when`(repository.getCommunityPost(102L, true)).thenReturn(post)
|
||||
Mockito.`when`(useCanRepository.isExistCommunityPostOrdered(102L, 200L)).thenReturn(true)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(102L), "ja"))
|
||||
.thenReturn(mapOf(102L to CreatorCommunityDisplayContent("translated paid content", true)))
|
||||
|
||||
val response = service.getCommunityPostDetail(102L, memberId = 200L, timezone = "Asia/Seoul", isAdult = true)
|
||||
|
||||
assertEquals("translated paid content", response.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("레거시 상세는 소유자에게 선택한 유료 번역문 전체를 반환한다")
|
||||
fun shouldReturnFullTranslatedContentToLegacyDetailOwner() {
|
||||
langContext.setLang(Lang.JA)
|
||||
val post = selectCommunityPost(postId = 103L, creatorId = 200L, content = "source paid content", price = 100)
|
||||
Mockito.`when`(repository.getCommunityPost(103L, true)).thenReturn(post)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(103L), "ja"))
|
||||
.thenReturn(mapOf(103L to CreatorCommunityDisplayContent("translated paid content", true)))
|
||||
|
||||
val response = service.getCommunityPostDetail(103L, memberId = 200L, timezone = "Asia/Seoul", isAdult = true)
|
||||
|
||||
assertEquals("translated paid content", response.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("레거시 목록은 페이지 본문을 한 번에 번역 선택한 뒤 기존 유료 미리보기를 적용한다")
|
||||
fun shouldUseStoredTranslationsOnceForLegacyCommunityListBeforePaidMasking() {
|
||||
langContext.setLang(Lang.JA)
|
||||
val posts = listOf(
|
||||
selectCommunityPost(postId = 201L, content = "source free", price = 0),
|
||||
selectCommunityPost(postId = 202L, content = "source paid", price = 100),
|
||||
selectCommunityPost(postId = 203L, content = "source buyer", price = 100),
|
||||
selectCommunityPost(postId = 204L, creatorId = 200L, content = "source owner", price = 100),
|
||||
selectCommunityPost(postId = 205L, content = "source stale", price = 0)
|
||||
)
|
||||
val postIds = posts.map { it.id }
|
||||
Mockito.`when`(repository.getCommunityPostList(1L, 200L, 0L, 20L, true)).thenReturn(posts)
|
||||
Mockito.`when`(useCanRepository.isExistCommunityPostOrdered(203L, 200L)).thenReturn(true)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(postIds, "ja"))
|
||||
.thenReturn(
|
||||
mapOf(
|
||||
201L to CreatorCommunityDisplayContent("translated free", true),
|
||||
202L to CreatorCommunityDisplayContent("가나다라마바사아자차카타파하🙂끝", true),
|
||||
203L to CreatorCommunityDisplayContent("translated buyer", true),
|
||||
204L to CreatorCommunityDisplayContent("translated owner", true),
|
||||
205L to CreatorCommunityDisplayContent("source stale", false)
|
||||
)
|
||||
)
|
||||
|
||||
val responses = service.getCommunityPostList(
|
||||
creatorId = 1L,
|
||||
memberId = 200L,
|
||||
timezone = "Asia/Seoul",
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
isAdult = true
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"translated free",
|
||||
"가나다라마바사아자차카타파하🙂...",
|
||||
"translated buyer",
|
||||
"translated owner",
|
||||
"source stale"
|
||||
),
|
||||
responses.map { it.content }
|
||||
)
|
||||
Mockito.verify(creatorCommunityTranslationService).findDisplayContents(postIds, "ja")
|
||||
Mockito.verifyNoMoreInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("레거시 최신 목록은 차단 필터 뒤 남은 본문만 한 번에 번역 선택하고 작업을 만들지 않는다")
|
||||
fun shouldUseStoredTranslationsForVisibleLatestCommunityPostsWithoutScheduling() {
|
||||
langContext.setLang(Lang.JA)
|
||||
val visiblePost = selectCommunityPost(postId = 206L, content = "source visible", price = 0)
|
||||
val blockedPost = selectCommunityPost(postId = 207L, creatorId = 2L, content = "source blocked", price = 0)
|
||||
Mockito.`when`(repository.getLatestPostListFromCreatorsYouFollow(200L, true))
|
||||
.thenReturn(listOf(visiblePost, blockedPost))
|
||||
Mockito.`when`(blockMemberRepository.isBlocked(200L, 2L)).thenReturn(true)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(206L), "ja"))
|
||||
.thenReturn(mapOf(206L to CreatorCommunityDisplayContent("translated visible", true)))
|
||||
|
||||
val responses = service.getLatestPostListFromCreatorsYouFollow(
|
||||
timezone = "Asia/Seoul",
|
||||
memberId = 200L,
|
||||
isAdult = true
|
||||
)
|
||||
|
||||
assertEquals(listOf("translated visible"), responses.map { it.content })
|
||||
Mockito.verify(creatorCommunityTranslationService).findDisplayContents(listOf(206L), "ja")
|
||||
Mockito.verifyNoMoreInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("차단된 레거시 상세은 번역 조회나 예약을 시작하지 않는다")
|
||||
fun shouldNotScheduleTranslationsWhenLegacyDetailIsBlocked() {
|
||||
val post = selectCommunityPost(postId = 104L, content = "blocked", price = 0)
|
||||
Mockito.`when`(repository.getCommunityPost(104L, true)).thenReturn(post)
|
||||
Mockito.`when`(blockMemberRepository.isBlocked(200L, 1L)).thenReturn(true)
|
||||
|
||||
assertThrows(SodaException::class.java) {
|
||||
service.getCommunityPostDetail(104L, memberId = 200L, timezone = "Asia/Seoul", isAdult = true)
|
||||
}
|
||||
|
||||
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("비활성 레거시 상세은 번역 조회나 예약을 시작하지 않는다")
|
||||
fun shouldNotScheduleTranslationsWhenLegacyDetailIsInactive() {
|
||||
Mockito.`when`(repository.getCommunityPost(105L, true)).thenReturn(null)
|
||||
|
||||
assertThrows(SodaException::class.java) {
|
||||
service.getCommunityPostDetail(105L, memberId = 200L, timezone = "Asia/Seoul", isAdult = true)
|
||||
}
|
||||
|
||||
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("레거시 댓글과 구매 응답은 번역 예약 진입점이 아니다")
|
||||
fun shouldNotScheduleTranslationsFromLegacyCommentOrPurchaseResponses() {
|
||||
val creator = createMember(id = 1L, role = MemberRole.CREATOR, nickname = "creator")
|
||||
val post = CreatorCommunity(content = "original", price = 100, isCommentAvailable = false, isAdult = false).apply {
|
||||
id = 106L
|
||||
createdAt = LocalDateTime.of(2026, 9, 10, 10, 0)
|
||||
member = creator
|
||||
}
|
||||
Mockito.`when`(repository.findByIdAndActive(106L, true)).thenReturn(post)
|
||||
Mockito.`when`(useCanRepository.isExistCommunityPostOrdered(106L, 200L)).thenReturn(true)
|
||||
|
||||
service.getCommunityPostCommentList(106L, 200L, "Asia/Seoul", 0, 20, true)
|
||||
service.purchasePost(106L, 200L, "Asia/Seoul", true, "test")
|
||||
|
||||
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 소식 발행 실패는 커뮤니티 게시글 생성을 실패시키지 않는다")
|
||||
fun shouldNotFailCommunityPostCreationWhenNewsPublishFails() {
|
||||
@@ -457,4 +774,26 @@ class CreatorCommunityServiceTest {
|
||||
member.id = id
|
||||
return member
|
||||
}
|
||||
|
||||
private fun selectCommunityPost(
|
||||
postId: Long,
|
||||
creatorId: Long = 1L,
|
||||
content: String,
|
||||
price: Int
|
||||
): SelectCommunityPostResponse {
|
||||
return SelectCommunityPostResponse(
|
||||
id = postId,
|
||||
creatorId = creatorId,
|
||||
creatorNickname = "creator-$creatorId",
|
||||
creatorProfileUrl = "profile/$creatorId.png",
|
||||
imagePath = null,
|
||||
audioPath = null,
|
||||
content = content,
|
||||
date = LocalDateTime.of(2026, 9, 10, 10, 0),
|
||||
isCommentAvailable = false,
|
||||
price = price,
|
||||
isAdult = false,
|
||||
isFixed = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
package kr.co.vividnext.sodalive.explorer.profile.creatorCommunity
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||
import kr.co.vividnext.sodalive.can.use.UseCanRepository
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityCommentRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLikeRepository
|
||||
import kr.co.vividnext.sodalive.i18n.Lang
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobRepository
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationReadModelMaterializer
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationSource
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceExtractor
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceField
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslationRepository
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
|
||||
import org.hibernate.SessionFactory
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.context.annotation.Import
|
||||
import javax.persistence.EntityManager
|
||||
import javax.persistence.EntityManagerFactory
|
||||
|
||||
@DataJpaTest(
|
||||
properties = [
|
||||
"spring.cache.type=none",
|
||||
"spring.datasource.url=" +
|
||||
"jdbc:h2:mem:creator-community-detail-translation;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE",
|
||||
"spring.jpa.properties.hibernate.generate_statistics=true"
|
||||
]
|
||||
)
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import(QueryDslConfig::class)
|
||||
class CreatorCommunityTranslationEndToEndTest @Autowired constructor(
|
||||
private val creatorCommunityRepository: CreatorCommunityRepository,
|
||||
private val creatorCommunityTranslationRepository: CreatorCommunityTranslationRepository,
|
||||
private val memberRepository: MemberRepository,
|
||||
private val entityManager: EntityManager,
|
||||
private val entityManagerFactory: EntityManagerFactory,
|
||||
private val translationJobRepository: TranslationJobRepository
|
||||
) {
|
||||
@Test
|
||||
@DisplayName("실제 번역 읽기는 작은 배치와 큰 배치에서 게시글과 번역을 각각 한 번씩 조회한다")
|
||||
fun shouldReadTranslationsWithConstantSelectCountAcrossBatchSizes() {
|
||||
val postIds = (1..4).map { index ->
|
||||
persistPost(content = "batch source $index", languageCode = "ko", price = 0).id!!
|
||||
}
|
||||
val translationService = CreatorCommunityTranslationService(
|
||||
creatorCommunityRepository = creatorCommunityRepository,
|
||||
creatorCommunityTranslationRepository = creatorCommunityTranslationRepository,
|
||||
sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java),
|
||||
materializer = Mockito.mock(TranslationReadModelMaterializer::class.java),
|
||||
resourceTranslationJobScheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java),
|
||||
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java),
|
||||
entityManager = entityManager
|
||||
)
|
||||
val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
statistics.clear()
|
||||
translationService.findDisplayContents(postIds.take(1), "ja")
|
||||
val smallBatchStatementCount = statistics.prepareStatementCount
|
||||
|
||||
entityManager.clear()
|
||||
statistics.clear()
|
||||
translationService.findDisplayContents(postIds, "ja")
|
||||
val largeBatchStatementCount = statistics.prepareStatementCount
|
||||
|
||||
assertEquals(2L, smallBatchStatementCount)
|
||||
assertEquals(2L, largeBatchStatementCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("실제 레거시 목록 조회는 번역이 없어도 감지와 작업을 만들지 않는다")
|
||||
fun shouldNotPublishOrScheduleWhenLegacyListReadsMissingTranslation() {
|
||||
val post = persistPost(content = "missing translation", languageCode = null, price = 0)
|
||||
val publisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
val scheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
|
||||
translationJobRepository.deleteAll()
|
||||
entityManager.flush()
|
||||
val initialJobCount = translationJobRepository.count()
|
||||
|
||||
val responses = legacyService(
|
||||
sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java),
|
||||
materializer = materializer,
|
||||
publisher = publisher,
|
||||
scheduler = scheduler
|
||||
).getCommunityPostList(
|
||||
creatorId = post.member!!.id!!,
|
||||
memberId = 999L,
|
||||
timezone = "Asia/Seoul",
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
isAdult = true
|
||||
)
|
||||
|
||||
assertEquals(listOf("missing translation"), responses.map { it.content })
|
||||
assertEquals(initialJobCount, translationJobRepository.count())
|
||||
Mockito.verifyNoInteractions(publisher, materializer, scheduler)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("레거시 상세는 저장된 현재 번역문을 유료 마스킹 전에 사용한다")
|
||||
fun shouldUseStoredTranslationBeforeLegacyPaidMasking() {
|
||||
val post = persistPost(content = "source paid content", languageCode = "ko", price = 100)
|
||||
creatorCommunityTranslationRepository.save(
|
||||
CreatorCommunityTranslation(
|
||||
creatorCommunityId = post.id!!,
|
||||
locale = "ja",
|
||||
content = "translated paid content",
|
||||
sourceRevision = post.contentRevision,
|
||||
sourceHash = SourceTextNormalizer.hash(post.content),
|
||||
sourceLanguage = "ko"
|
||||
)
|
||||
)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, post.id!!))
|
||||
.thenReturn(translationSource(post.id!!, post.content, "ko", post.contentRevision))
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
Mockito.`when`(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, post.id!!, "ja"))
|
||||
.thenReturn(true)
|
||||
|
||||
val response = legacyService(sourceExtractor, materializer, Mockito.mock(ApplicationEventPublisher::class.java))
|
||||
.getCommunityPostDetail(post.id!!, memberId = 999L, timezone = "Asia/Seoul", isAdult = true)
|
||||
|
||||
assertEquals("translated paid...", response.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("언어 미확정 레거시 상세는 원문을 반환하고 요청 언어만 감지 이벤트에 전달한다")
|
||||
fun shouldReturnOriginalAndRequestOnlyCurrentLanguageDetectionForLegacyDetail() {
|
||||
val post = persistPost(content = "current original", languageCode = null, price = 0)
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, post.id!!)).thenReturn(null)
|
||||
val publisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||
|
||||
val response = legacyService(
|
||||
sourceExtractor,
|
||||
Mockito.mock(TranslationReadModelMaterializer::class.java),
|
||||
publisher
|
||||
).getCommunityPostDetail(post.id!!, memberId = 999L, timezone = "Asia/Seoul", isAdult = true)
|
||||
|
||||
assertEquals("current original", response.content)
|
||||
val eventCaptor = ArgumentCaptor.forClass(LanguageDetectEvent::class.java)
|
||||
Mockito.verify(publisher).publishEvent(eventCaptor.capture())
|
||||
assertEquals("ja", eventCaptor.value.targetLanguage)
|
||||
}
|
||||
|
||||
private fun legacyService(
|
||||
sourceExtractor: TranslationSourceExtractor,
|
||||
materializer: TranslationReadModelMaterializer,
|
||||
publisher: ApplicationEventPublisher,
|
||||
scheduler: ResourceTranslationJobScheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
|
||||
): CreatorCommunityService {
|
||||
val langContext = LangContext().apply { setLang(Lang.JA) }
|
||||
val translationService = CreatorCommunityTranslationService(
|
||||
creatorCommunityRepository = creatorCommunityRepository,
|
||||
creatorCommunityTranslationRepository = creatorCommunityTranslationRepository,
|
||||
sourceExtractor = sourceExtractor,
|
||||
materializer = materializer,
|
||||
resourceTranslationJobScheduler = scheduler,
|
||||
applicationEventPublisher = publisher,
|
||||
entityManager = entityManager
|
||||
)
|
||||
return CreatorCommunityService(
|
||||
canPaymentService = Mockito.mock(CanPaymentService::class.java),
|
||||
repository = creatorCommunityRepository,
|
||||
blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java),
|
||||
likeRepository = Mockito.mock(CreatorCommunityLikeRepository::class.java),
|
||||
commentRepository = Mockito.mock(CreatorCommunityCommentRepository::class.java),
|
||||
useCanRepository = Mockito.mock(UseCanRepository::class.java),
|
||||
s3Uploader = Mockito.mock(S3Uploader::class.java),
|
||||
objectMapper = ObjectMapper().registerModule(KotlinModule.Builder().build()),
|
||||
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java),
|
||||
applicationEventPublisher = publisher,
|
||||
messageSource = SodaMessageSource(),
|
||||
langContext = langContext,
|
||||
homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java),
|
||||
creatorCommunityTranslationService = translationService,
|
||||
entityManager = entityManager,
|
||||
imageBucket = "test-image-bucket",
|
||||
contentBucket = "test-content-bucket",
|
||||
imageHost = "https://cdn.test"
|
||||
)
|
||||
}
|
||||
|
||||
private fun persistPost(content: String, languageCode: String?, price: Int): CreatorCommunity {
|
||||
val creator = memberRepository.save(
|
||||
Member(
|
||||
email = "creator-${System.nanoTime()}@test.com",
|
||||
password = "password",
|
||||
nickname = "creator-${System.nanoTime()}",
|
||||
role = MemberRole.CREATOR
|
||||
)
|
||||
)
|
||||
return creatorCommunityRepository.save(
|
||||
CreatorCommunity(
|
||||
content = content,
|
||||
price = price,
|
||||
isCommentAvailable = false,
|
||||
isAdult = false,
|
||||
languageCode = languageCode
|
||||
).apply { member = creator }
|
||||
)
|
||||
}
|
||||
|
||||
private fun translationSource(
|
||||
postId: Long,
|
||||
content: String,
|
||||
sourceLanguage: String,
|
||||
sourceRevision: Long
|
||||
): TranslationSource {
|
||||
return TranslationSource(
|
||||
resourceType = LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
||||
resourceId = postId,
|
||||
sourceLanguage = sourceLanguage,
|
||||
fields = listOf(TranslationSourceField("content", content)),
|
||||
sourceRevision = sourceRevision
|
||||
)
|
||||
}
|
||||
}
|
||||
+47
-1
@@ -75,6 +75,51 @@ class TranslationJobWorkerTest {
|
||||
Mockito.verify(materializer, Mockito.times(1)).materialize(LanguageTranslationTargetType.CONTENT, 10L, "en")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldUseExistingMemoryBeforeMaterializingCreatorCommunity() {
|
||||
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
|
||||
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
|
||||
val provider = failingProvider()
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
val worker = TranslationJobWorker(
|
||||
translationJobRepository = jobRepository,
|
||||
translationMemoryRepository = memoryRepository,
|
||||
translationProvider = provider,
|
||||
materializer = materializer,
|
||||
transactionManager = TestTransactionManager()
|
||||
)
|
||||
val job = translationJob(
|
||||
resourceType = LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
||||
fieldKey = "content",
|
||||
sourceText = "본문"
|
||||
).also { it.id = 100L }
|
||||
Mockito.`when`(jobRepository.findNextPendingGroupJobIdsForUpdate(anyLocalDateTime())).thenReturn(listOf(100L))
|
||||
Mockito.`when`(jobRepository.findById(100L)).thenReturn(Optional.of(job))
|
||||
Mockito.`when`(
|
||||
memoryRepository.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
|
||||
job.sourceHash,
|
||||
"ko",
|
||||
"en",
|
||||
"papago",
|
||||
SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
).thenReturn(
|
||||
TranslationMemory(
|
||||
sourceHash = job.sourceHash,
|
||||
sourceText = job.sourceText,
|
||||
sourceLanguage = "ko",
|
||||
targetLanguage = "en",
|
||||
translatedText = "translated",
|
||||
provider = "papago",
|
||||
providerVersion = "nmt-v1"
|
||||
)
|
||||
)
|
||||
|
||||
worker.processNextGroup()
|
||||
|
||||
Mockito.verify(materializer).materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, 10L, "en")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldRetryGroupWhenMaterializationFails() {
|
||||
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
|
||||
@@ -174,11 +219,12 @@ class TranslationJobWorkerTest {
|
||||
|
||||
private fun translationJob(
|
||||
resourceId: Long = 10L,
|
||||
resourceType: LanguageTranslationTargetType = LanguageTranslationTargetType.CONTENT,
|
||||
fieldKey: String = "title",
|
||||
sourceText: String = "제목"
|
||||
): TranslationJob {
|
||||
return TranslationJob(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT,
|
||||
resourceType = resourceType,
|
||||
resourceId = resourceId,
|
||||
fieldKey = fieldKey,
|
||||
sourceHash = SourceTextNormalizer.hash(sourceText),
|
||||
|
||||
+166
-1
@@ -6,10 +6,12 @@ import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
@@ -54,7 +56,8 @@ class AiCharacterAdminCommunityPostConcurrencyTest @Autowired constructor(
|
||||
private val chatCharacterService: ChatCharacterService,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val entityManager: EntityManager,
|
||||
private val transactionManager: PlatformTransactionManager
|
||||
private val transactionManager: PlatformTransactionManager,
|
||||
private val creatorCommunityService: CreatorCommunityService
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var amazonS3Client: AmazonS3Client
|
||||
@@ -62,9 +65,15 @@ class AiCharacterAdminCommunityPostConcurrencyTest @Autowired constructor(
|
||||
@MockBean
|
||||
private lateinit var applicationEventPublisher: ApplicationEventPublisher
|
||||
|
||||
@MockBean
|
||||
private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService
|
||||
|
||||
@SpyBean
|
||||
private lateinit var creatorCommunityRepository: CreatorCommunityRepository
|
||||
|
||||
@SpyBean
|
||||
private lateinit var aiCharacterAdminCommunityPostRepository: AiCharacterAdminCommunityPostRepository
|
||||
|
||||
@Test
|
||||
@DisplayName("순차 고정 요청에서 세 번째는 허용되고 네 번째는 최대 고정 수로 거부된다")
|
||||
fun shouldAllowThirdFixedPostAndRejectFourthWhenRequestsAreSerialized() {
|
||||
@@ -167,6 +176,133 @@ class AiCharacterAdminCommunityPostConcurrencyTest @Autowired constructor(
|
||||
assertEquals(3L, transactionTemplate.execute { fixedPostCount(fixture.ownerId) })
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("병렬 본문 수정은 현재 행 잠금으로 개정을 순서대로 증가시킨다")
|
||||
fun shouldSerializeContentRevisionsWhenUpdatesRace() {
|
||||
val transactionTemplate = TransactionTemplate(transactionManager)
|
||||
val fixture = transactionTemplate.execute {
|
||||
val character = createCharacter("community-content-race")
|
||||
val post = savePost(character.creatorMember!!, "before").apply {
|
||||
languageCode = "ko"
|
||||
contentRevision = 0
|
||||
}
|
||||
entityManager.flush()
|
||||
ContentRaceFixture(character.id!!, post.id!!)
|
||||
} ?: throw IllegalStateException("fixture creation failed")
|
||||
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
val updatesReady = CountDownLatch(2)
|
||||
val startUpdates = CountDownLatch(1)
|
||||
try {
|
||||
val first = executor.submit<Int> {
|
||||
updatesReady.countDown()
|
||||
startUpdates.await(5, TimeUnit.SECONDS)
|
||||
mockMvc.perform(updateContentRequest(fixture.characterId, fixture.postId, "first"))
|
||||
.andReturn()
|
||||
.response
|
||||
.status
|
||||
}
|
||||
|
||||
val second = executor.submit<Int> {
|
||||
updatesReady.countDown()
|
||||
startUpdates.await(5, TimeUnit.SECONDS)
|
||||
mockMvc.perform(updateContentRequest(fixture.characterId, fixture.postId, "second"))
|
||||
.andReturn()
|
||||
.response
|
||||
.status
|
||||
}
|
||||
assertTrue(updatesReady.await(5, TimeUnit.SECONDS))
|
||||
startUpdates.countDown()
|
||||
assertEquals(listOf(200, 200), listOf(first.get(5, TimeUnit.SECONDS), second.get(5, TimeUnit.SECONDS)))
|
||||
} finally {
|
||||
startUpdates.countDown()
|
||||
executor.shutdown()
|
||||
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS))
|
||||
}
|
||||
|
||||
val updated = transactionTemplate.execute {
|
||||
entityManager.clear()
|
||||
entityManager.find(CreatorCommunity::class.java, fixture.postId)
|
||||
}!!
|
||||
assertEquals(2, updated.contentRevision)
|
||||
assertTrue(updated.content == "first" || updated.content == "second")
|
||||
Mockito.verify(creatorCommunityTranslationService, Mockito.times(2)).requestTranslations(fixture.postId, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("고정 상태의 stale read는 최신 본문 수정의 개정과 언어를 덮어쓰지 않는다")
|
||||
fun shouldApplyFixedAndContentUpdateAfterConcurrentContentCommit() {
|
||||
val transactionTemplate = TransactionTemplate(transactionManager)
|
||||
val fixture = transactionTemplate.execute {
|
||||
val character = createCharacter("community-fixed-content-race")
|
||||
val post = savePost(character.creatorMember!!, "before").apply {
|
||||
languageCode = "ko"
|
||||
contentRevision = 0
|
||||
}
|
||||
entityManager.flush()
|
||||
ContentRaceFixture(character.id!!, post.id!!)
|
||||
} ?: throw IllegalStateException("fixture creation failed")
|
||||
val staleAdminRead = CountDownLatch(1)
|
||||
val releaseFixedUpdate = CountDownLatch(1)
|
||||
val contentUpdatePreLock = CountDownLatch(1)
|
||||
val fixedReadPaused = AtomicBoolean(false)
|
||||
Mockito.doAnswer { invocation ->
|
||||
val post = invocation.callRealMethod() as CreatorCommunity?
|
||||
if (post?.id == fixture.postId && fixedReadPaused.compareAndSet(false, true)) {
|
||||
staleAdminRead.countDown()
|
||||
releaseFixedUpdate.await(5, TimeUnit.SECONDS)
|
||||
}
|
||||
post
|
||||
}.`when`(aiCharacterAdminCommunityPostRepository)
|
||||
.findActiveByIdAndCreatorMemberId(Mockito.anyLong(), Mockito.anyLong())
|
||||
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
try {
|
||||
val fixedAndContent = executor.submit<Int> {
|
||||
mockMvc.perform(fixedAndContentRequest(fixture.characterId, fixture.postId, "after fixed"))
|
||||
.andReturn()
|
||||
.response
|
||||
.status
|
||||
}
|
||||
assertTrue(staleAdminRead.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val concurrentContent = executor.submit<Int> {
|
||||
contentUpdatePreLock.countDown()
|
||||
creatorCommunityService.modifyCommunityPost(
|
||||
postImage = null,
|
||||
requestString = objectMapper.writeValueAsString(
|
||||
mapOf("creatorCommunityId" to fixture.postId, "content" to "after concurrent")
|
||||
),
|
||||
member = transactionTemplate.execute {
|
||||
entityManager.find(CreatorCommunity::class.java, fixture.postId).member!!
|
||||
}!!
|
||||
)
|
||||
200
|
||||
}
|
||||
assertTrue(contentUpdatePreLock.await(5, TimeUnit.SECONDS))
|
||||
assertEquals(200, concurrentContent.get(5, TimeUnit.SECONDS))
|
||||
|
||||
releaseFixedUpdate.countDown()
|
||||
assertEquals(200, fixedAndContent.get(5, TimeUnit.SECONDS))
|
||||
} finally {
|
||||
releaseFixedUpdate.countDown()
|
||||
executor.shutdown()
|
||||
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS))
|
||||
}
|
||||
|
||||
val updated = transactionTemplate.execute {
|
||||
entityManager.clear()
|
||||
entityManager.find(CreatorCommunity::class.java, fixture.postId)
|
||||
}!!
|
||||
assertEquals("after fixed", updated.content)
|
||||
assertEquals(2, updated.contentRevision)
|
||||
assertEquals(null, updated.languageCode)
|
||||
assertTrue(updated.isFixed)
|
||||
assertNotNull(updated.fixedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid target와 cross-owner 고정 요청은 DB, S3, event 변경 없이 거부된다")
|
||||
fun shouldRejectInvalidTargetAndCrossOwnerFixedRequestsWithoutSideEffects() {
|
||||
@@ -213,6 +349,30 @@ class AiCharacterAdminCommunityPostConcurrencyTest @Autowired constructor(
|
||||
postImage?.let(::file)
|
||||
}.with(adminAuthentication())
|
||||
|
||||
private fun updateContentRequest(characterId: Long, postId: Long, content: String) =
|
||||
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/$characterId/community-posts/$postId")
|
||||
.file(
|
||||
MockMultipartFile(
|
||||
"request",
|
||||
"request.json",
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
objectMapper.writeValueAsBytes(mapOf("content" to content))
|
||||
)
|
||||
)
|
||||
.with(adminAuthentication())
|
||||
|
||||
private fun fixedAndContentRequest(characterId: Long, postId: Long, content: String) =
|
||||
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/$characterId/community-posts/$postId")
|
||||
.file(
|
||||
MockMultipartFile(
|
||||
"request",
|
||||
"request.json",
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
objectMapper.writeValueAsBytes(mapOf("isFixed" to true, "content" to content))
|
||||
)
|
||||
)
|
||||
.with(adminAuthentication())
|
||||
|
||||
private fun pngFile() = MockMultipartFile(
|
||||
"postImage",
|
||||
"post.png",
|
||||
@@ -283,4 +443,9 @@ class AiCharacterAdminCommunityPostConcurrencyTest @Autowired constructor(
|
||||
val firstPostId: Long,
|
||||
val secondPostId: Long
|
||||
)
|
||||
|
||||
private data class ContentRaceFixture(
|
||||
val characterId: Long,
|
||||
val postId: Long
|
||||
)
|
||||
}
|
||||
|
||||
+20
@@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommuni
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
@@ -34,6 +35,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multi
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.net.URL
|
||||
import javax.persistence.EntityManager
|
||||
@@ -51,6 +53,9 @@ class AiCharacterAdminCommunityPostCreateTest @Autowired constructor(
|
||||
@MockBean
|
||||
private lateinit var amazonS3Client: AmazonS3Client
|
||||
|
||||
@MockBean
|
||||
private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService
|
||||
|
||||
@Test
|
||||
@DisplayName("정상 무료 게시글 생성은 target 소유자를 작성자로 사용하고 data null을 반환한다")
|
||||
fun shouldCreateFreePostForTargetOwnerAndReturnNullData() {
|
||||
@@ -73,6 +78,21 @@ class AiCharacterAdminCommunityPostCreateTest @Autowired constructor(
|
||||
assertTrue(post.member!!.id != otherCharacter.creatorMember!!.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("관리자 생성은 게시글 커밋 후 공용 번역 요청을 한 번 보낸다")
|
||||
fun shouldRequestTranslationsAfterCommittedAdminCreation() {
|
||||
val character = createCharacter("community-create-translation")
|
||||
val content = "translation request after committed admin creation"
|
||||
|
||||
mockMvc.perform(createRequest(character.id!!, content = content))
|
||||
.andExpect(status().isOk)
|
||||
|
||||
val post = findPost(content)
|
||||
assertNotNull(post)
|
||||
Mockito.verify(creatorCommunityTranslationService).requestTranslations(post!!.id!!, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("생성 price 생략은 기본값 0으로 게시글을 생성한다")
|
||||
fun shouldCreatePostWithDefaultPriceWhenPriceIsOmitted() {
|
||||
|
||||
+4
@@ -62,6 +62,8 @@ class AiCharacterAdminCommunityPostUpdateTest @Autowired constructor(
|
||||
val post = savePost(character.creatorMember!!, "before", isCommentAvailable = true, isAdult = false).apply {
|
||||
isFixed = true
|
||||
fixedAt = LocalDateTime.now()
|
||||
languageCode = "ko"
|
||||
contentRevision = 7
|
||||
}
|
||||
entityManager.flush()
|
||||
|
||||
@@ -86,6 +88,8 @@ class AiCharacterAdminCommunityPostUpdateTest @Autowired constructor(
|
||||
assertTrue(updated.isAdult)
|
||||
assertTrue(updated.isFixed)
|
||||
assertNotNull(updated.fixedAt)
|
||||
assertNull(updated.languageCode)
|
||||
assertEquals(8, updated.contentRevision)
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -23,6 +23,7 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
@@ -37,6 +38,7 @@ import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import java.io.InputStream
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
class LegacyCommunityPostCharacterizationTest {
|
||||
private lateinit var repository: CreatorCommunityRepository
|
||||
@@ -65,6 +67,8 @@ class LegacyCommunityPostCharacterizationTest {
|
||||
messageSource = SodaMessageSource(),
|
||||
langContext = LangContext(),
|
||||
homeFollowingNewsPublishService = homeFollowingNewsPublishService,
|
||||
creatorCommunityTranslationService = Mockito.mock(CreatorCommunityTranslationService::class.java),
|
||||
entityManager = Mockito.mock(EntityManager::class.java),
|
||||
imageBucket = "image-bucket",
|
||||
contentBucket = "content-bucket",
|
||||
imageHost = "https://cdn.test"
|
||||
@@ -176,7 +180,7 @@ class LegacyCommunityPostCharacterizationTest {
|
||||
post.member = creator
|
||||
post.isFixed = true
|
||||
post.fixedAt = LocalDateTime.of(2026, 7, 28, 10, 0)
|
||||
Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post)
|
||||
Mockito.`when`(repository.findByIdAndMemberIdForUpdate(post.id!!, creator.id!!)).thenReturn(post)
|
||||
Mockito.`when`(repository.countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(creator.id!!)).thenReturn(3L)
|
||||
|
||||
service.updateCommunityPostFixed(UpdateCommunityPostFixedRequest(post.id!!, isFixed = true), creator)
|
||||
@@ -193,7 +197,7 @@ class LegacyCommunityPostCharacterizationTest {
|
||||
val otherPost = CreatorCommunity("other fixed post", 0, true, false)
|
||||
otherPost.id = 1004L
|
||||
otherPost.member = creator
|
||||
Mockito.`when`(repository.findByIdAndMemberId(otherPost.id!!, creator.id!!)).thenReturn(otherPost)
|
||||
Mockito.`when`(repository.findByIdAndMemberIdForUpdate(otherPost.id!!, creator.id!!)).thenReturn(otherPost)
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
service.updateCommunityPostFixed(UpdateCommunityPostFixedRequest(otherPost.id!!, isFixed = true), creator)
|
||||
}
|
||||
|
||||
+152
@@ -13,6 +13,8 @@ import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMember
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityDisplayContent
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
@@ -47,6 +49,9 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
||||
@MockBean
|
||||
private lateinit var audioContentCloudFront: AudioContentCloudFront
|
||||
|
||||
@MockBean
|
||||
private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 탭 API는 E2E로 정렬, fallback, 성인 필터, 유료 미디어 접근 정책을 반환한다")
|
||||
fun shouldReturnCommunityTabThroughControllerServiceAndRepository() {
|
||||
@@ -97,6 +102,41 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
||||
Mockito.verifyNoMoreInteractions(audioContentCloudFront)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 탭 API는 저장 번역을 페이지 단위로 선택하고 유료 미리보기를 유지한다")
|
||||
fun shouldUseStoredTranslationsForCommunityTabWithoutScheduling() {
|
||||
val fixture = createFixture()
|
||||
val postIds = listOf(
|
||||
fixture.pinnedPostId,
|
||||
fixture.purchasedPaidPostId,
|
||||
fixture.unpurchasedPaidPostId,
|
||||
fixture.noImagePostId
|
||||
)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(postIds, "ja"))
|
||||
.thenReturn(
|
||||
mapOf(
|
||||
fixture.pinnedPostId to CreatorCommunityDisplayContent("translated pinned", true),
|
||||
fixture.purchasedPaidPostId to CreatorCommunityDisplayContent("translated purchased", true),
|
||||
fixture.unpurchasedPaidPostId to CreatorCommunityDisplayContent("가나다라마바사아자차카타파하🙂끝", true),
|
||||
fixture.noImagePostId to CreatorCommunityDisplayContent("no image community", false)
|
||||
)
|
||||
)
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/creator-channels/${fixture.creatorId}/community")
|
||||
.header("Accept-Language", "ja")
|
||||
.with(user(MemberAdapter(fixture.viewer)))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.communityPosts[0].content").value("translated pinned"))
|
||||
.andExpect(jsonPath("$.data.communityPosts[1].content").value("translated purchased"))
|
||||
.andExpect(jsonPath("$.data.communityPosts[2].content").value("가나다라마바사아자차카타파하🙂..."))
|
||||
.andExpect(jsonPath("$.data.communityPosts[3].content").value("no image community"))
|
||||
|
||||
Mockito.verify(creatorCommunityTranslationService).findDisplayContents(postIds, "ja")
|
||||
Mockito.verifyNoMoreInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 상세 API는 초기 댓글 20개와 최신 답글을 반환한다")
|
||||
fun shouldReturnCommunityPostDetailWithInitialComments() {
|
||||
@@ -115,6 +155,74 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
||||
.andExpect(jsonPath("$.data.comments.comments[0].createdAtUtc").value(org.hamcrest.Matchers.endsWith("Z")))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 상세 API는 저장 번역문에 유료 미리보기를 적용하고 요청 언어만 예약한다")
|
||||
fun shouldSelectTranslatedContentBeforePaidMaskingAndRequestCurrentLanguage() {
|
||||
val fixture = createTranslatedPaidDetailFixture(purchased = false)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(fixture.postId), "ja"))
|
||||
.thenReturn(mapOf(fixture.postId to CreatorCommunityDisplayContent("translated paid content", true)))
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/creator-channels/community-posts/${fixture.postId}")
|
||||
.header("Accept-Language", "ja")
|
||||
.with(user(MemberAdapter(fixture.viewer)))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.content").value("translated paid..."))
|
||||
|
||||
Mockito.verify(creatorCommunityTranslationService, Mockito.never()).requestTranslations(fixture.postId, "ja")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 상세 API는 구매자에게 선택한 유료 번역문 전체를 반환한다")
|
||||
fun shouldReturnFullTranslatedContentToBuyer() {
|
||||
val fixture = createTranslatedPaidDetailFixture(purchased = true)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(fixture.postId), "ja"))
|
||||
.thenReturn(mapOf(fixture.postId to CreatorCommunityDisplayContent("translated paid content", true)))
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/creator-channels/community-posts/${fixture.postId}")
|
||||
.header("Accept-Language", "ja")
|
||||
.with(user(MemberAdapter(fixture.viewer)))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.content").value("translated paid content"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 상세 API는 소유자에게 선택한 유료 번역문 전체를 반환한다")
|
||||
fun shouldReturnFullTranslatedContentToOwner() {
|
||||
val fixture = createTranslatedPaidOwnerDetailFixture()
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(fixture.postId), "ja"))
|
||||
.thenReturn(mapOf(fixture.postId to CreatorCommunityDisplayContent("translated paid content", true)))
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/creator-channels/community-posts/${fixture.postId}")
|
||||
.header("Accept-Language", "ja")
|
||||
.with(user(MemberAdapter(fixture.viewer)))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.content").value("translated paid content"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 상세 API는 반복 요청을 모두 성공시키고 같은 요청 언어만 전달한다")
|
||||
fun shouldKeepRepeatedCommunityDetailRequestsSuccessful() {
|
||||
val fixture = createTranslatedPaidDetailFixture(purchased = false)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(fixture.postId), "ja"))
|
||||
.thenReturn(emptyMap())
|
||||
|
||||
repeat(2) {
|
||||
mockMvc.perform(
|
||||
get("/api/v2/creator-channels/community-posts/${fixture.postId}")
|
||||
.header("Accept-Language", "ja")
|
||||
.with(user(MemberAdapter(fixture.viewer)))
|
||||
).andExpect(status().isOk)
|
||||
}
|
||||
|
||||
Mockito.verify(creatorCommunityTranslationService, Mockito.times(2)).requestTranslations(fixture.postId, "ja")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 댓글 API는 댓글 페이지와 최신 답글을 반환한다")
|
||||
fun shouldReturnPagedCommunityCommentsWithLatestReply() {
|
||||
@@ -131,6 +239,8 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
||||
.andExpect(jsonPath("$.data.comments.length()").value(20))
|
||||
.andExpect(jsonPath("$.data.hasNext").value(true))
|
||||
.andExpect(jsonPath("$.data.comments[0].latestReply.commentId").value(fixture.latestReplyId))
|
||||
|
||||
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -148,6 +258,8 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
||||
.andExpect(jsonPath("$.data.replyCount").value(1))
|
||||
.andExpect(jsonPath("$.data.replies[0].commentId").value(fixture.latestReplyId))
|
||||
.andExpect(jsonPath("$.data.replies[0].latestReply").doesNotExist())
|
||||
|
||||
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -176,6 +288,8 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
|
||||
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -189,6 +303,8 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
|
||||
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -435,6 +551,42 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
||||
}!!
|
||||
}
|
||||
|
||||
private fun createTranslatedPaidDetailFixture(purchased: Boolean): PostFixture {
|
||||
return transactionTemplate.execute {
|
||||
val viewer = saveMember("community-translated-viewer-$purchased", MemberRole.USER)
|
||||
val creator = saveMember("community-translated-creator-$purchased", MemberRole.CREATOR)
|
||||
savePreference(viewer, isAdultContentVisible = true)
|
||||
val post = saveCommunity(
|
||||
creator = creator,
|
||||
isFixed = false,
|
||||
price = 100,
|
||||
content = "source paid content"
|
||||
)
|
||||
if (purchased) {
|
||||
saveCommunityOrder(viewer, post)
|
||||
}
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
PostFixture(viewer = viewer, postId = post.id!!)
|
||||
}!!
|
||||
}
|
||||
|
||||
private fun createTranslatedPaidOwnerDetailFixture(): PostFixture {
|
||||
return transactionTemplate.execute {
|
||||
val creator = saveMember("community-translated-owner", MemberRole.CREATOR)
|
||||
savePreference(creator, isAdultContentVisible = true)
|
||||
val post = saveCommunity(
|
||||
creator = creator,
|
||||
isFixed = false,
|
||||
price = 100,
|
||||
content = "source paid content"
|
||||
)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
PostFixture(viewer = creator, postId = post.id!!)
|
||||
}!!
|
||||
}
|
||||
|
||||
private fun createCommentUnavailableFixture(): PostFixture {
|
||||
return transactionTemplate.execute {
|
||||
val viewer = saveMember("community-unavailable-viewer", MemberRole.USER)
|
||||
|
||||
+39
@@ -11,12 +11,14 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
|
||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
|
||||
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
||||
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
||||
import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisit
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
@@ -82,6 +84,28 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor(
|
||||
.andExpect(jsonPath("$.data.sns.instagramUrl").value("e2e-instagram"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("크리에이터 채널 홈 API는 고정과 일반 커뮤니티의 저장 번역을 반환한다")
|
||||
fun shouldUseStoredTranslationsForPinnedAndNormalHomeCommunityPosts() {
|
||||
Mockito.doReturn("US").`when`(countryContext).countryCode
|
||||
val fixture = createFixture()
|
||||
transactionTemplate.execute {
|
||||
saveCommunityTranslation(fixture.noticeId, "translated notice")
|
||||
saveCommunityTranslation(fixture.communityId, "translated community")
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
}
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/creator-channels/${fixture.creatorId}/home")
|
||||
.header("Accept-Language", "ja")
|
||||
.with(user(MemberAdapter(fixture.viewer)))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.notices[0].content").value("translated notice"))
|
||||
.andExpect(jsonPath("$.data.communities[0].content").value("translated community"))
|
||||
}
|
||||
|
||||
private fun createFixture(): Fixture {
|
||||
return transactionTemplate.execute {
|
||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||
@@ -237,6 +261,21 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor(
|
||||
return post
|
||||
}
|
||||
|
||||
private fun saveCommunityTranslation(postId: Long, content: String) {
|
||||
val post = entityManager.find(CreatorCommunity::class.java, postId)
|
||||
post.languageCode = "ko"
|
||||
entityManager.persist(
|
||||
CreatorCommunityTranslation(
|
||||
creatorCommunityId = postId,
|
||||
locale = "ja",
|
||||
content = content,
|
||||
sourceRevision = post.contentRevision,
|
||||
sourceHash = SourceTextNormalizer.hash(post.content),
|
||||
sourceLanguage = "ko"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun saveCheers(member: Member, creator: Member, cheers: String, createdAt: LocalDateTime): CreatorCheers {
|
||||
val creatorCheers = CreatorCheers(cheers = cheers, languageCode = "ko", isActive = true)
|
||||
creatorCheers.member = member
|
||||
|
||||
+75
@@ -1,6 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.home
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
@@ -12,6 +13,8 @@ import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacade
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityDisplayContent
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshot
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy
|
||||
@@ -27,6 +30,7 @@ import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.boot.test.system.CapturedOutput
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension
|
||||
import org.springframework.http.MediaType
|
||||
@@ -53,6 +57,9 @@ class HomeRecommendationControllerTest @Autowired constructor(
|
||||
private val creatorFollowingRepository: CreatorFollowingRepository,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService
|
||||
|
||||
@Test
|
||||
@DisplayName("추천 크리에이터 동시 팔로우 비로그인 요청은 Spring Security에서 거부한다")
|
||||
fun shouldRejectAnonymousFollowRequest() {
|
||||
@@ -230,6 +237,60 @@ class HomeRecommendationControllerTest @Autowired constructor(
|
||||
assertTrue(output.out.contains("emptySections="))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("비회원 홈 추천은 반환된 인기 커뮤니티 게시물만 일본어 표시 본문으로 반환한다")
|
||||
fun shouldReturnJapanesePopularCommunityTranslationForAnonymous() {
|
||||
val translatedCreator = saveMember("popular-community-translated-creator", MemberRole.CREATOR)
|
||||
val fallbackCreator = saveMember("popular-community-fallback-creator", MemberRole.CREATOR)
|
||||
val translatedPost = saveCommunityPost(translatedCreator, "historical translated popular body")
|
||||
val fallbackPost = saveCommunityPost(fallbackCreator, "historical fallback popular body")
|
||||
val snapshotAt = RecommendationSnapshotWindowPolicy()
|
||||
.previousKstDayUtcWindow(LocalDateTime.now(ZoneOffset.UTC))
|
||||
.snapshotAt
|
||||
entityManager.persist(
|
||||
RecommendationSnapshot(
|
||||
sectionType = RecommendedSectionType.POPULAR_COMMUNITY,
|
||||
targetId = translatedPost.id!!,
|
||||
score = 100.0,
|
||||
snapshotAt = snapshotAt,
|
||||
randomTieBreaker = 0.1
|
||||
)
|
||||
)
|
||||
entityManager.persist(
|
||||
RecommendationSnapshot(
|
||||
sectionType = RecommendedSectionType.POPULAR_COMMUNITY,
|
||||
targetId = fallbackPost.id!!,
|
||||
score = 90.0,
|
||||
snapshotAt = snapshotAt,
|
||||
randomTieBreaker = 0.2
|
||||
)
|
||||
)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
Mockito.`when`(
|
||||
creatorCommunityTranslationService.findDisplayContents(
|
||||
listOf(translatedPost.id!!, fallbackPost.id!!),
|
||||
"ja"
|
||||
)
|
||||
).thenReturn(
|
||||
mapOf(
|
||||
translatedPost.id!! to CreatorCommunityDisplayContent("translated popular body ja", true),
|
||||
fallbackPost.id!! to CreatorCommunityDisplayContent("current fallback popular body ja", false)
|
||||
)
|
||||
)
|
||||
|
||||
mockMvc.perform(get("/api/v2/home/recommendations").header("Accept-Language", "ja"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.popularCommunityPosts[0].content").value("translated popular body ja"))
|
||||
.andExpect(jsonPath("$.data.popularCommunityPosts[1].content").value("current fallback popular body ja"))
|
||||
|
||||
Mockito.verify(creatorCommunityTranslationService).findDisplayContents(
|
||||
listOf(translatedPost.id!!, fallbackPost.id!!),
|
||||
"ja"
|
||||
)
|
||||
Mockito.verifyNoMoreInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("메인 홈 통합 조회는 인증 회원도 호출 가능하고 성공 응답한다")
|
||||
fun shouldReturnHomeRecommendationsForMember() {
|
||||
@@ -590,6 +651,20 @@ class HomeRecommendationControllerTest @Autowired constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private fun saveCommunityPost(creator: Member, content: String): CreatorCommunity {
|
||||
val post = CreatorCommunity(
|
||||
content = content,
|
||||
price = 0,
|
||||
isCommentAvailable = true,
|
||||
isAdult = false,
|
||||
isActive = true
|
||||
).apply {
|
||||
member = creator
|
||||
}
|
||||
entityManager.persist(post)
|
||||
return post
|
||||
}
|
||||
|
||||
private fun saveLiveRoom(
|
||||
creator: Member,
|
||||
beginDateTime: LocalDateTime,
|
||||
|
||||
+4
-1
@@ -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")
|
||||
|
||||
+55
@@ -18,6 +18,8 @@ import kr.co.vividnext.sodalive.member.MemberKind
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityDisplayContent
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInbox
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
|
||||
import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.CreatorRankingSnapshot
|
||||
@@ -63,6 +65,9 @@ class HomeFollowingEndToEndTest @Autowired constructor(
|
||||
@MockBean
|
||||
private lateinit var countryContext: CountryContext
|
||||
|
||||
@MockBean
|
||||
private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService
|
||||
|
||||
@Test
|
||||
@DisplayName("팔로잉 탭 API는 비회원에게 200 OK와 로그인 필요 빈 섹션 응답을 반환한다")
|
||||
fun shouldReturnLoginRequiredForAnonymous() {
|
||||
@@ -137,6 +142,56 @@ class HomeFollowingEndToEndTest @Autowired constructor(
|
||||
.andExpect(jsonPath("$.data.recentNews[?(@.communityPost.postId == ${fixture.paidPostId})]").isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("인증 회원 팔로잉 탭은 노출된 커뮤니티 소식만 영어 표시 본문으로 반환하고 다른 소식은 유지한다")
|
||||
fun shouldReturnEnglishCommunityTranslationForAuthenticatedFollowingTab() {
|
||||
Mockito.doReturn("US").`when`(countryContext).countryCode
|
||||
val fixture = createFixture()
|
||||
val fallbackPostId = transactionTemplate.execute {
|
||||
val creator = entityManager.find(Member::class.java, fixture.creatorId)
|
||||
val fallbackPost = saveCommunityPost(
|
||||
creator,
|
||||
"historical following fallback body",
|
||||
price = 0,
|
||||
imagePath = "community/fallback.png"
|
||||
)
|
||||
saveNews(
|
||||
memberId = fixture.viewer.id!!,
|
||||
creatorId = fixture.creatorId,
|
||||
sourceKey = "fallback-community-news",
|
||||
visibleFromAtUtc = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(10),
|
||||
newsType = FollowingNewsType.COMMUNITY_POST,
|
||||
targetId = fallbackPost.id!!
|
||||
)
|
||||
entityManager.flush()
|
||||
fallbackPost.id!!
|
||||
}!!
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(fallbackPostId, fixture.freePostId), "en"))
|
||||
.thenReturn(
|
||||
mapOf(
|
||||
fallbackPostId to CreatorCommunityDisplayContent("current following fallback body en", false),
|
||||
fixture.freePostId to CreatorCommunityDisplayContent("translated following body en", true)
|
||||
)
|
||||
)
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/home/following")
|
||||
.header("Accept-Language", "en")
|
||||
.with(user(MemberAdapter(fixture.viewer)))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.recentNews[0].type").value("COMMUNITY_POST"))
|
||||
.andExpect(jsonPath("$.data.recentNews[0].communityPost.content").value("current following fallback body en"))
|
||||
.andExpect(jsonPath("$.data.recentNews[1].type").value("CREATOR_RANKING"))
|
||||
.andExpect(jsonPath("$.data.recentNews[2].type").value("COMMUNITY_POST"))
|
||||
.andExpect(jsonPath("$.data.recentNews[2].communityPost.content").value("translated following body en"))
|
||||
.andExpect(jsonPath("$.data.recentNews[3].type").value("AUDIO_CONTENT"))
|
||||
.andExpect(jsonPath("$.data.recentNews[3].audioContent.title").value("home-following-audio"))
|
||||
|
||||
Mockito.verify(creatorCommunityTranslationService).findDisplayContents(listOf(fallbackPostId, fixture.freePostId), "en")
|
||||
Mockito.verifyNoMoreInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
private fun createFixture(): Fixture {
|
||||
return transactionTemplate.execute {
|
||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||
|
||||
+108
-1
@@ -15,6 +15,8 @@ import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorCha
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityDisplayContent
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
@@ -175,6 +177,92 @@ class CreatorChannelCommunityQueryServiceTest {
|
||||
assertEquals(true, posts.single().isLiked)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 탭은 페이지 본문을 한 번에 번역 선택한 뒤 유료 미리보기를 적용한다")
|
||||
fun shouldUseStoredTranslationsOnceForCommunityTabBeforePaidMasking() {
|
||||
val port = FakeCreatorChannelCommunityQueryPort().apply {
|
||||
communityPosts = listOf(
|
||||
communityPostRecord(11L, price = 0).copy(content = "source free"),
|
||||
communityPostRecord(12L, price = 100).copy(content = "source paid"),
|
||||
communityPostRecord(13L, price = 100, existOrdered = true).copy(content = "source buyer"),
|
||||
communityPostRecord(14L, creatorId = 10L, price = 100).copy(content = "source owner"),
|
||||
communityPostRecord(15L, price = 0).copy(content = "source stale")
|
||||
)
|
||||
}
|
||||
val translationService = Mockito.mock(CreatorCommunityTranslationService::class.java)
|
||||
val service = createService(port, creatorCommunityTranslationService = translationService)
|
||||
val postIds = port.communityPosts.map { it.postId }
|
||||
Mockito.`when`(translationService.findDisplayContents(postIds, "en"))
|
||||
.thenReturn(
|
||||
mapOf(
|
||||
11L to CreatorCommunityDisplayContent("translated free", true),
|
||||
12L to CreatorCommunityDisplayContent("가나다라마바사아자차카타파하🙂끝", true),
|
||||
13L to CreatorCommunityDisplayContent("translated buyer", true),
|
||||
14L to CreatorCommunityDisplayContent("translated owner", true),
|
||||
15L to CreatorCommunityDisplayContent("source stale", false)
|
||||
)
|
||||
)
|
||||
|
||||
val posts = service.getCommunityTab(1L, createMember(id = 10L), 0, 20).communityPosts
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"translated free",
|
||||
"가나다라마바사아자차카타파하🙂...",
|
||||
"translated buyer",
|
||||
"translated owner",
|
||||
"source stale"
|
||||
),
|
||||
posts.map { it.content }
|
||||
)
|
||||
Mockito.verify(translationService).findDisplayContents(postIds, "en")
|
||||
Mockito.verifyNoMoreInteractions(translationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 탭 번역 조회는 fetchLimit 다음 게시글을 제외한 반환 페이지 ID만 사용한다")
|
||||
fun shouldExcludeFetchLimitPlusOnePostFromCommunityTabTranslationLookup() {
|
||||
val port = FakeCreatorChannelCommunityQueryPort().apply {
|
||||
communityPosts = (1L..51L).map { communityPostRecord(it, price = 0) }
|
||||
}
|
||||
val translationService = Mockito.mock(CreatorCommunityTranslationService::class.java)
|
||||
val service = createService(port, creatorCommunityTranslationService = translationService)
|
||||
val returnedPostIds = (1L..50L).toList()
|
||||
Mockito.`when`(translationService.findDisplayContents(returnedPostIds, "en")).thenReturn(emptyMap())
|
||||
|
||||
val tab = service.getCommunityTab(1L, createMember(id = 10L), page = 0, size = 50)
|
||||
|
||||
assertEquals(50, tab.communityPosts.size)
|
||||
assertTrue(tab.hasNext)
|
||||
Mockito.verify(translationService).findDisplayContents(returnedPostIds, "en")
|
||||
Mockito.verifyNoMoreInteractions(translationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("채널 홈의 고정과 일반 본문은 각 그룹에서 번역 선택만 하고 작업을 만들지 않는다")
|
||||
fun shouldUseStoredTranslationsForPinnedAndNormalHomeCommunityPostsWithoutScheduling() {
|
||||
val port = FakeCreatorChannelCommunityQueryPort()
|
||||
val translationService = Mockito.mock(CreatorCommunityTranslationService::class.java)
|
||||
val service = createService(port, creatorCommunityTranslationService = translationService)
|
||||
port.homeCommunityPosts = listOf(communityPostRecord(21L, price = 0).copy(content = "source pinned"))
|
||||
Mockito.`when`(translationService.findDisplayContents(listOf(21L), "en"))
|
||||
.thenReturn(mapOf(21L to CreatorCommunityDisplayContent("translated pinned", true)))
|
||||
|
||||
val pinnedPosts = service.findHomeCommunityPosts(1L, 10L, true, false, 3)
|
||||
|
||||
port.homeCommunityPosts = listOf(communityPostRecord(22L, price = 0).copy(content = "source normal"))
|
||||
Mockito.`when`(translationService.findDisplayContents(listOf(22L), "en"))
|
||||
.thenReturn(mapOf(22L to CreatorCommunityDisplayContent("translated normal", true)))
|
||||
|
||||
val normalPosts = service.findHomeCommunityPosts(1L, 10L, false, false, 3)
|
||||
|
||||
assertEquals("translated pinned", pinnedPosts.single().content)
|
||||
assertEquals("translated normal", normalPosts.single().content)
|
||||
Mockito.verify(translationService).findDisplayContents(listOf(21L), "en")
|
||||
Mockito.verify(translationService).findDisplayContents(listOf(22L), "en")
|
||||
Mockito.verifyNoMoreInteractions(translationService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 상세는 게시글과 초기 댓글 및 최신 답글을 조립한다")
|
||||
fun shouldAssembleCommunityPostDetailWithInitialComments() {
|
||||
@@ -201,6 +289,20 @@ class CreatorChannelCommunityQueryServiceTest {
|
||||
assertEquals("https://cdn.test/profile/default-profile.png", detail.comments.comments.first().writerProfileImageUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("v2 상세는 원문과 같은 유효 번역문에도 누락 예약을 요청하지 않는다")
|
||||
fun shouldNotRequestTranslationsWhenV2DetailHasValidTranslationMatchingSourceText() {
|
||||
val port = FakeCreatorChannelCommunityQueryPort()
|
||||
val translationService = Mockito.mock(CreatorCommunityTranslationService::class.java)
|
||||
val service = createService(port, creatorCommunityTranslationService = translationService)
|
||||
Mockito.`when`(translationService.findDisplayContents(listOf(1L), "en"))
|
||||
.thenReturn(mapOf(1L to CreatorCommunityDisplayContent("content-1", true)))
|
||||
|
||||
service.getCommunityPostDetail(1L, createMember(id = 10L), LocalDateTime.of(2026, 7, 6, 10, 0))
|
||||
|
||||
Mockito.verify(translationService, Mockito.never()).requestTranslations(1L, "en")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("댓글 불가 게시글은 댓글 port를 호출하지 않고 빈 댓글 응답을 반환한다")
|
||||
fun shouldReturnEmptyCommentsWhenCommentUnavailable() {
|
||||
@@ -298,7 +400,9 @@ class CreatorChannelCommunityQueryServiceTest {
|
||||
private fun createService(
|
||||
port: FakeCreatorChannelCommunityQueryPort,
|
||||
audioContentCloudFront: AudioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java),
|
||||
canViewAdultContent: Boolean = true
|
||||
canViewAdultContent: Boolean = true,
|
||||
creatorCommunityTranslationService: CreatorCommunityTranslationService =
|
||||
Mockito.mock(CreatorCommunityTranslationService::class.java)
|
||||
): CreatorChannelCommunityQueryService {
|
||||
val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||
Mockito.`when`(
|
||||
@@ -306,6 +410,8 @@ class CreatorChannelCommunityQueryServiceTest {
|
||||
).thenReturn(canViewAdultContent)
|
||||
val langContext = LangContext()
|
||||
langContext.setLang(Lang.EN)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(Mockito.anyList(), Mockito.anyString()))
|
||||
.thenReturn(emptyMap())
|
||||
return CreatorChannelCommunityQueryService(
|
||||
queryPortProvider = FixedCreatorChannelCommunityQueryPortProvider(port),
|
||||
queryPolicy = CreatorChannelCommunityQueryPolicy(),
|
||||
@@ -313,6 +419,7 @@ class CreatorChannelCommunityQueryServiceTest {
|
||||
audioContentCloudFront = audioContentCloudFront,
|
||||
messageSource = SodaMessageSource(),
|
||||
langContext = langContext,
|
||||
creatorCommunityTranslationService = creatorCommunityTranslationService,
|
||||
cloudFrontHost = "https://cdn.test"
|
||||
)
|
||||
}
|
||||
|
||||
+878
@@ -0,0 +1,878 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.community.translation
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
|
||||
import kr.co.vividnext.sodalive.admin.content.series.genre.AdminContentSeriesGenreRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.content.AudioContentRepository
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||
import kr.co.vividnext.sodalive.content.category.CategoryRepository
|
||||
import kr.co.vividnext.sodalive.content.category.CategoryTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJob
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobRepository
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationMemory
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationMemoryRepository
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationReadModelMaterializer
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationSource
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceExtractor
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceField
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslationRepository
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.TransactionDefinition
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@DataJpaTest(
|
||||
properties = [
|
||||
"spring.cache.type=none",
|
||||
"spring.datasource.url=jdbc:h2:mem:creator-community-translation;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
|
||||
]
|
||||
)
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import(QueryDslConfig::class)
|
||||
class CreatorCommunityTranslationServiceTest @Autowired constructor(
|
||||
private val persistentCreatorCommunityRepository: CreatorCommunityRepository,
|
||||
private val persistentMemberRepository: MemberRepository,
|
||||
private val persistentTranslationRepository: CreatorCommunityTranslationRepository,
|
||||
private val persistentMemoryRepository: TranslationMemoryRepository,
|
||||
private val persistentTranslationJobRepository: TranslationJobRepository,
|
||||
private val entityManager: EntityManager,
|
||||
private val transactionManager: PlatformTransactionManager
|
||||
) {
|
||||
private val transactionTemplate = TransactionTemplate(transactionManager)
|
||||
private val requiresNewTransactionTemplate = TransactionTemplate(transactionManager).apply {
|
||||
propagationBehavior = Propagation.REQUIRES_NEW.value()
|
||||
}
|
||||
private val repeatableReadTransactionTemplate = TransactionTemplate(transactionManager).apply {
|
||||
isolationLevel = TransactionDefinition.ISOLATION_REPEATABLE_READ
|
||||
}
|
||||
private val applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||
|
||||
@Test
|
||||
fun shouldRequireCreatorCommunityTranslationPersistenceContract() {
|
||||
val fieldNames = CreatorCommunity::class.java.declaredFields.map { it.name }
|
||||
|
||||
assertTrue(fieldNames.contains("languageCode"))
|
||||
assertTrue(fieldNames.contains("contentRevision"))
|
||||
assertTrue(LanguageTranslationTargetType.values().any { it.name == "CREATOR_COMMUNITY" })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("표시 조회는 현재 개정과 원문 언어가 일치하는 번역만 일괄 조회해 반환한다")
|
||||
fun shouldReturnOnlyCurrentTranslationFromBatchedRead() {
|
||||
val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java)
|
||||
val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java)
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
val scheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
|
||||
val service = CreatorCommunityTranslationService(
|
||||
creatorCommunityRepository,
|
||||
translationRepository,
|
||||
sourceExtractor,
|
||||
materializer,
|
||||
scheduler,
|
||||
applicationEventPublisher,
|
||||
Mockito.mock(EntityManager::class.java)
|
||||
)
|
||||
val current = creatorCommunity(postId = 1L, content = "current", languageCode = "ko", contentRevision = 2)
|
||||
val staleRevision = creatorCommunity(postId = 2L, content = "changed", languageCode = "ko", contentRevision = 3)
|
||||
val sameLanguage = creatorCommunity(postId = 3L, content = "original", languageCode = "en", contentRevision = 2)
|
||||
val inactive = creatorCommunity(postId = 4L, content = "hidden", languageCode = "ko", contentRevision = 2).apply {
|
||||
isActive = false
|
||||
}
|
||||
val currentTranslation = translation(postId = 1L, content = "translated", sourceRevision = 2, sourceText = "current")
|
||||
val staleTranslation = translation(postId = 2L, content = "old translated", sourceRevision = 2, sourceText = "changed")
|
||||
val sameLanguageTranslation = translation(
|
||||
postId = 3L,
|
||||
content = "should not show",
|
||||
sourceRevision = 2,
|
||||
sourceText = "original",
|
||||
sourceLanguage = "en"
|
||||
)
|
||||
|
||||
Mockito.`when`(creatorCommunityRepository.findAllById(listOf(1L, 2L, 3L, 4L)))
|
||||
.thenReturn(listOf(current, staleRevision, sameLanguage, inactive))
|
||||
Mockito.`when`(translationRepository.findByCreatorCommunityIdInAndLocale(listOf(1L, 2L, 3L), "en"))
|
||||
.thenReturn(listOf(currentTranslation, staleTranslation, sameLanguageTranslation))
|
||||
|
||||
val contents = service.findDisplayContents(listOf(1L, 2L, 3L, 4L), "EN")
|
||||
|
||||
assertEquals("translated", contents[1L]?.content)
|
||||
assertTrue(contents[1L]?.hasValidTranslation == true)
|
||||
assertEquals("changed", contents[2L]?.content)
|
||||
assertTrue(contents[2L]?.hasValidTranslation == false)
|
||||
assertEquals("original", contents[3L]?.content)
|
||||
assertTrue(contents[3L]?.hasValidTranslation == false)
|
||||
assertTrue(4L !in contents)
|
||||
Mockito.verifyNoInteractions(sourceExtractor, materializer, scheduler)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("번역이 없거나 원문 언어와 해시가 다르면 현재 원문을 반환한다")
|
||||
fun shouldFallbackToOriginalWhenTranslationIsMissingOrDoesNotMatchCurrentSource() {
|
||||
val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java)
|
||||
val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java)
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
val scheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
|
||||
val service = CreatorCommunityTranslationService(
|
||||
creatorCommunityRepository,
|
||||
translationRepository,
|
||||
sourceExtractor,
|
||||
materializer,
|
||||
scheduler,
|
||||
applicationEventPublisher,
|
||||
Mockito.mock(EntityManager::class.java)
|
||||
)
|
||||
val missing = creatorCommunity(1L, "missing", "ko", 1)
|
||||
val sourceLanguageMismatch = creatorCommunity(2L, "language", "ko", 1)
|
||||
val sourceHashMismatch = creatorCommunity(3L, "current", "ko", 1)
|
||||
val languageMismatchTranslation = translation(
|
||||
postId = 2L,
|
||||
content = "wrong language",
|
||||
sourceRevision = 1,
|
||||
sourceText = "language",
|
||||
sourceLanguage = "ja"
|
||||
)
|
||||
val hashMismatchTranslation = translation(
|
||||
postId = 3L,
|
||||
content = "wrong hash",
|
||||
sourceRevision = 1,
|
||||
sourceText = "previous"
|
||||
)
|
||||
|
||||
Mockito.`when`(creatorCommunityRepository.findAllById(listOf(1L, 2L, 3L)))
|
||||
.thenReturn(listOf(missing, sourceLanguageMismatch, sourceHashMismatch))
|
||||
Mockito.`when`(translationRepository.findByCreatorCommunityIdInAndLocale(listOf(1L, 2L, 3L), "en"))
|
||||
.thenReturn(listOf(languageMismatchTranslation, hashMismatchTranslation))
|
||||
|
||||
val contents = service.findDisplayContents(listOf(1L, 2L, 3L), "en")
|
||||
|
||||
assertEquals("missing", contents[1L]?.content)
|
||||
assertTrue(contents[1L]?.hasValidTranslation == false)
|
||||
assertEquals("language", contents[2L]?.content)
|
||||
assertTrue(contents[2L]?.hasValidTranslation == false)
|
||||
assertEquals("current", contents[3L]?.content)
|
||||
assertTrue(contents[3L]?.hasValidTranslation == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("현재 원문의 메모리를 재료화할 수 있으면 작업을 새로 예약하지 않는다")
|
||||
fun shouldRematerializeFromMemoryBeforeScheduling() {
|
||||
val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java)
|
||||
val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java)
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
val scheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
|
||||
val service = CreatorCommunityTranslationService(
|
||||
creatorCommunityRepository,
|
||||
translationRepository,
|
||||
sourceExtractor,
|
||||
materializer,
|
||||
scheduler,
|
||||
applicationEventPublisher,
|
||||
Mockito.mock(EntityManager::class.java)
|
||||
)
|
||||
val source = TranslationSource(
|
||||
resourceType = LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
||||
resourceId = 1L,
|
||||
sourceLanguage = "ko",
|
||||
fields = listOf(TranslationSourceField("content", "A")),
|
||||
sourceRevision = 2
|
||||
)
|
||||
Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(1L))
|
||||
.thenReturn(creatorCommunity(1L, "A", "ko", 2))
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L)).thenReturn(source)
|
||||
Mockito.`when`(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L, "en"))
|
||||
.thenReturn(true)
|
||||
|
||||
service.requestTranslations(1L, "EN")
|
||||
|
||||
Mockito.verify(materializer).materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L, "en")
|
||||
Mockito.verifyNoInteractions(scheduler)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("현재 원문의 메모리가 없으면 기존 작업 예약기를 사용한다")
|
||||
fun shouldScheduleMissingTranslationAfterMemoryCannotMaterialize() {
|
||||
val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java)
|
||||
val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java)
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
val scheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
|
||||
val service = CreatorCommunityTranslationService(
|
||||
creatorCommunityRepository,
|
||||
translationRepository,
|
||||
sourceExtractor,
|
||||
materializer,
|
||||
scheduler,
|
||||
applicationEventPublisher,
|
||||
Mockito.mock(EntityManager::class.java)
|
||||
)
|
||||
val source = TranslationSource(
|
||||
resourceType = LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
||||
resourceId = 1L,
|
||||
sourceLanguage = "ko",
|
||||
fields = listOf(TranslationSourceField("content", "B")),
|
||||
sourceRevision = 3
|
||||
)
|
||||
Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(1L))
|
||||
.thenReturn(creatorCommunity(1L, "B", "ko", 3))
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L)).thenReturn(source)
|
||||
Mockito.`when`(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L, "en"))
|
||||
.thenReturn(false)
|
||||
|
||||
service.requestTranslations(1L, "en")
|
||||
|
||||
Mockito.verify(scheduler).scheduleResourceTranslation(
|
||||
LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
||||
1L,
|
||||
"en"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("상세 요청 언어가 원문 언어와 같으면 번역 작업을 예약하지 않는다")
|
||||
fun shouldNotScheduleTranslationWhenDetailTargetLanguageEqualsSourceLanguage() {
|
||||
val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java)
|
||||
val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java)
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
val scheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
|
||||
val service = CreatorCommunityTranslationService(
|
||||
creatorCommunityRepository,
|
||||
translationRepository,
|
||||
sourceExtractor,
|
||||
materializer,
|
||||
scheduler,
|
||||
applicationEventPublisher,
|
||||
Mockito.mock(EntityManager::class.java)
|
||||
)
|
||||
val post = creatorCommunity(postId = 1L, content = "original", languageCode = "ja", contentRevision = 0)
|
||||
val source = translationSource(content = "original", sourceLanguage = "ja", sourceRevision = 0)
|
||||
Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(1L)).thenReturn(post)
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L)).thenReturn(source)
|
||||
|
||||
service.requestTranslations(1L, "ja")
|
||||
|
||||
Mockito.verifyNoInteractions(materializer, scheduler)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("언어가 없는 현재 커뮤니티 본문은 감지 이벤트에 개정과 요청 언어를 보존한다")
|
||||
fun shouldPublishCurrentContentDetectionEventWhenSourceLanguageIsUnknown() {
|
||||
val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java)
|
||||
val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java)
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
val scheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
|
||||
val service = CreatorCommunityTranslationService(
|
||||
creatorCommunityRepository,
|
||||
translationRepository,
|
||||
sourceExtractor,
|
||||
materializer,
|
||||
scheduler,
|
||||
applicationEventPublisher,
|
||||
Mockito.mock(EntityManager::class.java)
|
||||
)
|
||||
val creatorCommunity = CreatorCommunity(
|
||||
content = "current",
|
||||
price = 0,
|
||||
isCommentAvailable = true,
|
||||
isAdult = false,
|
||||
contentRevision = 2
|
||||
).apply {
|
||||
id = 1L
|
||||
}
|
||||
Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(1L)).thenReturn(creatorCommunity)
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L)).thenReturn(null)
|
||||
Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrue(1L)).thenReturn(creatorCommunity)
|
||||
|
||||
service.requestTranslations(1L, "ja")
|
||||
|
||||
val eventCaptor = org.mockito.ArgumentCaptor.forClass(LanguageDetectEvent::class.java)
|
||||
Mockito.verify(applicationEventPublisher).publishEvent(eventCaptor.capture())
|
||||
assertEquals(1L, eventCaptor.value.id)
|
||||
assertEquals("current", eventCaptor.value.query)
|
||||
assertEquals(LanguageDetectTargetType.CREATOR_COMMUNITY, eventCaptor.value.targetType)
|
||||
assertEquals(2L, eventCaptor.value.sourceRevision)
|
||||
assertEquals("ja", eventCaptor.value.targetLanguage)
|
||||
Mockito.verifyNoInteractions(materializer, scheduler)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("이전 본문으로 돌아오면 번역 메모리에서 현재 개정의 조회 모델을 복원한다")
|
||||
fun shouldRestoreReturnedContentFromTranslationMemoryAtCurrentRevision() {
|
||||
val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java)
|
||||
val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java)
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
|
||||
val materializer = materializer(
|
||||
sourceExtractor,
|
||||
memoryRepository,
|
||||
creatorCommunityRepository,
|
||||
translationRepository
|
||||
)
|
||||
val source = translationSource(content = "A", sourceLanguage = "ko", sourceRevision = 2)
|
||||
val currentPost = creatorCommunity(postId = 1L, content = "A", languageCode = "ko", contentRevision = 2)
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L)).thenReturn(source)
|
||||
givenTranslationMemory(memoryRepository, "A", "ko")
|
||||
Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(1L)).thenReturn(currentPost)
|
||||
Mockito.`when`(translationRepository.findByCreatorCommunityIdAndLocale(1L, "en")).thenReturn(null)
|
||||
|
||||
assertTrue(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L, "en"))
|
||||
|
||||
val translationCaptor = org.mockito.ArgumentCaptor.forClass(CreatorCommunityTranslation::class.java)
|
||||
Mockito.verify(translationRepository).save(translationCaptor.capture())
|
||||
assertEquals("translated A", translationCaptor.value.content)
|
||||
assertEquals(2L, translationCaptor.value.sourceRevision)
|
||||
assertEquals(SourceTextNormalizer.hash("A"), translationCaptor.value.sourceHash)
|
||||
assertEquals("ko", translationCaptor.value.sourceLanguage)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("원문 추출 뒤 본문 개정이 바뀌면 번역 결과를 저장하지 않는다")
|
||||
fun shouldNotStoreTranslationWhenRevisionChangesBeforeLockedWrite() {
|
||||
val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java)
|
||||
val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java)
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
|
||||
val materializer = materializer(
|
||||
sourceExtractor,
|
||||
memoryRepository,
|
||||
creatorCommunityRepository,
|
||||
translationRepository
|
||||
)
|
||||
val source = translationSource(content = "before", sourceLanguage = "ko", sourceRevision = 0)
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L)).thenReturn(source)
|
||||
givenTranslationMemory(memoryRepository, "before", "ko")
|
||||
Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(1L))
|
||||
.thenReturn(creatorCommunity(1L, "after", "ko", 1))
|
||||
|
||||
assertFalse(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L, "en"))
|
||||
|
||||
Mockito.verifyNoInteractions(translationRepository)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("원문 언어가 바뀌면 번역 결과를 저장하지 않는다")
|
||||
fun shouldNotStoreTranslationWhenSourceLanguageChangesBeforeLockedWrite() {
|
||||
val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java)
|
||||
val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java)
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
|
||||
val materializer = materializer(
|
||||
sourceExtractor,
|
||||
memoryRepository,
|
||||
creatorCommunityRepository,
|
||||
translationRepository
|
||||
)
|
||||
val source = translationSource(content = "same", sourceLanguage = "ko", sourceRevision = 0)
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L)).thenReturn(source)
|
||||
givenTranslationMemory(memoryRepository, "same", "ko")
|
||||
Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(1L))
|
||||
.thenReturn(creatorCommunity(1L, "same", "en", 0))
|
||||
|
||||
assertFalse(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L, "en"))
|
||||
|
||||
Mockito.verifyNoInteractions(translationRepository)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("원문 게시물이 비활성화되면 번역 결과를 저장하지 않는다")
|
||||
fun shouldNotStoreTranslationWhenPostBecomesInactiveBeforeLockedWrite() {
|
||||
val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java)
|
||||
val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java)
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
|
||||
val materializer = materializer(
|
||||
sourceExtractor,
|
||||
memoryRepository,
|
||||
creatorCommunityRepository,
|
||||
translationRepository
|
||||
)
|
||||
val source = translationSource(content = "before", sourceLanguage = "ko", sourceRevision = 0)
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L)).thenReturn(source)
|
||||
givenTranslationMemory(memoryRepository, "before", "ko")
|
||||
Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(1L)).thenReturn(null)
|
||||
|
||||
assertFalse(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L, "en"))
|
||||
|
||||
Mockito.verifyNoInteractions(translationRepository)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("이미 관리 중인 게시물이 다른 트랜잭션에서 수정되면 이전 번역을 저장하지 않는다")
|
||||
fun shouldRejectStaleManagedCreatorCommunityAfterConcurrentCommit() {
|
||||
val postId = persistCreatorCommunity("stale-A", 0)
|
||||
persistTranslationMemory("stale-A")
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val materializer = persistentMaterializer(sourceExtractor)
|
||||
val source = translationSource(postId, "stale-A", "ko", 0)
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId)).thenReturn(source)
|
||||
|
||||
transactionTemplate.executeWithoutResult {
|
||||
persistentCreatorCommunityRepository.findById(postId).orElseThrow()
|
||||
updateCreatorCommunity(postId, "stale-B", 1)
|
||||
|
||||
assertFalse(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, "en"))
|
||||
}
|
||||
|
||||
val translation = requiresNewTransactionTemplate.execute {
|
||||
persistentTranslationRepository.findByCreatorCommunityIdAndLocale(postId, "en")
|
||||
}
|
||||
assertEquals(null, translation)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("본문 A에서 B를 거쳐 A로 돌아오면 기존 번역 행을 현재 개정으로 갱신한다")
|
||||
fun shouldRematerializeAtoBtoAIntoExistingTranslationRow() {
|
||||
val postId = persistCreatorCommunity("restore-A", 0)
|
||||
persistTranslationMemory("restore-A")
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val materializer = persistentMaterializer(sourceExtractor)
|
||||
val firstSource = translationSource(postId, "restore-A", "ko", 0)
|
||||
val restoredSource = translationSource(postId, "restore-A", "ko", 2)
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId))
|
||||
.thenReturn(firstSource, restoredSource)
|
||||
|
||||
requiresNewTransactionTemplate.executeWithoutResult {
|
||||
assertTrue(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, "en"))
|
||||
}
|
||||
updateCreatorCommunity(postId, "restore-B", 1)
|
||||
updateCreatorCommunity(postId, "restore-A", 2)
|
||||
requiresNewTransactionTemplate.executeWithoutResult {
|
||||
assertTrue(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, "en"))
|
||||
}
|
||||
|
||||
val translation = requiresNewTransactionTemplate.execute {
|
||||
persistentTranslationRepository.findByCreatorCommunityIdAndLocale(postId, "en")
|
||||
}!!
|
||||
assertEquals("translated restore-A", translation.content)
|
||||
assertEquals(2L, translation.sourceRevision)
|
||||
assertEquals(SourceTextNormalizer.hash("restore-A"), translation.sourceHash)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("동시 재료화는 게시물 잠금과 locale 유일 키로 하나의 번역 행만 저장한다")
|
||||
fun shouldSerializeConcurrentCreatorCommunityUpserts() {
|
||||
val postId = persistCreatorCommunity("concurrent-A", 2)
|
||||
persistTranslationMemory("concurrent-A")
|
||||
val source = translationSource(postId, "concurrent-A", "ko", 2)
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val materializer = persistentMaterializer(sourceExtractor)
|
||||
val readyToLock = CountDownLatch(2)
|
||||
val releaseLock = CountDownLatch(1)
|
||||
val completed = CountDownLatch(2)
|
||||
val errors = ConcurrentLinkedQueue<Throwable>()
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId)).thenAnswer {
|
||||
readyToLock.countDown()
|
||||
if (!releaseLock.await(5, TimeUnit.SECONDS)) {
|
||||
throw IllegalStateException("workers did not begin together")
|
||||
}
|
||||
source
|
||||
}
|
||||
|
||||
try {
|
||||
repeat(2) {
|
||||
executor.submit {
|
||||
try {
|
||||
transactionTemplate.executeWithoutResult {
|
||||
materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, "en")
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
errors.add(error)
|
||||
} finally {
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(readyToLock.await(5, TimeUnit.SECONDS))
|
||||
releaseLock.countDown()
|
||||
assertTrue(completed.await(10, TimeUnit.SECONDS))
|
||||
assertTrue(errors.isEmpty())
|
||||
} finally {
|
||||
releaseLock.countDown()
|
||||
executor.shutdownNow()
|
||||
}
|
||||
|
||||
val translations = transactionTemplate.execute {
|
||||
persistentTranslationRepository.findByCreatorCommunityIdInAndLocale(listOf(postId), "en")
|
||||
}.orEmpty()
|
||||
assertEquals(1, translations.size)
|
||||
assertEquals("translated concurrent-A", translations.single().content)
|
||||
assertEquals(2L, translations.single().sourceRevision)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("같은 게시물의 누락 번역 예약은 하나의 작업만 만들고 둘 다 정상 종료한다")
|
||||
fun shouldSerializeConcurrentMissingMemoryTranslationJobScheduling() {
|
||||
val postId = persistCreatorCommunity("concurrent-job", 0)
|
||||
val missingJobReads = CountDownLatch(2)
|
||||
val jobRepository = delayingTranslationJobRepository(postId, "concurrent-job", missingJobReads)
|
||||
val service = persistentService(jobRepository)
|
||||
val start = CountDownLatch(1)
|
||||
val completed = CountDownLatch(2)
|
||||
val errors = ConcurrentLinkedQueue<Throwable>()
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
|
||||
try {
|
||||
repeat(2) {
|
||||
executor.submit {
|
||||
try {
|
||||
check(start.await(5, TimeUnit.SECONDS))
|
||||
requiresNewTransactionTemplate.executeWithoutResult {
|
||||
service.requestTranslations(postId, "en")
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
errors.add(error)
|
||||
} finally {
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start.countDown()
|
||||
assertTrue(completed.await(10, TimeUnit.SECONDS))
|
||||
} finally {
|
||||
start.countDown()
|
||||
executor.shutdownNow()
|
||||
}
|
||||
|
||||
assertTrue(errors.isEmpty())
|
||||
val jobs = requiresNewTransactionTemplate.execute {
|
||||
persistentTranslationJobRepository.findAll().filter {
|
||||
it.resourceType == LanguageTranslationTargetType.CREATOR_COMMUNITY &&
|
||||
it.resourceId == postId &&
|
||||
it.targetLanguage == "en"
|
||||
}
|
||||
}.orEmpty()
|
||||
assertEquals(1, jobs.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("반복 읽기 스냅샷 뒤 번역 행을 잠가 기존 행을 갱신한다")
|
||||
fun shouldUpdateExistingTranslationAfterRepeatableReadSnapshotWaitsForFirstWriter() {
|
||||
val postId = persistCreatorCommunity("repeatable-A", 0)
|
||||
persistTranslationMemory("repeatable-A")
|
||||
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
|
||||
val materializer = persistentMaterializer(sourceExtractor)
|
||||
val source = translationSource(postId, "repeatable-A", "ko", 0)
|
||||
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId)).thenReturn(source)
|
||||
val snapshotReady = CountDownLatch(2)
|
||||
val startFirstWriter = CountDownLatch(1)
|
||||
val firstWriterCommitted = CountDownLatch(1)
|
||||
val completed = CountDownLatch(2)
|
||||
val errors = ConcurrentLinkedQueue<Throwable>()
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
|
||||
try {
|
||||
executor.submit {
|
||||
try {
|
||||
repeatableReadTransactionTemplate.executeWithoutResult {
|
||||
loadTranslationMemoryForSnapshot("repeatable-A")
|
||||
snapshotReady.countDown()
|
||||
startFirstWriter.await()
|
||||
assertTrue(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, "en"))
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
errors.add(error)
|
||||
} finally {
|
||||
firstWriterCommitted.countDown()
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
executor.submit {
|
||||
try {
|
||||
repeatableReadTransactionTemplate.executeWithoutResult {
|
||||
loadTranslationMemoryForSnapshot("repeatable-A")
|
||||
snapshotReady.countDown()
|
||||
firstWriterCommitted.await()
|
||||
assertTrue(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, "en"))
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
errors.add(error)
|
||||
} finally {
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(snapshotReady.await(5, TimeUnit.SECONDS))
|
||||
startFirstWriter.countDown()
|
||||
assertTrue(completed.await(10, TimeUnit.SECONDS))
|
||||
assertTrue(errors.isEmpty())
|
||||
} finally {
|
||||
startFirstWriter.countDown()
|
||||
firstWriterCommitted.countDown()
|
||||
executor.shutdownNow()
|
||||
}
|
||||
|
||||
val translations = requiresNewTransactionTemplate.execute {
|
||||
persistentTranslationRepository.findByCreatorCommunityIdInAndLocale(listOf(postId), "en")
|
||||
}.orEmpty()
|
||||
assertEquals(1, translations.size)
|
||||
assertEquals("translated repeatable-A", translations.single().content)
|
||||
}
|
||||
|
||||
private fun materializer(
|
||||
sourceExtractor: TranslationSourceExtractor,
|
||||
memoryRepository: TranslationMemoryRepository,
|
||||
creatorCommunityRepository: CreatorCommunityRepository,
|
||||
translationRepository: CreatorCommunityTranslationRepository,
|
||||
entityManager: EntityManager = Mockito.mock(EntityManager::class.java)
|
||||
): TranslationReadModelMaterializer {
|
||||
return TranslationReadModelMaterializer(
|
||||
sourceExtractor = sourceExtractor,
|
||||
translationMemoryRepository = memoryRepository,
|
||||
contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java),
|
||||
aiCharacterTranslationRepository = Mockito.mock(AiCharacterTranslationRepository::class.java),
|
||||
contentThemeTranslationRepository = Mockito.mock(ContentThemeTranslationRepository::class.java),
|
||||
seriesTranslationRepository = Mockito.mock(SeriesTranslationRepository::class.java),
|
||||
seriesGenreTranslationRepository = Mockito.mock(SeriesGenreTranslationRepository::class.java),
|
||||
originalWorkTranslationRepository = Mockito.mock(OriginalWorkTranslationRepository::class.java),
|
||||
categoryTranslationRepository = Mockito.mock(CategoryTranslationRepository::class.java),
|
||||
creatorCommunityRepository = creatorCommunityRepository,
|
||||
creatorCommunityTranslationRepository = translationRepository,
|
||||
entityManager = entityManager
|
||||
)
|
||||
}
|
||||
|
||||
private fun persistentMaterializer(sourceExtractor: TranslationSourceExtractor): TranslationReadModelMaterializer {
|
||||
return materializer(
|
||||
sourceExtractor,
|
||||
persistentMemoryRepository,
|
||||
persistentCreatorCommunityRepository,
|
||||
persistentTranslationRepository,
|
||||
entityManager
|
||||
)
|
||||
}
|
||||
|
||||
private fun persistentService(translationJobRepository: TranslationJobRepository): CreatorCommunityTranslationService {
|
||||
val sourceExtractor = TranslationSourceExtractor(
|
||||
audioContentRepository = Mockito.mock(AudioContentRepository::class.java),
|
||||
chatCharacterRepository = Mockito.mock(ChatCharacterRepository::class.java),
|
||||
audioContentThemeRepository = Mockito.mock(AudioContentThemeQueryRepository::class.java),
|
||||
seriesRepository = Mockito.mock(AdminContentSeriesRepository::class.java),
|
||||
seriesGenreRepository = Mockito.mock(AdminContentSeriesGenreRepository::class.java),
|
||||
originalWorkRepository = Mockito.mock(OriginalWorkRepository::class.java),
|
||||
categoryRepository = Mockito.mock(CategoryRepository::class.java),
|
||||
creatorCommunityRepository = persistentCreatorCommunityRepository
|
||||
)
|
||||
return CreatorCommunityTranslationService(
|
||||
creatorCommunityRepository = persistentCreatorCommunityRepository,
|
||||
creatorCommunityTranslationRepository = persistentTranslationRepository,
|
||||
sourceExtractor = sourceExtractor,
|
||||
materializer = persistentMaterializer(sourceExtractor),
|
||||
resourceTranslationJobScheduler = ResourceTranslationJobScheduler(
|
||||
sourceExtractor,
|
||||
TranslationJobScheduler(translationJobRepository)
|
||||
),
|
||||
applicationEventPublisher = applicationEventPublisher,
|
||||
entityManager = entityManager
|
||||
)
|
||||
}
|
||||
|
||||
private fun delayingTranslationJobRepository(
|
||||
postId: Long,
|
||||
sourceText: String,
|
||||
missingJobReads: CountDownLatch
|
||||
): TranslationJobRepository {
|
||||
val sourceHash = SourceTextNormalizer.hash(sourceText)
|
||||
val translationJobRepository = Mockito.mock(TranslationJobRepository::class.java)
|
||||
Mockito.`when`(
|
||||
translationJobRepository.findByResourceTypeAndResourceIdAndFieldKeyAndTargetLanguageAndSourceHash(
|
||||
LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
||||
postId,
|
||||
"content",
|
||||
"en",
|
||||
sourceHash
|
||||
)
|
||||
).thenAnswer {
|
||||
val existing = persistentTranslationJobRepository
|
||||
.findByResourceTypeAndResourceIdAndFieldKeyAndTargetLanguageAndSourceHash(
|
||||
LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
||||
postId,
|
||||
"content",
|
||||
"en",
|
||||
sourceHash
|
||||
)
|
||||
if (existing == null) {
|
||||
missingJobReads.countDown()
|
||||
missingJobReads.await(1, TimeUnit.SECONDS)
|
||||
}
|
||||
existing
|
||||
}
|
||||
Mockito.`when`(translationJobRepository.save(Mockito.any(TranslationJob::class.java))).thenAnswer {
|
||||
persistentTranslationJobRepository.save(it.getArgument(0))
|
||||
}
|
||||
return translationJobRepository
|
||||
}
|
||||
|
||||
private fun translationSource(
|
||||
postId: Long = 1L,
|
||||
content: String,
|
||||
sourceLanguage: String,
|
||||
sourceRevision: Long
|
||||
): TranslationSource {
|
||||
return TranslationSource(
|
||||
resourceType = LanguageTranslationTargetType.CREATOR_COMMUNITY,
|
||||
resourceId = postId,
|
||||
sourceLanguage = sourceLanguage,
|
||||
fields = listOf(TranslationSourceField("content", content)),
|
||||
sourceRevision = sourceRevision
|
||||
)
|
||||
}
|
||||
|
||||
private fun translationMemory(sourceText: String, sourceLanguage: String): TranslationMemory {
|
||||
return TranslationMemory(
|
||||
sourceHash = SourceTextNormalizer.hash(sourceText),
|
||||
sourceText = sourceText,
|
||||
sourceLanguage = sourceLanguage,
|
||||
targetLanguage = "en",
|
||||
translatedText = "translated $sourceText",
|
||||
provider = "papago",
|
||||
providerVersion = "nmt-v1"
|
||||
)
|
||||
}
|
||||
|
||||
private fun persistCreatorCommunity(content: String, contentRevision: Long): Long {
|
||||
return requiresNewTransactionTemplate.execute {
|
||||
val member = Member(
|
||||
password = "password",
|
||||
nickname = "translation-${UUID.randomUUID()}",
|
||||
role = MemberRole.CREATOR
|
||||
)
|
||||
val post = CreatorCommunity(
|
||||
content = content,
|
||||
price = 0,
|
||||
isCommentAvailable = true,
|
||||
isAdult = false,
|
||||
languageCode = "ko",
|
||||
contentRevision = contentRevision
|
||||
).apply {
|
||||
this.member = persistentMemberRepository.save(member)
|
||||
}
|
||||
persistentCreatorCommunityRepository.save(post).id!!
|
||||
}!!
|
||||
}
|
||||
|
||||
private fun persistTranslationMemory(sourceText: String) {
|
||||
requiresNewTransactionTemplate.executeWithoutResult {
|
||||
persistentMemoryRepository.save(translationMemory(sourceText, "ko"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCreatorCommunity(postId: Long, content: String, contentRevision: Long) {
|
||||
requiresNewTransactionTemplate.executeWithoutResult {
|
||||
val post = persistentCreatorCommunityRepository.findById(postId).orElseThrow()
|
||||
post.content = content
|
||||
post.contentRevision = contentRevision
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadTranslationMemoryForSnapshot(sourceText: String) {
|
||||
persistentMemoryRepository.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
|
||||
SourceTextNormalizer.hash(sourceText),
|
||||
"ko",
|
||||
"en",
|
||||
"papago",
|
||||
SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
}
|
||||
|
||||
private fun givenTranslationMemory(
|
||||
memoryRepository: TranslationMemoryRepository,
|
||||
sourceText: String,
|
||||
sourceLanguage: String
|
||||
) {
|
||||
Mockito.`when`(
|
||||
memoryRepository.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
|
||||
SourceTextNormalizer.hash(sourceText),
|
||||
sourceLanguage,
|
||||
"en",
|
||||
"papago",
|
||||
SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
).thenReturn(translationMemory(sourceText, sourceLanguage))
|
||||
}
|
||||
|
||||
private fun creatorCommunity(
|
||||
postId: Long,
|
||||
content: String,
|
||||
languageCode: String,
|
||||
contentRevision: Long
|
||||
): CreatorCommunity {
|
||||
return CreatorCommunity(
|
||||
content = content,
|
||||
price = 0,
|
||||
isCommentAvailable = true,
|
||||
isAdult = false,
|
||||
languageCode = languageCode,
|
||||
contentRevision = contentRevision
|
||||
).apply {
|
||||
id = postId
|
||||
}
|
||||
}
|
||||
|
||||
private fun translation(
|
||||
postId: Long,
|
||||
content: String,
|
||||
sourceRevision: Long,
|
||||
sourceText: String,
|
||||
sourceLanguage: String = "ko"
|
||||
): CreatorCommunityTranslation {
|
||||
return CreatorCommunityTranslation(
|
||||
creatorCommunityId = postId,
|
||||
locale = "en",
|
||||
content = content,
|
||||
sourceRevision = sourceRevision,
|
||||
sourceHash = SourceTextNormalizer.hash(sourceText),
|
||||
sourceLanguage = sourceLanguage
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
@@ -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(),
|
||||
|
||||
+66
-3
@@ -1,10 +1,16 @@
|
||||
package kr.co.vividnext.sodalive.v2.home.following.application
|
||||
|
||||
import kr.co.vividnext.sodalive.i18n.Lang
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.member.Gender
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.auth.Auth
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityDisplayContent
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCommunityPostNews
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews
|
||||
@@ -23,10 +29,14 @@ import java.util.TimeZone
|
||||
class HomeFollowingQueryServiceTest {
|
||||
private val queryPort = RecordingHomeFollowingQueryPort()
|
||||
private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||
private val langContext = LangContext()
|
||||
private val creatorCommunityTranslationService = Mockito.mock(CreatorCommunityTranslationService::class.java)
|
||||
private val now = LocalDateTime.of(2026, 6, 25, 12, 0)
|
||||
private val service = HomeFollowingQueryService(
|
||||
queryPort,
|
||||
memberContentPreferenceService
|
||||
memberContentPreferenceService,
|
||||
langContext,
|
||||
creatorCommunityTranslationService
|
||||
) { now }
|
||||
|
||||
@Test
|
||||
@@ -86,7 +96,12 @@ class HomeFollowingQueryServiceTest {
|
||||
try {
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"))
|
||||
val defaultQueryPort = RecordingHomeFollowingQueryPort()
|
||||
val defaultService = HomeFollowingQueryService(defaultQueryPort, memberContentPreferenceService)
|
||||
val defaultService = HomeFollowingQueryService(
|
||||
defaultQueryPort,
|
||||
memberContentPreferenceService,
|
||||
langContext,
|
||||
creatorCommunityTranslationService
|
||||
)
|
||||
val member = member(12L)
|
||||
Mockito.`when`(memberContentPreferenceService.canViewAdultContent(member)).thenReturn(true)
|
||||
val beforeUtc = LocalDateTime.now(ZoneOffset.UTC).minusSeconds(1)
|
||||
@@ -103,10 +118,57 @@ class HomeFollowingQueryServiceTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("팔로잉 최근 소식은 커뮤니티 게시물만 영어 표시 본문으로 일괄 치환하고 다른 소식과 원문 폴백을 유지한다")
|
||||
fun shouldTranslateOnlyCommunityPostNewsInOneEnglishBatch() {
|
||||
langContext.setLang(Lang.EN)
|
||||
val translatedNews = communityNews("community-1", 101L, "original-1")
|
||||
val audioNews = HomeFollowingNews(
|
||||
newsId = "audio-1",
|
||||
type = FollowingNewsType.AUDIO_CONTENT,
|
||||
visibleFromAtUtc = "2026-06-25T11:59:00Z"
|
||||
)
|
||||
val fallbackNews = communityNews("community-2", 102L, "historical-original-2")
|
||||
queryPort.recentNews = listOf(translatedNews, audioNews, fallbackNews)
|
||||
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(101L, 102L), "en"))
|
||||
.thenReturn(
|
||||
mapOf(
|
||||
101L to CreatorCommunityDisplayContent("translated-1-en", true),
|
||||
102L to CreatorCommunityDisplayContent("current-original-2", false)
|
||||
)
|
||||
)
|
||||
|
||||
val home = service.findHomeFollowing(member(14L))
|
||||
|
||||
assertEquals("translated-1-en", home.recentNews[0].communityPost?.content)
|
||||
assertEquals(audioNews, home.recentNews[1])
|
||||
assertEquals("current-original-2", home.recentNews[2].communityPost?.content)
|
||||
Mockito.verify(creatorCommunityTranslationService).findDisplayContents(listOf(101L, 102L), "en")
|
||||
Mockito.verifyNoMoreInteractions(creatorCommunityTranslationService)
|
||||
}
|
||||
|
||||
private fun member(id: Long): Member {
|
||||
return Member(email = "member-$id@test.com", password = "password", nickname = "member-$id").apply { this.id = id }
|
||||
}
|
||||
|
||||
private fun communityNews(newsId: String, postId: Long, content: String): HomeFollowingNews {
|
||||
return HomeFollowingNews(
|
||||
newsId = newsId,
|
||||
type = FollowingNewsType.COMMUNITY_POST,
|
||||
visibleFromAtUtc = "2026-06-25T12:00:00Z",
|
||||
communityPost = HomeFollowingCommunityPostNews(
|
||||
postId = postId,
|
||||
creatorProfileImage = "profile.png",
|
||||
creatorNickname = "creator",
|
||||
imageUrl = "image.png",
|
||||
content = content,
|
||||
createdAt = "2026-06-25T12:00:00Z",
|
||||
likeCount = 1,
|
||||
commentCount = 2
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private class RecordingHomeFollowingQueryPort : HomeFollowingQueryPort {
|
||||
var memberId: Long? = null
|
||||
var followingCreatorsLimit: Int? = null
|
||||
@@ -122,6 +184,7 @@ class HomeFollowingQueryServiceTest {
|
||||
var recentNewsLimit: Int? = null
|
||||
var recentNewsNow: LocalDateTime? = null
|
||||
var recentNewsCanViewAdultContent: Boolean? = null
|
||||
var recentNews: List<HomeFollowingNews> = emptyList()
|
||||
|
||||
override fun findFollowingCreators(memberId: Long, limit: Int): List<HomeFollowingCreator> {
|
||||
this.memberId = memberId
|
||||
@@ -168,7 +231,7 @@ class HomeFollowingQueryServiceTest {
|
||||
recentNewsCanViewAdultContent = canViewAdultContent
|
||||
recentNewsNow = nowUtc
|
||||
recentNewsLimit = limit
|
||||
return emptyList()
|
||||
return recentNews
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+146
-15
@@ -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<RecommendationSnapshotRecord>()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user