Compare commits
15 Commits
1939fdcb33
...
test
| Author | SHA1 | Date | |
|---|---|---|---|
| 9278761c5b | |||
| 85621cd107 | |||
| 487c10d4d0 | |||
| 870afb03da | |||
| 13ca6a97b9 | |||
| 3a0c30e340 | |||
| dfb97fba80 | |||
| 343dee1f6c | |||
| b98cc4b018 | |||
| dc11f44a32 | |||
| d736ec4368 | |||
| 0c0da6cbc9 | |||
| c7352c4bd3 | |||
| 1b20bc81b7 | |||
| 0665cdaca8 |
227
AGENTS.md
227
AGENTS.md
@@ -5,6 +5,113 @@
|
||||
- 목표는 "추측 최소화 + 기존 패턴 준수 + 검증 우선"이다.
|
||||
- 이 문서의 규칙은 코드/테스트/문서 변경 모두에 적용한다.
|
||||
|
||||
## 지시 우선순위
|
||||
- 충돌 시 항상 더 높은 우선순위의 지시를 따른다.
|
||||
- 우선순위는 다음 순서를 따른다.
|
||||
1. 사용자 직접 지시
|
||||
2. `AGENTS.md`
|
||||
3. 프로젝트별 제약 조건
|
||||
4. oh-my-openagent 플러그인의 agents / workflows / hooks
|
||||
5. superpowers skills
|
||||
6. 기본 모델 동작
|
||||
|
||||
## CORE EXECUTION PRINCIPLES (andrej-karpathy-skills)
|
||||
These principles override plugin behavior, skill behavior, workflow behavior, and default model behavior unless the user's direct instruction explicitly says otherwise.
|
||||
|
||||
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||
|
||||
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||
|
||||
## 1. Think Before Coding
|
||||
|
||||
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||
|
||||
Before implementing:
|
||||
- State your assumptions explicitly. If uncertain, ask.
|
||||
- If multiple interpretations exist, present them - don't pick silently.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
- If something is unclear, stop. Name what's confusing. Ask.
|
||||
|
||||
## 2. Simplicity First
|
||||
|
||||
**Minimum code that solves the problem. Nothing speculative.**
|
||||
|
||||
- No features beyond what was asked.
|
||||
- No abstractions for single-use code.
|
||||
- No "flexibility" or "configurability" that wasn't requested.
|
||||
- No error handling for impossible scenarios.
|
||||
- If you write 200 lines and it could be 50, rewrite it.
|
||||
|
||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
||||
## 3. Surgical Changes
|
||||
|
||||
**Touch only what you must. Clean up only your own mess.**
|
||||
|
||||
When editing existing code:
|
||||
- Don't "improve" adjacent code, comments, or formatting.
|
||||
- Don't refactor things that aren't broken.
|
||||
- Match existing style, even if you'd do it differently.
|
||||
- If you notice unrelated dead code, mention it - don't delete it.
|
||||
|
||||
When your changes create orphans:
|
||||
- Remove imports/variables/functions that YOUR changes made unused.
|
||||
- Don't remove pre-existing dead code unless asked.
|
||||
|
||||
The test: Every changed line should trace directly to the user's request.
|
||||
|
||||
## 4. Goal-Driven Execution
|
||||
|
||||
**Define success criteria. Loop until verified.**
|
||||
|
||||
Transform tasks into verifiable goals:
|
||||
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||
- "Refactor X" → "Ensure tests pass before and after"
|
||||
|
||||
For multi-step tasks, state a brief plan:
|
||||
```
|
||||
1. [Step] → verify: [check]
|
||||
2. [Step] → verify: [check]
|
||||
3. [Step] → verify: [check]
|
||||
```
|
||||
|
||||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||
|
||||
---
|
||||
|
||||
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||
|
||||
## 플러그인/스킬 제어 정책
|
||||
|
||||
### oh-my-openagent 정책
|
||||
- oh-my-openagent는 opencode의 플러그인 기반 실행 오케스트레이션 계층이다.
|
||||
- oh-my-openagent는 의사결정 권한이 아니라 실행 보조 권한만 가진다.
|
||||
- 작은 작업에는 multi-agent 실행이나 과도한 workflow를 사용하지 않는다.
|
||||
- 병렬 실행은 명확한 이득이 있을 때만 사용한다.
|
||||
- 모든 oh-my-openagent 동작은 CORE EXECUTION PRINCIPLES를 따라야 한다.
|
||||
|
||||
### superpowers 정책
|
||||
- superpowers는 선택적 스킬 계층이다.
|
||||
- superpowers skill은 필요한 경우에만 사용한다.
|
||||
- superpowers가 과도한 리팩토링, 불필요한 범위 확장, 가정 기반 실행을 유도하면 따르지 않는다.
|
||||
- superpowers를 사용할 때도 최소 변경, 단순성, 검증 가능성을 우선한다.
|
||||
- 모든 superpowers 동작은 CORE EXECUTION PRINCIPLES를 따라야 한다.
|
||||
|
||||
## 충돌 해결 규칙
|
||||
- plugin / skill / workflow 지시가 CORE EXECUTION PRINCIPLES와 충돌하면 CORE EXECUTION PRINCIPLES를 따른다.
|
||||
- 사용자 직접 지시가 명확할 경우 사용자 지시가 최우선이다.
|
||||
- 불확실하거나 모호한 경우 추측하지 말고 확인하거나, 가능한 최소 범위의 안전한 조치를 취한다.
|
||||
|
||||
## 실행 모드
|
||||
- 기본 모드: 보수적 실행
|
||||
- 최소 변경
|
||||
- 단순한 구현
|
||||
- 검증 가능한 결과
|
||||
- 확장 모드:
|
||||
- 사용자가 명시적으로 요청한 경우에만 사용한다.
|
||||
- 대규모 리팩토링, 브레인스토밍, 다중 에이전트 실행, 병렬 workflow를 허용한다.
|
||||
|
||||
## 커뮤니케이션 규칙
|
||||
- **"질문에 대한 답변과 설명은 한국어로 한다."**
|
||||
- 이 저장소에서 사용자에게 전달하는 설명, 진행 상황, 결과 보고는 한국어로 작성한다.
|
||||
@@ -31,82 +138,17 @@
|
||||
./gradlew ktlintFormat
|
||||
```
|
||||
|
||||
## 코드 스타일 규칙
|
||||
## 프로젝트 핵심 규칙
|
||||
- Kotlin/Spring 스타일, 테스트 스타일, 보안 유의사항, 작업 절차, 문서 유지보수 상세 규칙은 아래 문서를 따른다.
|
||||
- `docs/agent-guides/코드스타일.md`
|
||||
- `docs/agent-guides/테스트스타일.md`
|
||||
- `docs/agent-guides/설정보안.md`
|
||||
- `docs/agent-guides/작업절차.md`
|
||||
- `docs/agent-guides/문서유지보수.md`
|
||||
- 공개 API 스키마는 임의 변경하지 말고, 작은 단위로 안전하게 수정한다.
|
||||
- 기존 코드베이스 관례를 우선하며, 불확실한 규칙은 추측하지 말고 근거 파일을 먼저 확인한다.
|
||||
|
||||
### 1) 포맷/기본 규칙
|
||||
- `.editorconfig` 기준을 준수한다.
|
||||
- 인덴트: 공백 4칸.
|
||||
- 줄바꿈: LF.
|
||||
- 최대 라인 길이: 130.
|
||||
- 파일 끝 개행 유지, trailing whitespace 제거.
|
||||
|
||||
### 2) import 규칙
|
||||
- 와일드카드 import(`*`)를 사용하지 않는다.
|
||||
- 사용하지 않는 import를 남기지 않는다.
|
||||
- import alias(`as`)는 현재 코드베이스에서 사용 사례가 없으므로 지양한다.
|
||||
- 기존 파일의 import 정렬/그룹 스타일을 그대로 맞춘다.
|
||||
|
||||
### 3) 네이밍 규칙
|
||||
- 클래스/인터페이스/enum: PascalCase.
|
||||
- 함수/변수/파라미터: camelCase.
|
||||
- 상수: UPPER_SNAKE_CASE (`companion object` 내부 `const val`).
|
||||
- Request/Response DTO는 `...Request`, `...Response` 접미사를 유지한다.
|
||||
- 서비스/컨트롤러/리포지토리 명명은 역할 접미사(`Service`, `Controller`, `Repository`)를 유지한다.
|
||||
|
||||
### 4) 타입/널 처리
|
||||
- Kotlin 타입 시스템을 활용하고 nullable(`?`)를 명시한다.
|
||||
- 불필요한 `Any`/약한 타입을 피하고 구체 타입을 우선한다.
|
||||
- 기존 코드에서 `!!` 사용이 많지만, 신규 코드는 가능한 안전 호출/가드절/명시적 예외로 대체를 우선 고려한다.
|
||||
|
||||
### 5) API/응답 규칙
|
||||
- API 응답은 `ApiResponse.ok(...)`, `ApiResponse.error(...)` 패턴을 따른다.
|
||||
- 컨트롤러는 도메인 예외를 직접 포맷하지 말고 `SodaException`을 던진다.
|
||||
- 인증 사용자 필요 시 `@AuthenticationPrincipal(... ) member: Member?` 패턴 + null 가드절을 사용한다.
|
||||
|
||||
### 6) 예외 처리 규칙
|
||||
- 비즈니스 예외는 `SodaException(messageKey = "...")` 우선 사용.
|
||||
- 사용자 노출 문구는 하드코딩보다 `messageKey` 기반 i18n을 우선한다.
|
||||
- 공통 예외 변환은 `SodaExceptionHandler`에서 수행하므로, 개별 컨트롤러에서 중복 처리하지 않는다.
|
||||
- 예외를 삼키는 빈 `catch` 블록을 금지한다.
|
||||
|
||||
### 7) 트랜잭션 규칙
|
||||
- 서비스 계층에서 `@Transactional`을 사용한다.
|
||||
- 조회 위주 메서드는 `@Transactional(readOnly = true)`를 우선한다.
|
||||
- 쓰기 로직은 메서드 단위 `@Transactional`로 경계를 명확히 한다.
|
||||
|
||||
### 8) 비동기/동시성 규칙
|
||||
- 비동기 처리는 Kotlin Coroutines 패턴을 따른다.
|
||||
- `CoroutineScope(Dispatchers.IO)` + `launch` + 예외 처리 패턴을 일관되게 유지한다.
|
||||
- 생명주기 종료 시 scope 정리(`@PreDestroy`) 패턴을 참고한다.
|
||||
|
||||
### 9) 의존성 주입
|
||||
- 생성자 주입(primary constructor + `private val`)을 기본으로 사용한다.
|
||||
- 필드 주입보다 명시적 생성자 주입을 우선한다.
|
||||
|
||||
### 10) 주석
|
||||
- 의미 단위별로 주석을 작성한다.
|
||||
- 주석은 한 문장으로 간결하게 작성한다.
|
||||
- 주석은 코드의 의도와 구조를 설명한다.
|
||||
- 주석은 코드 변경 시 업데이트를 잊지 않는다.
|
||||
|
||||
## 테스트 스타일 규칙
|
||||
- 테스트 프레임워크: JUnit 5 (`useJUnitPlatform()`)
|
||||
- 목킹: Mockito 사용 패턴 존재 (`Mockito.mock`, ``Mockito.`when`(...)``)
|
||||
- 검증: `assertEquals`, `assertThrows` 패턴 준수.
|
||||
- 테스트 이름은 의도가 드러나는 영어 문장형(`should...`)을 유지한다.
|
||||
- 테스트는 DisplayName으로 한국어 설명을 추가한다.
|
||||
- 예외 상황이 있는지 확인하고 예외 상황에 대한 테스트 케이스를 추가한다.
|
||||
|
||||
## 설정/보안 유의사항
|
||||
- `application.yml`은 다수의 `${ENV_VAR}`를 사용한다.
|
||||
- 비밀값(API Key, Secret, Token, DB 비밀번호)을 코드/문서/로그에 평문으로 남기지 않는다.
|
||||
- 환경변수/시크릿 파일은 커밋 대상에서 제외한다.
|
||||
|
||||
## Cursor/Copilot 규칙 반영
|
||||
`/.cursorrules`, `/.cursor/rules/`, `/.github/copilot-instructions.md` 파일은 현재 없다.
|
||||
별도 규칙 파일이 추가되면 본 문서보다 해당 규칙을 우선 반영한다.
|
||||
|
||||
## 커밋 메시지 규칙 (표준 Conventional Commits)
|
||||
## 커밋 메시지 규칙
|
||||
- 커밋 상세 가이드/절차는 `.opencode/skills/commit-policy/SKILL.md`를 단일 기준으로 사용한다.
|
||||
- 커밋 작업 시작 시 `skill` 도구로 `commit-policy`를 먼저 로드한다.
|
||||
- 기본 형식은 `<type>(scope): <description>`를 사용한다.
|
||||
@@ -114,43 +156,16 @@
|
||||
- 제목(description)은 한글로 작성하고, 명령형/간결한 현재형으로 작성한다.
|
||||
- 이슈 참조 footer는 `Refs: #123` 또는 `Refs: #123, #456` 형식을 사용한다.
|
||||
- 커밋 본문에는 `Ultraworked with [Sisyphus]...` 및 `Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>` 자동 footer를 포함하지 않는다.
|
||||
|
||||
### 커밋 메시지 검증 절차
|
||||
- `git commit` 실행 직전에 `work/scripts/check-commit-message-rules.sh`를 실행해 규칙 준수 여부를 확인한다.
|
||||
- `git commit` 실행 직후에도 `work/scripts/check-commit-message-rules.sh`를 다시 실행해 최종 메시지를 재검증한다.
|
||||
- 스크립트 결과가 `[FAIL]`이면 커밋 메시지를 규칙에 맞게 수정한 뒤 다시 검증한다.
|
||||
- 커밋 실행 시 검증한 메시지를 그대로 사용하고, 도구 기본 footer가 자동 추가되지 않도록 최종 커밋 본문을 확인한다.
|
||||
|
||||
## 작업 절차 체크리스트
|
||||
- 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다.
|
||||
- 변경 중: 공개 API 스키마를 임의 변경하지 말고, 작은 단위로 안전하게 수정한다.
|
||||
- 변경 후: 최소 단일 테스트 또는 `./gradlew test`를 실행하고, 필요 시 `./gradlew ktlintCheck`를 수행한다.
|
||||
- 커밋 전/후: `commit-policy` 스킬을 먼저 로드하고, `git commit` 직전과 직후에 `work/scripts/check-commit-message-rules.sh`를 실행해 커밋 메시지 규칙 준수 여부를 확인한다.
|
||||
- 커밋 전/후 확인 시 Sisyphus attribution footer가 없는지 함께 검증한다.
|
||||
- `git commit` 실행 직전과 직후에 `work/scripts/check-commit-message-rules.sh`를 실행해 규칙 준수 여부를 검증한다.
|
||||
|
||||
## 작업 계획 문서 규칙 (docs)
|
||||
- 모든 작업 시작 전에 `docs` 폴더 아래에 계획 문서를 먼저 생성하고, 해당 문서를 기준으로 구현을 진행한다.
|
||||
- 연속된 하나의 작업에 대한 후속 수정/보완이라면 새 계획 문서를 만들지 말고 기존 계획 문서에 작업 항목과 검증 기록을 이어서 추가한다.
|
||||
- 계획 문서 파일명은 `[날짜]_구현할내용한글.md` 형식을 사용한다.
|
||||
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
|
||||
- 파일명 예시: `20260101_구글계정으로로그인.md`
|
||||
- 구현 항목은 기능/작업 단위로 분리해 체크박스(`- [ ]`) 목록으로 작성한다.
|
||||
- 구현 완료 시마다 체크박스를 `- [x]`로 갱신하고, 각 항목이 정상 구현되었는지 확인한다.
|
||||
- 작업 도중 범위가 변경되면 계획 문서의 체크박스 항목을 먼저 업데이트한 뒤 구현을 진행한다.
|
||||
- 모든 구현이 끝난 후 결과 보고 시 계획 문서 맨 아래에 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 기록한다.
|
||||
- 후속 수정이 발생해도 기존 검증 기록은 삭제/덮어쓰지 않고 누적한다(예: `1차 구현`, `2차 수정`).
|
||||
- 검증 기록은 단계별로 `무엇을/왜/어떻게`를 유지해 작성하고, 이전 단계와 구분이 되도록 명시한다.
|
||||
- 단계별 `어떻게`에는 실제 실행한 검증 명령과 결과(성공/실패/불가 사유)를 함께 기록한다.
|
||||
- 기존 기록 정정이 필요하면 원문을 지우지 말고 `정정` 항목을 추가해 사유와 변경 내용을 남긴다.
|
||||
|
||||
## 문서 유지보수 규칙
|
||||
- `build.gradle.kts` 변경 시 실행 명령 섹션을 함께 갱신한다.
|
||||
- 테스트 클래스 추가/이동 시 단일 테스트 실행 예시를 최신 상태로 유지한다.
|
||||
- `.editorconfig` 변경 시 포맷 규칙 섹션을 동기화한다.
|
||||
- Cursor/Copilot 규칙 파일이 생기면 해당 내용을 이 문서에 반영한다.
|
||||
- 문서 변경 후 최소 한 번 `./gradlew tasks --all`로 명령 유효성을 확인한다.
|
||||
- 불확실한 규칙은 추측으로 채우지 말고 근거 파일 경로를 먼저 확인한다.
|
||||
- 에이전트 안내 문구는 한국어 중심으로 유지한다.
|
||||
- 커밋 규칙 예시는 팀 컨벤션 변경 시 즉시 업데이트한다.
|
||||
- 구현 완료 시마다 체크박스를 `- [x]`로 갱신하고, 범위가 바뀌면 문서를 먼저 갱신한다.
|
||||
- 결과 보고 시 계획 문서 맨 아래에 무엇을, 왜, 어떻게 검증했는지 한국어로 누적 기록한다.
|
||||
|
||||
## 에이전트 동작 원칙
|
||||
- 추측하지 말고, 근거 파일을 읽고 결정한다.
|
||||
|
||||
35
docs/20260429_에이전트가이드통합정리.md
Normal file
35
docs/20260429_에이전트가이드통합정리.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# 20260429 에이전트 가이드 통합 정리
|
||||
|
||||
## 구현 계획
|
||||
- [x] 기존 `AGENTS.md`의 우선순위, 에이전트 행동, 스킬/워크플로우 관련 항목을 분석한다.
|
||||
- [x] 공식 `andrej-karpathy-skills` `CLAUDE.md` 원문을 확인하고 삽입 위치와 래퍼 문구를 확정한다.
|
||||
- [x] `AGENTS.md` 상단 근처에 `CORE EXECUTION PRINCIPLES (andrej-karpathy-skills)` 섹션을 추가한다.
|
||||
- [x] 사용자 지시 > `AGENTS.md` > 프로젝트별 제약 조건 > oh-my-openagent > superpowers > 기본 모델 동작 우선순위를 명시한다.
|
||||
- [x] oh-my-openagent, superpowers, 충돌 해결 규칙, 실행 모드를 한국어 정책으로 추가한다.
|
||||
- [x] `AGENTS.md`에는 핵심 규칙만 남기고 세부 규칙은 `docs/agent-guides/` 아래 별도 문서로 분리해 참조하도록 정리한다.
|
||||
- [x] 기존 `docs/20260429_에이전트가이드상세규칙.md`의 섹션 구성을 분석하고 분리 단위를 확정한다.
|
||||
- [x] `docs/agent-guides/` 아래에 주제별 문서를 생성한다.
|
||||
- [x] `AGENTS.md`가 분리된 문서를 직접 참조하도록 수정한다.
|
||||
- [x] 기존 `docs/20260429_에이전트가이드상세규칙.md`를 제거한다.
|
||||
- [x] `docs/agent-guides/` 하위 파일명에서 `에이전트가이드` 접두를 제거한 새 이름 규칙을 확정한다.
|
||||
- [x] `docs/agent-guides/` 하위 파일명을 `코드스타일.md`, `테스트스타일.md`, `설정보안.md`, `작업절차.md`, `문서유지보수.md`로 정리한다.
|
||||
- [x] `AGENTS.md`와 연결된 작업 기록 문서의 참조 경로를 새 파일명 기준으로 갱신한다.
|
||||
- [x] 문서 변경 검증을 수행하고 결과를 기록한다.
|
||||
|
||||
## 검증 기록
|
||||
- [x] 작업 완료 후 검증 결과를 기록한다.
|
||||
|
||||
- 1차 구현
|
||||
- 무엇을: `AGENTS.md`에 명시적 지시 우선순위, `CORE EXECUTION PRINCIPLES (andrej-karpathy-skills)` 원문, oh-my-openagent/superpowers 제어 정책, 충돌 해결 규칙, 실행 모드를 추가했다. 동시에 Kotlin/Spring 스타일·테스트·보안·문서 유지보수의 상세 규칙은 `docs/agent-guides/코드스타일.md`, `docs/agent-guides/테스트스타일.md`, `docs/agent-guides/설정보안.md`, `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`로 분리하고 `AGENTS.md`는 핵심 규칙과 참조만 남기도록 정리했다.
|
||||
- 왜: 기존 `AGENTS.md`는 프로젝트 규칙은 충분했지만 명시적 우선순위 체계와 플러그인/스킬 통제 계층이 없었고, 세부 규칙이 길어져 상단에서 핵심 실행 원칙을 빠르게 파악하기 어려웠기 때문이다.
|
||||
- 어떻게: `AGENTS.md`, `docs/20260429_에이전트가이드통합정리.md`, `docs/agent-guides/` 아래 분리 문서를 다시 읽어 필수 문구, 영어 원문 유지, 한국어 정책 분리, 참조 경로를 확인했다. `lsp_diagnostics`로 관련 문서 모두 `No diagnostics found`를 확인하고, `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||
|
||||
- 2차 정리
|
||||
- 무엇을: 동일 작업을 별도로 기록하던 `docs/20260429_에이전트가이드분리.md`의 구현 계획과 검증 범위를 이 문서로 통합하고, 중복 작업 문서는 제거했다.
|
||||
- 왜: 두 문서가 모두 같은 날짜의 동일 작업 흐름을 기록하고 있었고, 하나는 `AGENTS.md` 재구성, 다른 하나는 세부 규칙 분리라는 하위 단계만 다를 뿐 논리적으로 하나의 작업 계획 문서였기 때문이다.
|
||||
- 어떻게: 두 문서의 구현 계획과 검증 기록을 대조해 누락된 분리 작업 항목만 이 문서에 합쳤고, 이후 `grep`으로 두 문서명 참조를 확인한 뒤 중복 문서를 제거했다. 마지막으로 `lsp_diagnostics`와 `./gradlew tasks --all`로 문서 정합성과 저장소 문서 검증 명령 성공을 다시 확인했다.
|
||||
|
||||
- 3차 정리
|
||||
- 무엇을: `docs/agent-guides/` 하위 파일명에서 `에이전트가이드` 접두를 제거해 `코드스타일.md`, `테스트스타일.md`, `설정보안.md`, `작업절차.md`, `문서유지보수.md`로 정리했다. 함께 `AGENTS.md`와 이 문서의 참조 경로도 새 파일명 기준으로 수정했고, 별도 작업 문서였던 `docs/20260429_agent_guides파일명정리.md`는 이 문서로 통합했다.
|
||||
- 왜: 디렉터리 경로 자체가 이미 `agent-guides`라서 파일명에 동일한 의미를 반복할 필요가 없고, AGENTS 가이드 정비의 후속 단계 역시 별도 문서보다 하나의 연속 작업 기록 안에 남기는 편이 더 자연스럽기 때문이다.
|
||||
- 어떻게: `grep`으로 기존 `docs/agent-guides/에이전트가이드*.md` 참조가 남지 않았음을 확인했고, `glob`으로 새 파일명 5개만 존재함을 확인했다. `lsp_diagnostics`로 `AGENTS.md`, 이 문서, `docs/agent-guides/` 전체에 대해 오류가 없음을 확인했고, `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||
15
docs/20260429_연속작업계획문서재사용규칙.md
Normal file
15
docs/20260429_연속작업계획문서재사용규칙.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# 20260429 연속 작업 계획 문서 재사용 규칙
|
||||
|
||||
## 구현 계획
|
||||
- [x] 작업 계획 문서 생성과 관련된 기존 규칙 위치를 확인한다.
|
||||
- [x] 연속된 하나의 작업일 때는 새 계획 문서를 만들지 않고 기존 문서에 이어서 기록하는 규칙을 추가한다.
|
||||
- [x] 관련 문서 간 표현과 의도를 일치시킨다.
|
||||
- [x] 문서 진단과 검증 결과를 기록한다.
|
||||
|
||||
## 검증 기록
|
||||
- [x] 작업 완료 후 검증 결과를 기록한다.
|
||||
|
||||
- 1차 구현
|
||||
- 무엇을: `AGENTS.md`의 `작업 계획 문서 규칙 (docs)`에 연속된 하나의 작업이라면 새 계획 문서를 만들지 않고 기존 계획 문서에 작업 항목과 검증 기록을 이어서 추가한다는 규칙을 넣었다. 함께 `docs/agent-guides/작업절차.md`와 `docs/agent-guides/문서유지보수.md`에도 같은 취지의 보완 문구를 추가해 실행 흐름과 문서 정책이 일치하도록 정리했다.
|
||||
- 왜: 같은 작업의 후속 수정마다 새 계획 문서가 계속 생성되면 문서 수가 불필요하게 늘어나고, 작업 이력도 여러 파일로 흩어져 관리와 탐색이 어려워지기 때문이다.
|
||||
- 어떻게: `read`로 세 문서의 해당 섹션을 다시 확인해 문구 위치와 의미 일치를 검토했고, `lsp_diagnostics`로 `AGENTS.md`, `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`, `docs/20260429_연속작업계획문서재사용규칙.md`에 대해 모두 `No diagnostics found`를 확인했다. 추가로 `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||
54
docs/20260429_채팅방쿼터충전방식확장.md
Normal file
54
docs/20260429_채팅방쿼터충전방식확장.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 채팅방 쿼터 충전 방식 확장
|
||||
|
||||
## 구현 계획
|
||||
- [x] `purchaseRoomQuota` 요청 DTO만 보고도 `캔 충전`인지 `광고 충전`인지 구분할 수 있도록 충전 방식 필드를 확장하고, 광고 충전 요청을 구분할 수 있는 최소 스키마를 정의한다.
|
||||
- [x] 캔 충전 옵션을 기존 단일 고정값에서 `10캔 → 15개`, `20캔 → 40개`의 2개 시나리오로 분기하도록 컨트롤러/서비스 전달 구조를 정리한다.
|
||||
- [x] `ChatRoomQuotaService.purchase`의 하드코딩된 `needCan=10`, `addPaid=12` 로직을 요청값 기반 분기로 치환하고, 광고 충전 시 캔 차감 없이 `5` quota를 지급하는 경로를 분리한다.
|
||||
- [x] 광고 충전은 1회당 `5` quota 지급으로 반영하고, 중복 호출 방지 여부와 추가 검증 조건만 별도 확인한다.
|
||||
- [x] 관련 테스트 또는 최소 검증 경로를 정리하고 `./gradlew test` 기준으로 회귀 여부를 확인한다.
|
||||
|
||||
## 요구사항 반영 체크
|
||||
- [x] Quota 충전 방식은 `캔 충전`, `광고 보고 충전`의 2가지로 확장한다.
|
||||
- [x] 서버는 요청 DTO를 통해 `캔 충전`인지 `광고 충전`인지 식별할 수 있어야 한다.
|
||||
- [x] 캔 충전 옵션은 `10캔 → 15개`, `20캔 → 40개`의 2가지다.
|
||||
- [x] 광고 충전은 1회당 `5` quota를 지급한다.
|
||||
|
||||
## 영향 범위 메모
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt`
|
||||
- 요청 DTO와 `purchaseRoomQuota` 분기 진입점 변경 대상.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt`
|
||||
- 캔 사용량/유료 quota 지급량 하드코딩 제거 및 충전 방식별 처리 분기 대상.
|
||||
- 테스트 코드
|
||||
- 현재 `src/test/kotlin`에서 `ChatRoomQuota` 관련 테스트가 바로 확인되지 않아, 필요 시 서비스 중심 테스트를 새로 보강한다.
|
||||
|
||||
## 확인 필요 사항
|
||||
- 광고 충전은 앱에서 선처리한다고 했으므로 서버는 DTO 기반 분기와 1회당 `5` quota 지급만 담당하고, 광고 시청 자체의 검증 책임은 범위 밖으로 본다.
|
||||
- 기존 클라이언트 호환을 위해 요청 본문에 `container`만 오면 서버는 기본값으로 `CAN + CAN_10` 충전으로 해석한다.
|
||||
- 광고 충전 요청에 `canOption`이 함께 오더라도 서버는 예외를 내지 않고 값을 무시한 뒤 광고 충전으로 처리한다.
|
||||
|
||||
## 검증 기록
|
||||
|
||||
### 1차 계획 수립
|
||||
- 무엇을: `purchaseRoomQuota` API에 광고 충전 구분과 2종 캔 충전 옵션을 추가하기 위한 구현 계획과 예상 영향 범위를 문서화했다.
|
||||
- 왜: 현재 구현은 `container`만 받고 서비스에서 `10캔 → 12개 quota`를 고정 처리하고 있어, 요구사항을 반영하려면 DTO/서비스 분기와 미정 항목을 먼저 분리해 두는 것이 안전하기 때문이다.
|
||||
- 어떻게: `ChatRoomQuotaController.kt`, `ChatRoomQuotaService.kt`, `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`, 기존 계획 문서 예시를 읽어 형식과 영향 범위를 맞췄고, 문서 변경 후 `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||
|
||||
### 2차 요구사항 점검 반영
|
||||
- 무엇을: 문서가 충전 방식 2종, 요청 DTO를 통한 충전 방식 식별, 캔 충전 2가지 옵션, 광고 충전 5개 지급을 모두 명시적으로 포함하도록 보강했다.
|
||||
- 왜: 기존 문서도 대부분의 요구사항을 담고 있었지만, 요구사항 전체를 한눈에 대조할 수 있는 체크 형태가 없어 일부 표현이 암묵적으로 읽힐 여지가 있었기 때문이다.
|
||||
- 어떻게: 계획 문서 본문에 `요구사항 반영 체크` 섹션을 추가하고 핵심 조건을 체크박스로 정리했으며, 문서 변경 후 `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 다시 확인했다.
|
||||
|
||||
### 3차 구현 및 검증
|
||||
- 무엇을: `purchaseRoomQuota` 요청 DTO에 충전 방식과 캔 옵션 enum을 추가하고, 컨트롤러에서 `캔 충전`과 `광고 충전`을 분기하도록 변경했다. 함께 `ChatRoomQuotaService`를 `purchaseWithCan`, `purchaseWithAd` 경로로 분리해 `10캔 → 15개`, `20캔 → 40개`, `광고 → 5개` 지급 규칙을 반영했다. 또한 `ChatRoomQuotaControllerTest`, `ChatRoomQuotaServiceTest`를 추가해 컨트롤러 분기와 서비스 적립 로직을 검증했다.
|
||||
- 왜: 기존 구현은 `container`만 받고 `10캔 → 12개`를 단일 하드코딩으로 처리해 새로운 충전 방식과 옵션을 구분할 수 없었기 때문이다.
|
||||
- 어떻게: `ChatRoomQuotaController.kt`, `ChatRoomQuotaService.kt`에 최소 분기 로직을 추가하고 `ChatRoomQuotaPurchaseOption.kt`에 enum을 분리했다. 검증은 Kotlin LSP가 이 환경에서 지원되지 않아 진단 대신 `./gradlew test --tests "kr.co.vividnext.sodalive.chat.quota.room.ChatRoomQuotaServiceTest" --tests "kr.co.vividnext.sodalive.chat.quota.room.ChatRoomQuotaControllerTest"`를 실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||
|
||||
### 4차 하위 호환 보완
|
||||
- 무엇을: 기존 클라이언트가 `container`만 보내도 동작하도록 `PurchaseRoomQuotaRequest`의 기본 충전 방식을 `CAN`, 기본 캔 옵션을 `CAN_10`으로 해석하도록 보완했고, 이를 검증하는 테스트를 추가했다.
|
||||
- 왜: 새 DTO 필드를 필수로 두면 구버전 클라이언트의 기존 요청 본문이 역직렬화 단계에서 바로 깨질 수 있기 때문이다.
|
||||
- 어떻게: `ChatRoomQuotaController.kt`에서 `chargeType` 기본값을 `CAN`으로 두고 `canOption` 누락 시 `CAN_10`을 사용하도록 변경했다. 검증은 `ChatRoomQuotaControllerTest`에 `container`만 전달하는 직접 호출 케이스를 추가해 수행했다.
|
||||
|
||||
### 5차 광고 옵션 허용 완화
|
||||
- 무엇을: 광고 충전 요청에 `canOption`이 함께 들어와도 예외를 던지지 않고 무시하도록 컨트롤러 분기를 완화했고, 해당 요청이 정상적으로 광고 충전 경로로 전달되는 테스트로 기대값을 바꿨다.
|
||||
- 왜: 광고 충전은 지급량이 고정이라 `canOption`이 의미 없는 부가 필드에 가깝고, 이를 이유로 요청을 실패시키는 것보다 무시하는 편이 클라이언트 호환성과 운영 안정성에 유리하기 때문이다.
|
||||
- 어떻게: `ChatRoomQuotaController.kt`의 `AD` 분기에서 `canOption` 검증 예외를 제거했고, `ChatRoomQuotaControllerTest`의 광고 케이스를 성공 경로 검증으로 변경했다.
|
||||
23
docs/20260501_payverse-jpy-지원.md
Normal file
23
docs/20260501_payverse-jpy-지원.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Payverse JPY 지원 작업 계획
|
||||
|
||||
- [x] 요구사항 정리
|
||||
- JPY 전용 자격 증명 사용
|
||||
- `payverseCharge`, `payverseWebhook`, `payverseVerify` 모두 일관 분기 추가
|
||||
- 금액 포맷: JPY는 강제 정수화(소수점 버림)
|
||||
- 결제수단 표기는 현행 규칙 유지
|
||||
|
||||
- [x] 구현 항목
|
||||
- [x] 환경변수 주입: `payverse.jpy-mid`, `payverse.jpy-client-key`, `payverse.jpy-secret-key`
|
||||
- [x] `ChargeService.payverseCharge`에 JPY 분기 및 금액 포맷 적용
|
||||
- [x] `ChargeService.payverseWebhook`에 JPY 분기 및 금액 검증 적용
|
||||
- [x] `ChargeService.payverseVerify`에 JPY 분기 및 금액 검증 적용
|
||||
- [x] 공통 금액 포맷 함수 `computePayverseAmount` 추가 (JPY=버림, 그외=4자리 반올림)
|
||||
|
||||
- [ ] 검증 항목
|
||||
- [ ] 단위/통합 테스트 빌드 및 실행 (`./gradlew test`)
|
||||
- [ ] KRW/JPY/USD 각각에 대해 payload 서명 및 검증 로직 수기 점검
|
||||
- [ ] JPY에서 `requestAmount`가 항상 정수로 전송되는지 로깅/샘플 요청으로 확인(스테이징)
|
||||
|
||||
## 검증 로그
|
||||
- [ ] 빌드/테스트 결과:
|
||||
- [ ] 수기 점검 결과:
|
||||
338
docs/20260506_번역언어감지효율화구상.md
Normal file
338
docs/20260506_번역언어감지효율화구상.md
Normal file
@@ -0,0 +1,338 @@
|
||||
# 번역/언어감지 효율화 구상
|
||||
|
||||
## 배경
|
||||
- 현재 구조는 도메인별 번역 테이블과 이벤트 기반 Papago 호출이 혼재되어 있다.
|
||||
- 생성/수정 이벤트 번역과 조회 시 캐시 미스 번역이 동시에 존재해 동일 원문이 중복 번역될 수 있다.
|
||||
- 번역 저장 로직은 `ContentTranslation`, `SeriesTranslation`, `AiCharacterTranslation`, `OriginalWorkTranslation`, `CategoryTranslation` 등으로 분산되어 있으며, 조회/upsert 패턴이 반복된다.
|
||||
- Papago 호출은 텍스트와 대상 locale 단위로 순차 실행되어 대상 하나가 여러 HTTP 요청으로 확장된다.
|
||||
|
||||
## 목표
|
||||
- Papago 호출 수와 비용을 줄인다.
|
||||
- 조회 API에서 Papago 호출로 인한 응답 지연을 줄인다.
|
||||
- 번역 저장/조회 로직의 중복을 줄이고, 향후 번역 provider 교체가 가능하도록 한다.
|
||||
- 기존 API 응답 스키마는 유지하고, DB 변경은 단계적으로 적용한다.
|
||||
|
||||
## 구현 항목
|
||||
- [x] `TranslationProvider` 인터페이스와 Papago provider 구현을 분리한다.
|
||||
- [x] `translation_memory`, `translation_job`, `language_detection_result` 테이블을 추가한다.
|
||||
- [x] 조회 fallback의 직접 Papago 호출을 제거하고 누락 번역 job 예약으로 전환한다.
|
||||
- [x] 생성/수정 이벤트 번역을 `translation_job` 기반 워커 처리로 전환한다.
|
||||
- [x] `TranslationMemory` 결과를 기존 JSON/scalar read model로 materialize한다.
|
||||
- [ ] 목록 조회 병목 측정 후 필요한 도메인에만 hot column을 추가한다.
|
||||
|
||||
## 접근안 비교
|
||||
|
||||
### 접근안 A: 현재 구조 유지 + 중복 방지 보강
|
||||
- 도메인별 번역 테이블은 유지한다.
|
||||
- 번역 전 기존 번역 조회와 unique 제약을 강화한다.
|
||||
- 조회 fallback에서 번역 생성 대신 번역 작업만 예약하도록 바꾼다.
|
||||
|
||||
장점:
|
||||
- 변경 범위가 가장 작다.
|
||||
- 기존 코드와 데이터 마이그레이션 위험이 낮다.
|
||||
|
||||
단점:
|
||||
- 도메인별 중복 저장/로직 구조는 그대로 남는다.
|
||||
- 원문이 같아도 도메인별로 재번역될 수 있다.
|
||||
|
||||
### 접근안 B: 원문 해시 기반 Translation Memory + 비동기 작업 큐 추가
|
||||
- 원문 텍스트를 정규화하고 `source_hash`로 중복을 제거한다.
|
||||
- `translation_memory`에 원문/언어쌍/번역 결과를 저장한다.
|
||||
- 도메인 엔티티는 `translation_job`을 생성하고, 워커가 Papago를 호출해 결과를 저장한다.
|
||||
- 조회 API는 Papago를 직접 호출하지 않고 기존 번역을 반환하거나 미번역 상태를 유지한다.
|
||||
|
||||
장점:
|
||||
- 동일 원문 재번역을 줄일 수 있다.
|
||||
- 조회 응답 지연을 안정적으로 제거할 수 있다.
|
||||
- 기존 도메인별 번역 테이블을 당장 제거하지 않아도 단계 적용이 가능하다.
|
||||
|
||||
단점:
|
||||
- 작업 큐, 상태 관리, 재시도 정책이 필요하다.
|
||||
- 생성 직후 번역이 아직 없을 수 있으므로 UX/응답 정책을 정해야 한다.
|
||||
|
||||
### 접근안 C: 도메인별 번역 테이블을 공통 번역 저장소로 통합
|
||||
- 모든 번역 결과를 `translation_entry` 같은 공통 테이블에 저장한다.
|
||||
- `resource_type`, `resource_id`, `field_key`, `locale`, `translated_text` 또는 JSON payload로 도메인별 필드를 표현한다.
|
||||
- 기존 도메인별 번역 테이블은 읽기 호환 기간 후 제거한다.
|
||||
|
||||
장점:
|
||||
- 저장/조회/upsert 로직을 크게 단순화할 수 있다.
|
||||
- 새 번역 대상 추가 비용이 줄어든다.
|
||||
|
||||
단점:
|
||||
- 마이그레이션 위험이 가장 크다.
|
||||
- 도메인별 payload 검증과 타입 안정성이 약해질 수 있다.
|
||||
- 기존 조회 쿼리와 응답 조립 로직 영향 범위가 넓다.
|
||||
|
||||
## 권장 방향
|
||||
- 최종 구현방식은 접근안 B를 중심으로 하되, 조회 성능을 위해 도메인별 read model을 유지하는 하이브리드 구조로 한다.
|
||||
- 기존 번역 데이터를 배제할 수 있어도 모든 번역 결과를 정규화 row만으로 저장하는 방식은 채택하지 않는다.
|
||||
- 이유는 Papago 호출 수 절감은 원문 해시 기반 `TranslationMemory`가 담당하고, API 조회 성능은 `entityId + locale` 단위 read model이 담당하는 분리가 가장 효율적이기 때문이다.
|
||||
- 2차로 운영 안정화 후 도메인별 read model의 중복을 줄이되, 조회 경로가 복잡해지는 전면 통합은 실제 병목이 확인될 때만 진행한다.
|
||||
|
||||
## 최종 구현방식
|
||||
|
||||
### 설계 원칙
|
||||
- 번역 원장은 정규화한다.
|
||||
- 조회 결과는 도메인별로 materialize한다.
|
||||
- Papago 호출은 사용자 요청 스레드에서 수행하지 않는다.
|
||||
- JSON payload는 원장이 아니라 조회 최적화용 read model로만 사용한다.
|
||||
|
||||
### 저장 구조
|
||||
|
||||
#### `translation_memory`
|
||||
- 동일 원문을 반복 번역하지 않기 위한 공통 번역 캐시이다.
|
||||
- 원문은 의미가 바뀌지 않는 선에서 공백, 줄바꿈, Unicode 표현을 정규화한다.
|
||||
- 주요 컬럼:
|
||||
- `id`
|
||||
- `source_hash`
|
||||
- `source_text`
|
||||
- `source_language`
|
||||
- `target_language`
|
||||
- `translated_text`
|
||||
- `provider`
|
||||
- `provider_version`
|
||||
- `normalization_version`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
- unique 제약:
|
||||
- `(source_hash, source_language, target_language, provider, normalization_version)`
|
||||
- 역할:
|
||||
- Papago 호출 수 절감의 핵심 원장이다.
|
||||
- 도메인이 달라도 원문과 언어쌍이 같으면 같은 번역을 재사용한다.
|
||||
|
||||
#### `translation_job`
|
||||
- 번역 실행 상태를 관리하는 durable job 테이블이다.
|
||||
- 현재 `@Async` 이벤트만으로 처리하던 작업을 명시적인 상태 모델로 바꾼다.
|
||||
- 주요 컬럼:
|
||||
- `id`
|
||||
- `resource_type`
|
||||
- `resource_id`
|
||||
- `field_key`
|
||||
- `source_hash`
|
||||
- `source_language`
|
||||
- `target_language`
|
||||
- `status`
|
||||
- `retry_count`
|
||||
- `last_error_message`
|
||||
- `next_retry_at`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
- 중복 방지:
|
||||
- 활성 상태 기준 `(resource_type, resource_id, field_key, target_language, source_hash)` 중복 생성을 막는다.
|
||||
- 역할:
|
||||
- 중복 작업 방지, 재시도, 실패 추적, 운영자 재처리를 담당한다.
|
||||
|
||||
#### `language_detection_result`
|
||||
- 언어 감지 결과를 원본 엔티티에만 저장하지 않고 별도 캐시/이력으로 관리한다.
|
||||
- 주요 컬럼:
|
||||
- `id`
|
||||
- `source_hash`
|
||||
- `source_text_sample`
|
||||
- `detected_language`
|
||||
- `provider`
|
||||
- `confidence`
|
||||
- `normalization_version`
|
||||
- `created_at`
|
||||
- 역할:
|
||||
- 짧은 문구나 반복 문구의 감지 호출을 줄인다.
|
||||
- 원본 엔티티의 `languageCode`는 기존 호환 필드로 유지할 수 있다.
|
||||
|
||||
#### 도메인별 번역 read model
|
||||
- `ContentTranslation`, `SeriesTranslation`, `AiCharacterTranslation`, `OriginalWorkTranslation`처럼 여러 필드가 묶인 응답은 JSON payload read model을 유지한다.
|
||||
- `CategoryTranslation`, `ContentThemeTranslation`, `SeriesGenreTranslation`처럼 단일 문자열만 필요한 대상은 scalar column을 유지한다.
|
||||
- JSON payload는 `translation_memory`를 조립한 결과를 저장하는 materialized cache로 본다.
|
||||
- provider, retry, source hash 같은 실행/원장 메타데이터는 JSON payload에 넣지 않는다.
|
||||
|
||||
### JSON 유지 여부
|
||||
- JSON payload 저장은 유지한다.
|
||||
- 단, JSON을 번역의 최종 원장으로 보지 않고 API 응답을 빠르게 만들기 위한 read model로 격하한다.
|
||||
- 현재 조회 패턴은 JSON 내부 검색이 아니라 `resourceId + locale`로 1-row 조인 후 payload를 읽는 방식이므로, 정규화 row를 매번 pivot하는 것보다 조회 경로가 단순하다.
|
||||
- 목록 API에서 제목이나 이름 하나만 자주 읽는데 payload가 커지는 도메인은 `translated_title`, `translated_name` 같은 hot column을 선택적으로 추가한다.
|
||||
- 단일 문자열 번역 대상은 JSON으로 바꾸지 않는다.
|
||||
|
||||
### 처리 흐름
|
||||
- 생성/수정 시 번역 대상 필드를 세그먼트로 추출한다.
|
||||
- 세그먼트별 `source_hash`를 계산한다.
|
||||
- 언어 정보가 없으면 `language_detection_result`를 조회하고, 없을 때만 감지 작업을 수행한다.
|
||||
- 대상 언어별로 `translation_memory`를 먼저 조회한다.
|
||||
- cache hit이면 Papago를 호출하지 않고 read model materialize 단계로 넘어간다.
|
||||
- cache miss이면 `translation_job`을 `PENDING`으로 생성한다.
|
||||
- 워커가 job을 가져와 Papago를 호출하고 `translation_memory`에 저장한다.
|
||||
- 대상 resource의 모든 필드 번역이 준비되면 도메인별 read model JSON 또는 scalar row를 갱신한다.
|
||||
|
||||
### 조회 정책
|
||||
- 조회 API에서는 Papago를 직접 호출하지 않는다.
|
||||
- 번역 read model이 있으면 해당 번역을 반환한다.
|
||||
- 번역 read model이 없으면 원문 또는 기존 fallback locale을 반환한다.
|
||||
- 누락된 번역은 조회 요청에서 job만 예약할 수 있으며, 외부 API 응답을 기다리지 않는다.
|
||||
|
||||
### 최종 선택 요약
|
||||
| 영역 | 최종 선택 | 이유 |
|
||||
|---|---|---|
|
||||
| Papago 중복 호출 방지 | `translation_memory` 정규화 | 동일 원문/언어쌍 재사용이 가능하다. |
|
||||
| 번역 실행 | `translation_job` 비동기 큐 | 재시도와 실패 추적이 가능하고 조회 응답을 지연시키지 않는다. |
|
||||
| 다중 필드 응답 저장 | JSON read model 유지 | `entityId + locale` 1-row 조회가 가능해 API 조립이 단순하다. |
|
||||
| 단일 문자열 번역 저장 | scalar column 유지 | JSON보다 단순하고 불필요한 변환 비용이 없다. |
|
||||
| 자주 읽는 일부 필드 | 선택적 hot column 추가 | 큰 JSON payload 전체 로딩 비용을 줄일 수 있다. |
|
||||
| provider 확장 | `TranslationProvider` 인터페이스 | Papago 의존을 낮추고 향후 교체를 쉽게 한다. |
|
||||
|
||||
## 상세 정책
|
||||
|
||||
### 운영자 재처리
|
||||
- 관리자/운영 API에서는 번역 상태를 확인하고 재번역을 요청할 수 있게 한다.
|
||||
- 실패한 job은 원문, 대상 언어, provider, 실패 사유, 재시도 횟수를 함께 보여준다.
|
||||
|
||||
### 중복 방지 정책
|
||||
- `TranslationMemory`는 `(source_hash, source_language, target_language, provider, normalization_version)`에 unique 제약을 둔다.
|
||||
- `TranslationJob`은 활성 상태 기준 `(resource_type, resource_id, field_key, target_language, source_hash)` 중복 생성을 막는다.
|
||||
- 동일 작업이 동시에 들어오면 기존 `PENDING` 또는 `RUNNING` 작업을 재사용한다.
|
||||
|
||||
### 오류와 재시도
|
||||
- Papago 실패 시 `TranslationJob.status = FAILED`와 실패 사유를 저장한다.
|
||||
- 일시 오류는 지수 백오프로 재시도한다.
|
||||
- 영구 오류나 빈 원문은 재시도하지 않는다.
|
||||
- 재시도 횟수를 초과하면 운영자가 재시도할 수 있도록 별도 상태로 남긴다.
|
||||
|
||||
### 운영 안정화 보완 작업
|
||||
- 현재 `TranslationJobWorker.claimNextJob()`는 `PENDING` job을 조회한 뒤 `RUNNING`으로 변경한다.
|
||||
- 다중 애플리케이션 인스턴스에서 같은 job을 동시에 조회할 수 있으므로 운영 반영 전 claim을 원자화한다.
|
||||
- 권장 방식은 MySQL 기준 `SELECT ... FOR UPDATE SKIP LOCKED` 또는 `UPDATE ... WHERE status = 'PENDING' ... LIMIT 1` 기반 원자적 claim이다.
|
||||
- job row를 여러 인스턴스가 나눠 처리하는 목적에는 `FOR UPDATE SKIP LOCKED` 또는 atomic update claim을 우선한다.
|
||||
- `ShedLock`은 스케줄러 실행 자체의 중복을 막는 용도로는 적합하지만, job row 단위 분산 claim을 대체하지는 않는다.
|
||||
- Papago 호출은 DB lock을 잡은 트랜잭션 밖에서 수행하고, claim/완료/실패 상태 변경만 짧은 트랜잭션으로 처리한다.
|
||||
- `FAILED`로 즉시 종료하는 최소 구현에서 지수 백오프 기반 재시도 정책으로 보완한다.
|
||||
- 재시도 정책은 `retry_count`, `next_retry_at`, `last_error_message`를 함께 갱신하고, 최대 재시도 초과 상태를 운영자가 확인할 수 있게 한다.
|
||||
- worker 처리량과 부하를 운영 설정으로 제어할 수 있도록 `fixed-delay-ms`, tick당 처리 group 수, 최대 재시도 횟수, Papago 호출 rate limit을 설정화한다.
|
||||
- tick당 처리 단위는 단순 job row 수가 아니라 `(resource_type, resource_id, target_language)` group으로 잡는다.
|
||||
- 1차 운영 기준은 tick당 최대 5개 group 처리로 둔다.
|
||||
- group 내부의 field job은 순차 처리하고, 같은 resource/locale의 모든 필드가 `translation_memory`에 준비된 뒤 read model을 materialize한다.
|
||||
- 콘텐츠 기준으로는 1개 group이 `title`, `detail`, `tags` 3개 field job이므로 tick당 최대 15개 field job이 된다.
|
||||
- 캐릭터처럼 필드가 많은 리소스도 group 5개 제한 안에서 처리해 Papago 호출 burst를 완화한다.
|
||||
- 운영 관측을 위해 pending/running/failed/completed count, oldest pending age, 처리 성공/실패 수, Papago 호출 시간, materialize 실패 수를 로그 또는 metric으로 남긴다.
|
||||
|
||||
### 번역 job 실행 주기 조정 검토
|
||||
- 현재 구현은 `sodalive.translation-job.fixed-delay-ms` 설정이 없으면 기본 `5000ms` fixed delay로 실행된다.
|
||||
- 콘텐츠가 지속적으로 올라오는 서비스 형태가 아니므로 기본 주기를 10분(`600000ms`)으로 늘리는 방향을 검토한다.
|
||||
- `fixedDelay`는 작업 종료 후 다음 실행까지의 지연 시간이므로 실제 실행 간격은 `처리 시간 + 10분`이 된다.
|
||||
- 정확히 벽시계 기준 10분마다 실행해야 한다면 `cron + 스케줄 중복 방지 lock`을 검토하고, 처리 후 10분 쉬는 정책이면 `fixedDelay`를 사용한다.
|
||||
- 10분 주기의 장점은 불필요한 DB polling과 Papago 호출 burst 가능성을 줄이고, 낮은 트래픽 환경에서 백그라운드 작업 부하를 완화하는 것이다.
|
||||
- 10분 주기의 단점은 번역 read model 반영 지연이 최대 10분 이상으로 늘어날 수 있다는 점이다.
|
||||
- 조회 정책이 원문 즉시 반환 + job 예약 방식이므로 API 응답 실패로 이어지지는 않지만, 사용자는 첫 조회 후 최대 다음 worker 실행까지 원문을 볼 수 있다.
|
||||
- 기존 tick당 20 job row 처리 방식은 같은 resource/locale의 일부 필드만 처리하고 다음 tick으로 넘어갈 수 있다.
|
||||
- 10분 주기에서는 부분 처리된 resource의 read model 반영이 다음 tick까지 지연될 수 있으므로 `(resource_type, resource_id, target_language)` group 단위 처리로 보완한다.
|
||||
- 10분 주기를 적용하려면 tick당 처리 group 수를 운영 설정으로 조정하거나, pending backlog가 특정 기준을 넘을 때 수동/운영자 재처리 또는 임시 짧은 주기 전환이 가능해야 한다.
|
||||
- 생성 직후 번역 노출이 중요한 리소스가 발견되면 해당 리소스만 별도 즉시 처리 정책을 두고, 일반 조회 fallback은 10분 주기를 유지한다.
|
||||
- 1차 운영 기준은 `fixed-delay-ms = 600000`, tick당 최대 5개 group, 원문 fallback 허용, backlog/oldest pending age 모니터링으로 둔다.
|
||||
|
||||
### 단계별 적용
|
||||
- 1단계: `TranslationProvider` 인터페이스를 만들고 기존 `PapagoTranslationService`를 provider 구현으로 감싼다.
|
||||
- 2단계: `translation_memory`, `translation_job`, `language_detection_result` 테이블을 추가한다.
|
||||
- 3단계: 조회 fallback의 직접 Papago 호출을 제거하고, 누락 번역 job 예약 방식으로 전환한다.
|
||||
- 4단계: 생성/수정 이벤트 번역을 `translation_job` 기반 워커로 전환한다.
|
||||
- 5단계: `TranslationMemory` 결과를 기존 JSON/scalar read model로 materialize한다.
|
||||
- 6단계: 목록 조회에서 큰 JSON payload 로딩이 병목이면 hot column을 선택적으로 추가한다.
|
||||
|
||||
## 검증 관점
|
||||
- 동일 원문을 여러 도메인에서 번역해도 Papago 호출이 한 번만 발생하는지 확인한다.
|
||||
- 조회 API에서 Papago 호출이 발생하지 않는지 확인한다.
|
||||
- 생성/수정 이벤트 후 번역 작업이 중복 생성되지 않는지 확인한다.
|
||||
- Papago 장애 시 원 API 응답이 실패하지 않고 작업 상태만 실패로 남는지 확인한다.
|
||||
- 기존 API 응답 스키마가 바뀌지 않는지 확인한다.
|
||||
|
||||
## 남은 결정사항
|
||||
- 번역이 없는 조회 응답은 원문을 반환한다.
|
||||
- 실시간 번역이 꼭 필요한 엔드포인트는 1차 구현 범위에 포함하지 않는다. 단, 생성 직후 번역 노출이 중요한 리소스가 확인되면 해당 리소스만 별도 즉시 처리 정책을 검토한다.
|
||||
- JSON read model을 장기 유지하되, hot column이 필요한 목록 API를 측정으로 결정해야 한다.
|
||||
- Papago 외 provider는 1차 구현에서는 인터페이스만 준비하고 실제 추가 provider는 범위에서 제외한다.
|
||||
|
||||
## 검증 기록
|
||||
- 2026-05-06: 코드 탐색 결과를 바탕으로 현재 번역 저장 구조, Papago 호출 흐름, 외부 아키텍처 패턴을 비교해 설계 초안을 작성했다.
|
||||
- 2026-05-06: 문서 내 미완성 placeholder, 상충되는 범위, 구현 전제 누락 여부를 점검했다.
|
||||
- 2026-05-06: 기존 번역 데이터 보존 제약을 배제해도 `TranslationMemory + TranslationJob + JSON read model` 하이브리드가 최종 구현방식으로 적합한지 재평가하고 문서에 반영했다.
|
||||
- 2026-05-06: TDD RED 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizerTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest'` 실행 시 `SourceTextNormalizer`, `TranslationJobRepository`, `TranslationJobScheduler`, `LanguageDetectionResultRepository`, `LanguageDetectionCacheService` 미구현으로 `compileTestKotlin` 실패를 확인했다.
|
||||
- 2026-05-06: 구현 후 동일 targeted test 명령을 재실행해 `BUILD SUCCESSFUL`을 확인했다. 정규화/해시, 누락 번역 job 생성, 언어 감지 캐시 hit 시 provider 미호출 동작을 검증했다.
|
||||
- 2026-05-06: 전체 회귀 검증으로 `./gradlew test`와 `./gradlew build`를 실행해 모두 `BUILD SUCCESSFUL`을 확인했다. `build`에는 ktlint main/test/sourceSet check가 포함되어 스타일도 함께 검증됐다.
|
||||
- 2026-05-06: `PapagoTranslationService|translationService\.translate|papagoTranslationService\.translate|TranslateRequest` 검색으로 직접 번역 호출이 `PapagoTranslationService`, `TranslationProvider`, `TranslationJobWorker`, DTO, 지원 언어 목록 참조에만 남아 있음을 확인했다. 조회 fallback과 `LanguageTranslationListener`에는 직접 Papago 번역 호출이 남아 있지 않다.
|
||||
- 2026-05-06: MySQL unique 제약은 활성 상태 partial unique를 표현할 수 없으므로 완료 job이 있는 동일 key 재예약 시 중복 insert가 발생하지 않도록 repository 파생 쿼리 기반 회귀 테스트를 추가했다. RED는 중복 job 조회 메서드 미구현으로 `compileTestKotlin` 실패, GREEN은 동일 테스트 `BUILD SUCCESSFUL`로 확인했다.
|
||||
- 2026-05-06: 최종 확인에서 Kotlin LSP 서버가 설정되어 있지 않아 `lsp_diagnostics`는 `No LSP server configured for extension: .kt`로 실행할 수 없었다. 대체 검증으로 `./gradlew test`, `./gradlew build`, 신규 focused test `--rerun-tasks`를 실행했고 모두 `BUILD SUCCESSFUL`을 확인했다.
|
||||
- 2026-05-06: 운영 안정화 보완 구현으로 `TranslationJobWorker` 기본 fixed delay를 10분(`600000ms`)으로 변경하고, MySQL `FOR UPDATE SKIP LOCKED` 기반 job id claim, 실패 시 `PENDING` 재전환 + `next_retry_at` backoff + 최대 재시도 후 `FAILED` 전환을 적용했다. RED는 `TranslationJobWorkerTest`에서 원자 claim 메서드 미구현으로 `compileTestKotlin` 실패, GREEN은 동일 focused test `BUILD SUCCESSFUL`로 확인했다.
|
||||
- 2026-05-06: 문서에 반영한 group 단위 처리 정책을 구현했다. `TranslationJobWorker`는 tick당 최대 5개 `(resource_type, resource_id, target_language)` group을 처리하고, group 내부 pending field job을 `RUNNING`으로 claim한 뒤 모두 성공한 경우 한 번만 read model을 materialize한다. RED는 `findPendingJobIdsForGroupForUpdate`, `processNextGroup` 미구현으로 `TranslationJobWorkerTest`의 `compileTestKotlin` 실패를 확인했고, GREEN은 동일 focused test `BUILD SUCCESSFUL`로 확인했다.
|
||||
- 2026-05-06: group 처리 리뷰에서 materialize 실패 후 재시도 불가 가능성과 seed row 기반 claim의 group 분리 가능성을 확인했다. 보완 구현으로 단일 native query `findNextPendingGroupJobIdsForUpdate`에서 다음 pending group의 job id들을 `FOR UPDATE SKIP LOCKED`로 함께 claim하고, materialize 실패 시 group job들을 backoff 재시도 대상으로 되돌리도록 수정했다. RED는 새 group claim 메서드 미구현으로 `TranslationJobWorkerTest`의 `compileTestKotlin` 실패를 확인했고, GREEN은 동일 focused test `BUILD SUCCESSFUL`로 확인했다.
|
||||
- 2026-05-06: group 처리 전환 후 남은 이전 row 단위 claim 함수 사용처를 `rg`, AST 검색, explore/librarian 병렬 탐색으로 확인했다. production 경로가 `processNextGroup` + `findNextPendingGroupJobIdsForUpdate`로 수렴되어 `processNextJob`, `findFirstByStatusAndNextRetryAtLessThanEqualOrderByCreatedAtAsc`, `findNextPendingJobIdForUpdate`, `findPendingJobIdsForGroupForUpdate`를 제거했다.
|
||||
|
||||
## 2026-05-06 구현 DDL
|
||||
|
||||
운영 MySQL은 `spring.jpa.hibernate.ddl-auto=validate`이므로 아래 DDL을 선반영해야 한다. `created_at`, `updated_at`은 `BaseEntity`의 `createdAt`, `updatedAt`과 매핑된다.
|
||||
|
||||
```sql
|
||||
CREATE TABLE translation_memory (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '번역 메모리 ID',
|
||||
source_hash VARCHAR(64) NOT NULL COMMENT '정규화 원문 SHA-256 해시',
|
||||
source_text TEXT NOT NULL COMMENT '정규화 원문 텍스트',
|
||||
source_language VARCHAR(10) NOT NULL COMMENT '원문 언어 코드',
|
||||
target_language VARCHAR(10) NOT NULL COMMENT '대상 언어 코드',
|
||||
translated_text TEXT NOT NULL COMMENT '번역 결과 텍스트',
|
||||
provider VARCHAR(50) NOT NULL COMMENT '번역 provider 이름',
|
||||
provider_version VARCHAR(50) NOT NULL COMMENT '번역 provider 버전',
|
||||
normalization_version VARCHAR(20) NOT NULL COMMENT '정규화 규칙 버전',
|
||||
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_translation_memory_source_target_provider (
|
||||
source_hash,
|
||||
source_language,
|
||||
target_language,
|
||||
provider,
|
||||
normalization_version
|
||||
),
|
||||
KEY idx_translation_memory_source_hash (source_hash),
|
||||
KEY idx_translation_memory_target_language (target_language)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE translation_job (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '번역 작업 ID',
|
||||
resource_type VARCHAR(50) NOT NULL COMMENT '번역 대상 리소스 타입',
|
||||
resource_id BIGINT NOT NULL COMMENT '번역 대상 리소스 ID',
|
||||
field_key VARCHAR(80) NOT NULL COMMENT '번역 대상 필드 키',
|
||||
source_hash VARCHAR(64) NOT NULL COMMENT '정규화 원문 SHA-256 해시',
|
||||
source_text TEXT NOT NULL COMMENT '정규화 원문 텍스트',
|
||||
source_language VARCHAR(10) NOT NULL COMMENT '원문 언어 코드',
|
||||
target_language VARCHAR(10) NOT NULL COMMENT '대상 언어 코드',
|
||||
status VARCHAR(20) NOT NULL COMMENT '번역 작업 상태',
|
||||
retry_count INT NOT NULL COMMENT '재시도 횟수',
|
||||
last_error_message TEXT DEFAULT NULL COMMENT '마지막 오류 메시지',
|
||||
next_retry_at TIMESTAMP NOT NULL COMMENT '다음 재시도 가능 시각',
|
||||
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_translation_job_resource_field_target_hash (
|
||||
resource_type,
|
||||
resource_id,
|
||||
field_key,
|
||||
target_language,
|
||||
source_hash
|
||||
),
|
||||
KEY idx_translation_job_status_retry (status, next_retry_at),
|
||||
KEY idx_translation_job_resource (resource_type, resource_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE language_detection_result (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '언어 감지 결과 ID',
|
||||
source_hash VARCHAR(64) NOT NULL COMMENT '정규화 원문 SHA-256 해시',
|
||||
source_text_sample VARCHAR(500) NOT NULL COMMENT '정규화 원문 샘플 텍스트',
|
||||
detected_language VARCHAR(10) NOT NULL COMMENT '감지된 언어 코드',
|
||||
provider VARCHAR(50) NOT NULL COMMENT '언어 감지 provider 이름',
|
||||
confidence DOUBLE DEFAULT NULL COMMENT '언어 감지 신뢰도',
|
||||
normalization_version VARCHAR(20) NOT NULL COMMENT '정규화 규칙 버전',
|
||||
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_language_detection_result_hash_provider_version (
|
||||
source_hash,
|
||||
provider,
|
||||
normalization_version
|
||||
),
|
||||
KEY idx_language_detection_result_source_hash (source_hash)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
18
docs/20260507_번역작업원문언어제한.md
Normal file
18
docs/20260507_번역작업원문언어제한.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# 번역 작업 원문 언어 제한
|
||||
|
||||
## 작업 항목
|
||||
- [x] `TranslationJobSchedulerTest`에 지원하지 않는 `sourceLanguage` 입력 시 `TranslationJob`을 저장하지 않는 RED 테스트를 추가한다.
|
||||
- [x] `TranslationJobScheduler`에서 `sourceLanguage`를 소문자 정규화한 뒤 `ko`, `en`, `ja`가 아니면 등록을 중단한다.
|
||||
- [x] focused test와 관련 검증 명령을 실행해 변경 결과를 확인한다.
|
||||
|
||||
## 설계
|
||||
- `TranslationJob` 등록의 최종 방어선인 `TranslationJobScheduler.scheduleMissingTranslation()`에서 검증한다.
|
||||
- 허용값은 현재 번역 지원 언어와 동일하게 `ko`, `en`, `ja`로 제한한다.
|
||||
- 지원하지 않는 원문 언어는 예외를 던지지 않고 기존 early return 흐름처럼 job 등록만 생략한다.
|
||||
|
||||
## 검증 기록
|
||||
- 2026-05-07: RED 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest.shouldNotCreateJobWhenSourceLanguageIsUnsupported'` 실행 시 `TranslationJobSchedulerTest.kt:102`의 `verifyNoInteractions` 검증 실패로 unsupported `sourceLanguage`가 repository 호출까지 진행됨을 확인했다.
|
||||
- 2026-05-07: GREEN 확인: 동일 focused test가 `BUILD SUCCESSFUL`로 통과해 unsupported `sourceLanguage`가 등록되지 않음을 확인했다.
|
||||
- 2026-05-07: 회귀 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest'`가 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-05-07: 스타일 확인: `./gradlew ktlintCheck`가 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-05-07: Kotlin LSP는 현재 환경에 `.kt` 서버가 설정되어 있지 않아 `lsp_diagnostics` 실행이 불가했다. 대신 Gradle 컴파일 포함 focused/class test와 ktlint로 검증했다.
|
||||
46
docs/20260507_콘텐츠관리자권한및관리자로그인API추가.md
Normal file
46
docs/20260507_콘텐츠관리자권한및관리자로그인API추가.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# 콘텐츠 관리자 권한 및 관리자 로그인 API 추가
|
||||
|
||||
## 작업 항목
|
||||
- [x] `MemberRole`에 콘텐츠 관리자 권한을 추가한다.
|
||||
- [x] 관리자 로그인 API 테스트를 먼저 추가하고 RED를 확인한다.
|
||||
- [x] 관리자와 콘텐츠 관리자만 로그인 가능한 관리자 전용 로그인 API를 구현한다.
|
||||
- [x] 응답을 `token`, `role`만 포함하도록 구현한다.
|
||||
- [x] focused test, 관련 테스트, 스타일 검사를 실행하고 결과를 기록한다.
|
||||
- [x] 콘텐츠 관리자가 `GET /menu`를 호출할 수 있도록 메뉴 조회 권한을 확장한다.
|
||||
- [x] 콘텐츠 관리자가 관리자 콘텐츠 목록/조회 보조 API를 호출할 수 있도록 읽기 권한만 확장한다.
|
||||
- [x] 콘텐츠 관리자가 관리자 콘텐츠 수정 API를 호출할 수 없도록 수정 권한은 관리자 전용으로 유지한다.
|
||||
- [x] focused security test, 관련 테스트, 스타일 검사를 실행하고 결과를 기록한다.
|
||||
|
||||
## 설계
|
||||
- 새 권한은 기존 `MemberRole` enum에 `CONTENT_MANAGER`로 추가한다.
|
||||
- 새 API는 관리자 도메인의 `/admin/member/login`으로 추가하고 인증 없이 호출 가능하도록 보안 설정에 permitAll을 추가한다.
|
||||
- 서비스는 기존 이메일/비밀번호 인증 흐름과 JWT 생성 방식을 재사용하되, `ADMIN`, `CONTENT_MANAGER` 외 역할은 `common.error.bad_credentials` 예외로 거부한다.
|
||||
- 응답 DTO는 관리자 로그인 전용으로 분리해 `token`, `role`만 노출한다.
|
||||
|
||||
## 후속 설계: 콘텐츠 관리자 메뉴 및 콘텐츠 읽기 권한
|
||||
- `CONTENT_MANAGER`는 이미 관리자 로그인 API로 토큰을 받을 수 있으므로 새 역할이나 새 권한 타입을 추가하지 않는다.
|
||||
- 메뉴 조회는 기존 `GET /menu`와 `MenuRepository.getMenu(member.role)` 구조를 그대로 사용한다. 컨트롤러의 `@PreAuthorize`에 `CONTENT_MANAGER`만 추가해 콘텐츠 관리자가 자신의 역할에 매핑된 메뉴를 받을 수 있게 한다.
|
||||
- `/content/list`는 서버 코드 상수가 아니라 `Menu.route` DB 값으로 내려가는 구조이므로, 서버에서는 별도 라우트 상수를 추가하지 않는다. 실제 메뉴 노출은 `roles = CONTENT_MANAGER`, `route = /content/list`, `isActive = true` 데이터가 존재할 때 가능하다.
|
||||
- 관리자 콘텐츠 API는 기존 `hasRole('ADMIN')` 클래스 권한을 메서드 단위로 분리한다. `GET /admin/audio-content/list`, `GET /admin/audio-content/search`, `GET /admin/audio-content/main/tab`은 `ADMIN`과 `CONTENT_MANAGER`를 허용하고, `PUT /admin/audio-content`는 `ADMIN`만 허용한다.
|
||||
- 콘텐츠 재생은 기존 사용자 콘텐츠 API의 `GET /audio-content/{id}/generate-url` 흐름을 변경하지 않는다. 이 API는 로그인 사용자와 구매/접근 조건으로 재생 URL을 제어하므로 콘텐츠 관리자 전용 우회 권한은 추가하지 않는다.
|
||||
|
||||
## 구현 계획
|
||||
- [x] `MenuController` 보안 테스트를 추가해 `CONTENT_MANAGER`가 `GET /menu`에 접근 가능하고 일반 사용자는 거부되는지 확인한다.
|
||||
- [x] `AdminContentController` 보안 테스트를 추가해 `CONTENT_MANAGER`는 목록/검색/메인탭 조회가 가능하고 수정은 거부되는지 확인한다.
|
||||
- [x] 테스트가 실패하는 것을 확인한다.
|
||||
- [x] `MenuController`의 `@PreAuthorize`에 `CONTENT_MANAGER`를 추가한다.
|
||||
- [x] `AdminContentController`의 클래스 단위 `@PreAuthorize`를 제거하고 각 메서드에 읽기/수정 권한을 분리한다.
|
||||
- [x] focused test, 관련 test, ktlint를 실행해 검증한다.
|
||||
|
||||
## 검증 기록
|
||||
- 2026-05-07: RED 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.admin.member.AdminMemberLoginServiceTest' --tests 'kr.co.vividnext.sodalive.admin.member.AdminMemberLoginControllerTest'` 실행 시 `AdminMemberLoginService`, `AdminMemberLoginController`, `AdminMemberLoginResponse`, `CONTENT_MANAGER`, `findByEmail` 미구현으로 `compileTestKotlin`이 실패함을 확인했다.
|
||||
- 2026-05-07: GREEN 확인: 동일 focused test가 `BUILD SUCCESSFUL`로 통과해 관리자/콘텐츠 관리자 로그인 허용 및 일반 사용자 거부를 확인했다.
|
||||
- 2026-05-07: API 응답 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.admin.member.AdminMemberLoginControllerTest.shouldReturnTokenAndRoleJson'`가 `BUILD SUCCESSFUL`로 통과해 `POST /admin/member/login` JSON 응답의 `data.token`, `data.role`을 확인했다.
|
||||
- 2026-05-07: 회귀 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.admin.member.*'`가 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-05-07: 스타일 확인: `./gradlew ktlintCheck`가 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-05-07: Kotlin LSP는 현재 환경에 `.kt` 서버가 설정되어 있지 않아 `lsp_diagnostics` 실행이 불가했다. 대신 Gradle 컴파일 포함 focused/관련 test와 ktlint로 검증했다.
|
||||
- 2026-05-07: RED 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.menu.MenuControllerSecurityTest' --tests 'kr.co.vividnext.sodalive.admin.content.AdminContentControllerSecurityTest'` 실행 시 `CONTENT_MANAGER`의 `GET /menu`, `GET /admin/audio-content/list`, `GET /admin/audio-content/search`, `GET /admin/audio-content/main/tab` 허용 기대 테스트 4건이 실패함을 확인했다.
|
||||
- 2026-05-07: GREEN 확인: 동일 focused security test가 `BUILD SUCCESSFUL`로 통과해 콘텐츠 관리자 메뉴 조회, 관리자 콘텐츠 목록/검색/메인탭 조회 허용과 관리자 콘텐츠 수정 거부를 확인했다.
|
||||
- 2026-05-07: 관련 테스트 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.admin.content.AdminContentServiceTest' --tests 'kr.co.vividnext.sodalive.admin.member.AdminMemberLoginServiceTest' --tests 'kr.co.vividnext.sodalive.admin.member.AdminMemberLoginControllerTest'`가 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-05-07: 스타일 확인: `./gradlew ktlintCheck`가 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-05-07: Kotlin LSP는 현재 환경에 `.kt` 서버가 설정되어 있지 않아 `lsp_diagnostics` 실행이 불가했다. 대신 Gradle 컴파일 포함 focused/관련 test와 ktlint로 검증했다.
|
||||
21
docs/20260508_크리에이터관리자로그아웃AGENT권한허용.md
Normal file
21
docs/20260508_크리에이터관리자로그아웃AGENT권한허용.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# 크리에이터 관리자 로그아웃 AGENT 권한 허용
|
||||
|
||||
## 목표
|
||||
- `/creator-admin/member/logout` 엔드포인트를 `CREATOR`뿐 아니라 `AGENT` 권한 계정도 사용할 수 있게 한다.
|
||||
- 로그인 로직이 이미 `CREATOR`, `AGENT`를 허용하는 기존 정책과 로그아웃 권한 조건을 맞춘다.
|
||||
|
||||
## 구현 항목
|
||||
- [x] `CreatorAdminMemberController`의 `logout` 권한 조건에 `AGENT`를 추가한다.
|
||||
- [x] `logout` 권한 조건이 `CREATOR`, `AGENT`를 모두 포함하는지 테스트로 검증한다.
|
||||
- [x] 변경 파일 진단과 대상 테스트를 실행한다.
|
||||
|
||||
## 검증 계획
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberControllerTest"`
|
||||
- `lsp_diagnostics`로 변경 Kotlin 파일 오류 확인
|
||||
|
||||
## 검증 기록
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberControllerTest"`를 먼저 실행해 `hasRole('CREATOR')` 상태에서 실패함을 확인했다.
|
||||
- `CreatorAdminMemberController.logout`의 `@PreAuthorize`를 `hasAnyRole('CREATOR', 'AGENT')`로 변경했다.
|
||||
- 동일한 대상 테스트를 `--rerun-tasks`로 재실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||
- `lsp_diagnostics`는 Kotlin(`.kt`) LSP 서버가 설정되어 있지 않아 실행할 수 없었다. Gradle 테스트 과정에서 Kotlin 컴파일과 테스트 컴파일은 정상 통과했다.
|
||||
- 테스트 실행 중 기존 파일 `ImageBlurUtil.kt`의 미사용 파라미터 경고가 표시됐으며, 이번 변경 범위와는 무관해 수정하지 않았다.
|
||||
12
docs/agent-guides/문서유지보수.md
Normal file
12
docs/agent-guides/문서유지보수.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# 문서 유지보수
|
||||
|
||||
## 문서 유지보수 규칙
|
||||
- `build.gradle.kts` 변경 시 실행 명령 섹션을 함께 갱신한다.
|
||||
- 테스트 클래스 추가/이동 시 단일 테스트 실행 예시를 최신 상태로 유지한다.
|
||||
- `.editorconfig` 변경 시 포맷 규칙 섹션을 동기화한다.
|
||||
- Cursor/Copilot 규칙 파일이 생기면 해당 내용을 이 문서에 반영한다.
|
||||
- 연속된 하나의 작업에 대해 계획 문서가 여러 개 생기지 않도록 기존 계획 문서 재사용 여부를 먼저 확인한다.
|
||||
- 문서 변경 후 최소 한 번 `./gradlew tasks --all`로 명령 유효성을 확인한다.
|
||||
- 불확실한 규칙은 추측으로 채우지 말고 근거 파일 경로를 먼저 확인한다.
|
||||
- 에이전트 안내 문구는 한국어 중심으로 유지한다.
|
||||
- 커밋 규칙 예시는 팀 컨벤션 변경 시 즉시 업데이트한다.
|
||||
6
docs/agent-guides/설정보안.md
Normal file
6
docs/agent-guides/설정보안.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# 설정 보안
|
||||
|
||||
## 설정/보안 유의사항
|
||||
- `application.yml`은 다수의 `${ENV_VAR}`를 사용한다.
|
||||
- 비밀값(API Key, Secret, Token, DB 비밀번호)을 코드/문서/로그에 평문으로 남기지 않는다.
|
||||
- 환경변수/시크릿 파일은 커밋 대상에서 제외한다.
|
||||
9
docs/agent-guides/작업절차.md
Normal file
9
docs/agent-guides/작업절차.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# 작업 절차
|
||||
|
||||
## 작업 절차 체크리스트
|
||||
- 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다.
|
||||
- 변경 전: 같은 작업의 연속 후속 수정인지 먼저 확인하고, 연속 작업이면 새 계획 문서를 만들지 말고 기존 계획 문서를 갱신한다.
|
||||
- 변경 중: 공개 API 스키마를 임의 변경하지 말고, 작은 단위로 안전하게 수정한다.
|
||||
- 변경 후: 최소 단일 테스트 또는 `./gradlew test`를 실행하고, 필요 시 `./gradlew ktlintCheck`를 수행한다.
|
||||
- 커밋 전/후: `commit-policy` 스킬을 먼저 로드하고, `git commit` 직전과 직후에 `work/scripts/check-commit-message-rules.sh`를 실행해 커밋 메시지 규칙 준수 여부를 확인한다.
|
||||
- 커밋 전/후 확인 시 Sisyphus attribution footer가 없는지 함께 검증한다.
|
||||
59
docs/agent-guides/코드스타일.md
Normal file
59
docs/agent-guides/코드스타일.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# 코드 스타일
|
||||
|
||||
## 코드 스타일 규칙
|
||||
|
||||
### 1) 포맷/기본 규칙
|
||||
- `.editorconfig` 기준을 준수한다.
|
||||
- 인덴트: 공백 4칸.
|
||||
- 줄바꿈: LF.
|
||||
- 최대 라인 길이: 130.
|
||||
- 파일 끝 개행 유지, trailing whitespace 제거.
|
||||
|
||||
### 2) import 규칙
|
||||
- 와일드카드 import(`*`)를 사용하지 않는다.
|
||||
- 사용하지 않는 import를 남기지 않는다.
|
||||
- import alias(`as`)는 현재 코드베이스에서 사용 사례가 없으므로 지양한다.
|
||||
- 기존 파일의 import 정렬/그룹 스타일을 그대로 맞춘다.
|
||||
|
||||
### 3) 네이밍 규칙
|
||||
- 클래스/인터페이스/enum: PascalCase.
|
||||
- 함수/변수/파라미터: camelCase.
|
||||
- 상수: UPPER_SNAKE_CASE (`companion object` 내부 `const val`).
|
||||
- Request/Response DTO는 `...Request`, `...Response` 접미사를 유지한다.
|
||||
- 서비스/컨트롤러/리포지토리 명명은 역할 접미사(`Service`, `Controller`, `Repository`)를 유지한다.
|
||||
|
||||
### 4) 타입/널 처리
|
||||
- Kotlin 타입 시스템을 활용하고 nullable(`?`)를 명시한다.
|
||||
- 불필요한 `Any`/약한 타입을 피하고 구체 타입을 우선한다.
|
||||
- 기존 코드에서 `!!` 사용이 많지만, 신규 코드는 가능한 안전 호출/가드절/명시적 예외로 대체를 우선 고려한다.
|
||||
|
||||
### 5) API/응답 규칙
|
||||
- API 응답은 `ApiResponse.ok(...)`, `ApiResponse.error(...)` 패턴을 따른다.
|
||||
- 컨트롤러는 도메인 예외를 직접 포맷하지 말고 `SodaException`을 던진다.
|
||||
- 인증 사용자 필요 시 `@AuthenticationPrincipal(... ) member: Member?` 패턴 + null 가드절을 사용한다.
|
||||
|
||||
### 6) 예외 처리 규칙
|
||||
- 비즈니스 예외는 `SodaException(messageKey = "...")` 우선 사용.
|
||||
- 사용자 노출 문구는 하드코딩보다 `messageKey` 기반 i18n을 우선한다.
|
||||
- 공통 예외 변환은 `SodaExceptionHandler`에서 수행하므로, 개별 컨트롤러에서 중복 처리하지 않는다.
|
||||
- 예외를 삼키는 빈 `catch` 블록을 금지한다.
|
||||
|
||||
### 7) 트랜잭션 규칙
|
||||
- 서비스 계층에서 `@Transactional`을 사용한다.
|
||||
- 조회 위주 메서드는 `@Transactional(readOnly = true)`를 우선한다.
|
||||
- 쓰기 로직은 메서드 단위 `@Transactional`로 경계를 명확히 한다.
|
||||
|
||||
### 8) 비동기/동시성 규칙
|
||||
- 비동기 처리는 Kotlin Coroutines 패턴을 따른다.
|
||||
- `CoroutineScope(Dispatchers.IO)` + `launch` + 예외 처리 패턴을 일관되게 유지한다.
|
||||
- 생명주기 종료 시 scope 정리(`@PreDestroy`) 패턴을 참고한다.
|
||||
|
||||
### 9) 의존성 주입
|
||||
- 생성자 주입(primary constructor + `private val`)을 기본으로 사용한다.
|
||||
- 필드 주입보다 명시적 생성자 주입을 우선한다.
|
||||
|
||||
### 10) 주석
|
||||
- 의미 단위별로 주석을 작성한다.
|
||||
- 주석은 한 문장으로 간결하게 작성한다.
|
||||
- 주석은 코드의 의도와 구조를 설명한다.
|
||||
- 주석은 코드 변경 시 업데이트를 잊지 않는다.
|
||||
9
docs/agent-guides/테스트스타일.md
Normal file
9
docs/agent-guides/테스트스타일.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# 테스트 스타일
|
||||
|
||||
## 테스트 스타일 규칙
|
||||
- 테스트 프레임워크: JUnit 5 (`useJUnitPlatform()`)
|
||||
- 목킹: Mockito 사용 패턴 존재 (`Mockito.mock`, ``Mockito.`when`(...)``)
|
||||
- 검증: `assertEquals`, `assertThrows` 패턴 준수.
|
||||
- 테스트 이름은 의도가 드러나는 영어 문장형(`should...`)을 유지한다.
|
||||
- 테스트는 DisplayName으로 한국어 설명을 추가한다.
|
||||
- 예외 상황이 있는지 확인하고 예외 상황에 대한 테스트 케이스를 추가한다.
|
||||
@@ -13,10 +13,10 @@ import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
|
||||
@RestController
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequestMapping("/admin/audio-content")
|
||||
class AdminContentController(private val service: AdminContentService) {
|
||||
@GetMapping("/list")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'CONTENT_MANAGER')")
|
||||
fun getAudioContentList(
|
||||
@RequestParam(value = "status", required = false) status: ContentReleaseStatus?,
|
||||
pageable: Pageable
|
||||
@@ -28,6 +28,7 @@ class AdminContentController(private val service: AdminContentService) {
|
||||
)
|
||||
|
||||
@GetMapping("/search")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'CONTENT_MANAGER')")
|
||||
fun searchAudioContent(
|
||||
@RequestParam(value = "status", required = false) status: ContentReleaseStatus?,
|
||||
@RequestParam(value = "search_word") searchWord: String,
|
||||
@@ -41,12 +42,14 @@ class AdminContentController(private val service: AdminContentService) {
|
||||
)
|
||||
|
||||
@PutMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
fun modifyAudioContent(
|
||||
@RequestPart("request") requestString: String,
|
||||
@RequestPart("coverImage", required = false) coverImage: MultipartFile? = null
|
||||
) = ApiResponse.ok(service.updateAudioContent(coverImage, requestString))
|
||||
|
||||
@GetMapping("/main/tab")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'CONTENT_MANAGER')")
|
||||
fun getContentMainTabList() = ApiResponse.ok(service.getContentMainTabList())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package kr.co.vividnext.sodalive.admin.member
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.member.login.LoginRequest
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/member")
|
||||
class AdminMemberLoginController(private val service: AdminMemberLoginService) {
|
||||
@PostMapping("/login")
|
||||
fun login(@RequestBody request: LoginRequest) = ApiResponse.ok(service.login(request))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.admin.member
|
||||
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
|
||||
data class AdminMemberLoginResponse(
|
||||
val token: String,
|
||||
val role: MemberRole
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
package kr.co.vividnext.sodalive.admin.member
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.login.LoginRequest
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class AdminMemberLoginService(
|
||||
private val repository: AdminMemberRepository,
|
||||
private val passwordEncoder: PasswordEncoder,
|
||||
private val tokenProvider: TokenProvider
|
||||
) {
|
||||
fun login(request: LoginRequest): AdminMemberLoginResponse {
|
||||
val member = repository.findByEmail(request.email)
|
||||
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
|
||||
if (member.role != MemberRole.ADMIN && member.role != MemberRole.CONTENT_MANAGER) {
|
||||
throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
}
|
||||
|
||||
if (!member.isActive || !passwordEncoder.matches(request.password, member.password)) {
|
||||
throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
}
|
||||
|
||||
val authentication = UsernamePasswordAuthenticationToken(
|
||||
MemberAdapter(member),
|
||||
null,
|
||||
MemberAdapter(member).authorities
|
||||
)
|
||||
val token = tokenProvider.createToken(authentication = authentication, memberId = member.id!!)
|
||||
|
||||
return AdminMemberLoginResponse(token = token, role = member.role)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.QMember.member
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface AdminMemberRepository : JpaRepository<Member, Long>, AdminMemberQueryRepository
|
||||
interface AdminMemberRepository : JpaRepository<Member, Long>, AdminMemberQueryRepository {
|
||||
fun findByEmail(email: String?): Member?
|
||||
}
|
||||
|
||||
interface AdminMemberQueryRepository {
|
||||
fun getMemberTotalCount(role: MemberRole? = null): Int
|
||||
|
||||
@@ -101,6 +101,10 @@ class AdminMemberService(
|
||||
MemberRole.CREATOR -> messageSource.getMessage("admin.member.role.creator", langContext.lang).orEmpty()
|
||||
MemberRole.AGENT -> messageSource.getMessage("admin.member.role.agent", langContext.lang).orEmpty()
|
||||
MemberRole.BOT -> messageSource.getMessage("admin.member.role.bot", langContext.lang).orEmpty()
|
||||
MemberRole.CONTENT_MANAGER ->
|
||||
messageSource
|
||||
.getMessage("admin.member.role.content_manager", langContext.lang)
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
val loginType = when (it.provider) {
|
||||
|
||||
@@ -17,8 +17,12 @@ class CanController(private val service: CanService) {
|
||||
fun getCans(
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
): ApiResponse<List<CanResponse>> {
|
||||
val isNotSelectedCurrency = member != null && member.id == 2L
|
||||
return ApiResponse.ok(service.getCans(isNotSelectedCurrency = isNotSelectedCurrency))
|
||||
val forcedCurrency = if (member != null && (member.id == 2L || member.id == 4L || member.id == 44144L)) {
|
||||
"JPY"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return ApiResponse.ok(service.getCans(forcedCurrency = forcedCurrency))
|
||||
}
|
||||
|
||||
@GetMapping("/status")
|
||||
|
||||
@@ -14,15 +14,11 @@ class CanService(
|
||||
private val repository: CanRepository,
|
||||
private val countryContext: CountryContext
|
||||
) {
|
||||
fun getCans(isNotSelectedCurrency: Boolean): List<CanResponse> {
|
||||
val currency = if (isNotSelectedCurrency) {
|
||||
null
|
||||
} else {
|
||||
when (countryContext.countryCode) {
|
||||
fun getCans(forcedCurrency: String? = null): List<CanResponse> {
|
||||
val currency = forcedCurrency ?: when (countryContext.countryCode) {
|
||||
"KR" -> "KRW"
|
||||
else -> "USD"
|
||||
}
|
||||
}
|
||||
return repository.findAllByStatusAndCurrency(status = CanStatus.SALE, currency = currency)
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,13 @@ class ChargeService(
|
||||
@Value("\${payverse.usd-secret-key}")
|
||||
private val payverseUsdSecretKey: String,
|
||||
|
||||
@Value("\${payverse.jpy-mid}")
|
||||
private val payverseJpyMid: String,
|
||||
@Value("\${payverse.jpy-client-key}")
|
||||
private val payverseJpyClientKey: String,
|
||||
@Value("\${payverse.jpy-secret-key}")
|
||||
private val payverseJpySecretKey: String,
|
||||
|
||||
@Value("\${payverse.host}")
|
||||
private val payverseHost: String,
|
||||
|
||||
@@ -106,18 +113,18 @@ class ChargeService(
|
||||
return when (charge.payment?.status) {
|
||||
PaymentStatus.REQUEST -> {
|
||||
// 성공 조건 검증
|
||||
val mid = if (request.requestCurrency == "KRW") {
|
||||
payverseMid
|
||||
} else {
|
||||
payverseUsdMid
|
||||
val mid = when (request.requestCurrency) {
|
||||
"KRW" -> payverseMid
|
||||
"JPY" -> payverseJpyMid
|
||||
else -> payverseUsdMid
|
||||
}
|
||||
val expectedSign = DigestUtils.sha512Hex(
|
||||
String.format(
|
||||
"||%s||%s||%s||%s||%s||",
|
||||
if (request.requestCurrency == "KRW") {
|
||||
payverseSecretKey
|
||||
} else {
|
||||
payverseUsdSecretKey
|
||||
when (request.requestCurrency) {
|
||||
"KRW" -> payverseSecretKey
|
||||
"JPY" -> payverseJpySecretKey
|
||||
else -> payverseUsdSecretKey
|
||||
},
|
||||
mid,
|
||||
request.orderId,
|
||||
@@ -126,9 +133,8 @@ class ChargeService(
|
||||
)
|
||||
)
|
||||
|
||||
val isAmountMatch = request.requestAmount.compareTo(
|
||||
charge.payment!!.price
|
||||
) == 0
|
||||
val expectedAmount = computePayverseAmount(charge.payment!!.price, request.requestCurrency)
|
||||
val isAmountMatch = request.requestAmount.compareTo(expectedAmount) == 0
|
||||
|
||||
val isSuccess = request.resultStatus == "SUCCESS" &&
|
||||
request.mid == mid &&
|
||||
@@ -241,21 +247,20 @@ class ChargeService(
|
||||
?: throw SodaException(messageKey = "can.charge.invalid_request_restart")
|
||||
|
||||
val requestCurrency = can.currency
|
||||
val isKrw = requestCurrency == "KRW"
|
||||
val mid = if (isKrw) {
|
||||
payverseMid
|
||||
} else {
|
||||
payverseUsdMid
|
||||
val mid = when (requestCurrency) {
|
||||
"KRW" -> payverseMid
|
||||
"JPY" -> payverseJpyMid
|
||||
else -> payverseUsdMid
|
||||
}
|
||||
val clientKey = if (isKrw) {
|
||||
payverseClientKey
|
||||
} else {
|
||||
payverseUsdClientKey
|
||||
val clientKey = when (requestCurrency) {
|
||||
"KRW" -> payverseClientKey
|
||||
"JPY" -> payverseJpyClientKey
|
||||
else -> payverseUsdClientKey
|
||||
}
|
||||
val secretKey = if (isKrw) {
|
||||
payverseSecretKey
|
||||
} else {
|
||||
payverseUsdSecretKey
|
||||
val secretKey = when (requestCurrency) {
|
||||
"KRW" -> payverseSecretKey
|
||||
"JPY" -> payverseJpySecretKey
|
||||
else -> payverseUsdSecretKey
|
||||
}
|
||||
|
||||
val charge = Charge(can.can, can.rewardCan)
|
||||
@@ -270,12 +275,7 @@ class ChargeService(
|
||||
val savedCharge = chargeRepository.save(charge)
|
||||
|
||||
val chargeId = savedCharge.id!!
|
||||
val amount = BigDecimal(
|
||||
savedCharge.payment!!.price
|
||||
.setScale(4, RoundingMode.HALF_UP)
|
||||
.stripTrailingZeros()
|
||||
.toPlainString()
|
||||
)
|
||||
val amount = computePayverseAmount(savedCharge.payment!!.price, requestCurrency)
|
||||
val reqDate = savedCharge.createdAt!!.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
|
||||
val sign = DigestUtils.sha512Hex(
|
||||
String.format(
|
||||
@@ -312,16 +312,16 @@ class ChargeService(
|
||||
val member = memberRepository.findByIdOrNull(memberId)
|
||||
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
|
||||
val isKrw = charge.can?.currency == "KRW"
|
||||
val mid = if (isKrw) {
|
||||
payverseMid
|
||||
} else {
|
||||
payverseUsdMid
|
||||
val currency = charge.can?.currency
|
||||
val mid = when (currency) {
|
||||
"KRW" -> payverseMid
|
||||
"JPY" -> payverseJpyMid
|
||||
else -> payverseUsdMid
|
||||
}
|
||||
val clientKey = if (isKrw) {
|
||||
payverseClientKey
|
||||
} else {
|
||||
payverseUsdClientKey
|
||||
val clientKey = when (currency) {
|
||||
"KRW" -> payverseClientKey
|
||||
"JPY" -> payverseJpyClientKey
|
||||
else -> payverseUsdClientKey
|
||||
}
|
||||
|
||||
// 결제수단 확인
|
||||
@@ -351,11 +351,12 @@ class ChargeService(
|
||||
val verifyResponse = objectMapper.readValue(body, PayverseVerifyResponse::class.java)
|
||||
|
||||
val customerId = "${serverEnv}_user_${member.id!!}"
|
||||
val expectedAmount = computePayverseAmount(charge.can!!.price, charge.can!!.currency)
|
||||
val isSuccess = verifyResponse.resultStatus == "SUCCESS" &&
|
||||
verifyResponse.transactionStatus == "SUCCESS" &&
|
||||
verifyResponse.orderId.toLongOrNull() == charge.id &&
|
||||
verifyResponse.customerId == customerId &&
|
||||
verifyResponse.requestAmount.compareTo(charge.can!!.price) == 0
|
||||
verifyResponse.requestAmount.compareTo(expectedAmount) == 0
|
||||
|
||||
if (isSuccess) {
|
||||
// verify 함수의 232~248 라인과 동일 처리
|
||||
@@ -737,4 +738,16 @@ class ChargeService(
|
||||
}
|
||||
return messageSource.getMessage("can.charge.payment_method.card", langContext.lang)
|
||||
}
|
||||
|
||||
// Payverse 금액 포맷: 통화별 규칙 적용
|
||||
private fun computePayverseAmount(price: BigDecimal, currency: String): BigDecimal {
|
||||
val scaled = if (currency == "JPY") {
|
||||
// JPY: 강제 정수화, 소수점 버림
|
||||
price.setScale(0, RoundingMode.FLOOR)
|
||||
} else {
|
||||
// 그 외: 4자리까지 반올림 후 불필요 0 제거
|
||||
price.setScale(4, RoundingMode.HALF_UP).stripTrailingZeros()
|
||||
}
|
||||
return BigDecimal(scaled.stripTrailingZeros().toPlainString())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,6 @@ import kr.co.vividnext.sodalive.chat.character.dto.RecentCharacter
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharactersResponse
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslation
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRenderedPayload
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterBackground
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterDetail
|
||||
@@ -24,8 +22,8 @@ import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
@@ -46,7 +44,7 @@ class ChatCharacterController(
|
||||
private val characterCommentService: CharacterCommentService,
|
||||
private val curationQueryService: CharacterCurationQueryService,
|
||||
|
||||
private val translationService: PapagoTranslationService,
|
||||
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
|
||||
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
|
||||
|
||||
private val langContext: LangContext,
|
||||
@@ -212,89 +210,11 @@ class ChatCharacterController(
|
||||
tags = payload.tags
|
||||
)
|
||||
} else {
|
||||
val texts = mutableListOf<String>()
|
||||
texts.add(character.name)
|
||||
texts.add(character.description)
|
||||
texts.add(character.gender ?: "")
|
||||
|
||||
val hasPersonality = personality != null
|
||||
if (hasPersonality) {
|
||||
texts.add(personality!!.trait)
|
||||
texts.add(personality.description)
|
||||
}
|
||||
|
||||
val hasBackground = background != null
|
||||
if (hasBackground) {
|
||||
texts.add(background!!.topic)
|
||||
texts.add(background.description)
|
||||
}
|
||||
|
||||
texts.add(tags)
|
||||
|
||||
val sourceLanguage = character.languageCode ?: "ko"
|
||||
|
||||
val response = translationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = sourceLanguage,
|
||||
resourceTranslationJobScheduler.scheduleResourceTranslation(
|
||||
resourceType = LanguageTranslationTargetType.CHARACTER,
|
||||
resourceId = character.id!!,
|
||||
targetLanguage = langContext.lang.code
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
if (translatedTexts.size == texts.size) {
|
||||
var index = 0
|
||||
|
||||
val translatedName = translatedTexts[index++]
|
||||
val translatedDescription = translatedTexts[index++]
|
||||
val translatedGender = translatedTexts[index++]
|
||||
|
||||
var translatedPersonality: TranslatedAiCharacterPersonality? = null
|
||||
if (hasPersonality) {
|
||||
translatedPersonality = TranslatedAiCharacterPersonality(
|
||||
trait = translatedTexts[index++],
|
||||
description = translatedTexts[index++]
|
||||
)
|
||||
}
|
||||
|
||||
var translatedBackground: TranslatedAiCharacterBackground? = null
|
||||
if (hasBackground) {
|
||||
translatedBackground = TranslatedAiCharacterBackground(
|
||||
topic = translatedTexts[index++],
|
||||
description = translatedTexts[index++]
|
||||
)
|
||||
}
|
||||
|
||||
val translatedTags = translatedTexts[index]
|
||||
|
||||
val payload = AiCharacterTranslationRenderedPayload(
|
||||
name = translatedName,
|
||||
description = translatedDescription,
|
||||
gender = translatedGender,
|
||||
personalityTrait = translatedPersonality?.trait ?: "",
|
||||
personalityDescription = translatedPersonality?.description ?: "",
|
||||
backgroundTopic = translatedBackground?.topic ?: "",
|
||||
backgroundDescription = translatedBackground?.description ?: "",
|
||||
tags = translatedTags
|
||||
)
|
||||
|
||||
val entity = AiCharacterTranslation(
|
||||
characterId = character.id!!,
|
||||
locale = langContext.lang.code,
|
||||
renderedPayload = payload
|
||||
)
|
||||
|
||||
aiCharacterTranslationRepository.save(entity)
|
||||
|
||||
translated = TranslatedAiCharacterDetail(
|
||||
name = translatedName,
|
||||
description = translatedDescription,
|
||||
gender = translatedGender,
|
||||
personality = translatedPersonality,
|
||||
background = translatedBackground,
|
||||
tags = translatedTags
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
package kr.co.vividnext.sodalive.chat.original.service
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslation
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationPayload
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.TranslatedOriginalWork
|
||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
|
||||
import org.slf4j.LoggerFactory
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class OriginalWorkTranslationService(
|
||||
private val translationRepository: OriginalWorkTranslationRepository,
|
||||
private val papagoTranslationService: PapagoTranslationService
|
||||
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler
|
||||
) {
|
||||
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
/**
|
||||
* 원작의 언어와 요청 언어가 다를 때 번역 데이터를 확보하고 반환한다.
|
||||
* - 기존 번역이 있으면 그대로 사용
|
||||
* - 없으면 파파고 번역 수행 후 저장
|
||||
* - 없으면 누락 번역 job 예약 후 null 반환
|
||||
* - 실패/불필요 시 null 반환
|
||||
*/
|
||||
@Transactional
|
||||
@@ -55,70 +49,11 @@ class OriginalWorkTranslationService(
|
||||
}
|
||||
}
|
||||
|
||||
// 파파고 번역 수행
|
||||
return try {
|
||||
val tags = originalWork.tagMappings.map { it.tag.tag }.filter { it.isNotBlank() }
|
||||
val texts = buildList {
|
||||
add(originalWork.title)
|
||||
add(originalWork.contentType)
|
||||
add(originalWork.category)
|
||||
add(originalWork.description)
|
||||
addAll(tags)
|
||||
}
|
||||
|
||||
val response = papagoTranslationService.translate(
|
||||
TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = source,
|
||||
resourceTranslationJobScheduler.scheduleResourceTranslation(
|
||||
resourceType = LanguageTranslationTargetType.ORIGINAL_WORK,
|
||||
resourceId = originalWork.id!!,
|
||||
targetLanguage = target
|
||||
)
|
||||
)
|
||||
|
||||
val out = response.translatedText
|
||||
if (out.isEmpty()) return null
|
||||
|
||||
// 앞 4개는 필드, 나머지는 태그
|
||||
val title = out.getOrNull(0)?.trim().orEmpty()
|
||||
val contentType = out.getOrNull(1)?.trim().orEmpty()
|
||||
val category = out.getOrNull(2)?.trim().orEmpty()
|
||||
val description = out.getOrNull(3)?.trim().orEmpty()
|
||||
val translatedTags = if (out.size > 4) {
|
||||
out.drop(4).map { it.trim() }.filter { it.isNotEmpty() }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val hasAny = title.isNotEmpty() || contentType.isNotEmpty() ||
|
||||
category.isNotEmpty() || description.isNotEmpty() || translatedTags.isNotEmpty()
|
||||
if (!hasAny) return null
|
||||
|
||||
val payload = OriginalWorkTranslationPayload(
|
||||
title = title,
|
||||
contentType = contentType,
|
||||
category = category,
|
||||
description = description,
|
||||
tags = translatedTags
|
||||
)
|
||||
|
||||
val entity = existed?.apply { this.renderedPayload = payload }
|
||||
?: OriginalWorkTranslation(
|
||||
originalWorkId = originalWork.id!!,
|
||||
locale = target,
|
||||
renderedPayload = payload
|
||||
)
|
||||
|
||||
translationRepository.save(entity)
|
||||
|
||||
TranslatedOriginalWork(
|
||||
title = title,
|
||||
contentType = contentType,
|
||||
category = category,
|
||||
description = description,
|
||||
tags = translatedTags
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
log.warn("Failed to translate OriginalWork(id={}) from {} to {}: {}", originalWork.id, source, target, e.message)
|
||||
null
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,9 @@ class ChatRoomQuotaController(
|
||||
) {
|
||||
|
||||
data class PurchaseRoomQuotaRequest(
|
||||
val container: String
|
||||
val container: String,
|
||||
val chargeType: ChatRoomQuotaChargeType = ChatRoomQuotaChargeType.CAN,
|
||||
val canOption: ChatRoomQuotaCanOption? = null
|
||||
)
|
||||
|
||||
data class PurchaseRoomQuotaResponse(
|
||||
@@ -45,8 +47,9 @@ class ChatRoomQuotaController(
|
||||
/**
|
||||
* 채팅방 유료 쿼터 구매 API
|
||||
* - 참여 여부 검증(내가 USER로 참여 중인 활성 방)
|
||||
* - 30캔 결제 (UseCan에 chatRoomId:characterId 기록)
|
||||
* - 방 유료 쿼터 40 충전
|
||||
* - 요청 DTO로 캔 충전 / 광고 충전을 구분
|
||||
* - 캔 충전은 옵션별 캔 차감 후 방 유료 쿼터 지급
|
||||
* - 광고 충전은 캔 차감 없이 방 유료 쿼터 5 지급
|
||||
*/
|
||||
@PostMapping("/{chatRoomId}/quota/purchase")
|
||||
fun purchaseRoomQuota(
|
||||
@@ -74,13 +77,28 @@ class ChatRoomQuotaController(
|
||||
val characterId = character.id
|
||||
?: throw SodaException(messageKey = "chat.room.quota.character_required")
|
||||
|
||||
val status = chatRoomQuotaService.purchase(
|
||||
val chargeType = req.chargeType
|
||||
val status = when (chargeType) {
|
||||
ChatRoomQuotaChargeType.CAN -> {
|
||||
val canOption = req.canOption ?: ChatRoomQuotaCanOption.CAN_10
|
||||
|
||||
chatRoomQuotaService.purchaseWithCan(
|
||||
memberId = member.id!!,
|
||||
chatRoomId = chatRoomId,
|
||||
characterId = characterId,
|
||||
addPaid = 12,
|
||||
canOption = canOption,
|
||||
container = req.container
|
||||
)
|
||||
}
|
||||
|
||||
ChatRoomQuotaChargeType.AD -> {
|
||||
chatRoomQuotaService.purchaseWithAd(
|
||||
memberId = member.id!!,
|
||||
chatRoomId = chatRoomId,
|
||||
characterId = characterId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ApiResponse.ok(
|
||||
PurchaseRoomQuotaResponse(
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package kr.co.vividnext.sodalive.chat.quota.room
|
||||
|
||||
enum class ChatRoomQuotaChargeType {
|
||||
CAN,
|
||||
AD
|
||||
}
|
||||
|
||||
enum class ChatRoomQuotaCanOption(
|
||||
val needCan: Int,
|
||||
val quota: Int
|
||||
) {
|
||||
CAN_10(10, 15),
|
||||
CAN_20(20, 40)
|
||||
}
|
||||
@@ -13,6 +13,10 @@ class ChatRoomQuotaService(
|
||||
private val repo: ChatRoomQuotaRepository,
|
||||
private val canPaymentService: CanPaymentService
|
||||
) {
|
||||
companion object {
|
||||
private const val AD_REWARD_QUOTA = 5
|
||||
}
|
||||
|
||||
data class RoomQuotaStatus(
|
||||
val totalRemaining: Int,
|
||||
val nextRechargeAtEpochMillis: Long?,
|
||||
@@ -122,23 +126,50 @@ class ChatRoomQuotaService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun purchase(
|
||||
fun purchaseWithCan(
|
||||
memberId: Long,
|
||||
chatRoomId: Long,
|
||||
characterId: Long,
|
||||
addPaid: Int = 12,
|
||||
canOption: ChatRoomQuotaCanOption,
|
||||
container: String
|
||||
): RoomQuotaStatus {
|
||||
// 요구사항: 10캔 결제 및 UseCan에 방/캐릭터 기록
|
||||
canPaymentService.spendCan(
|
||||
memberId = memberId,
|
||||
needCan = 10,
|
||||
needCan = canOption.needCan,
|
||||
canUsage = CanUsage.CHAT_QUOTA_PURCHASE,
|
||||
chatRoomId = chatRoomId,
|
||||
characterId = characterId,
|
||||
container = container
|
||||
)
|
||||
|
||||
return addPaidQuota(
|
||||
memberId = memberId,
|
||||
chatRoomId = chatRoomId,
|
||||
characterId = characterId,
|
||||
addPaid = canOption.quota
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun purchaseWithAd(
|
||||
memberId: Long,
|
||||
chatRoomId: Long,
|
||||
characterId: Long
|
||||
): RoomQuotaStatus {
|
||||
return addPaidQuota(
|
||||
memberId = memberId,
|
||||
chatRoomId = chatRoomId,
|
||||
characterId = characterId,
|
||||
addPaid = AD_REWARD_QUOTA
|
||||
)
|
||||
}
|
||||
|
||||
private fun addPaidQuota(
|
||||
memberId: Long,
|
||||
chatRoomId: Long,
|
||||
characterId: Long,
|
||||
addPaid: Int
|
||||
): RoomQuotaStatus {
|
||||
val quota = repo.findForUpdate(memberId, chatRoomId) ?: repo.save(
|
||||
ChatRoomQuota(
|
||||
memberId = memberId,
|
||||
|
||||
@@ -74,6 +74,7 @@ class SecurityConfig(
|
||||
.antMatchers("/member/login/kakao").permitAll()
|
||||
.antMatchers("/member/login/apple").permitAll()
|
||||
.antMatchers("/member/login/line").permitAll()
|
||||
.antMatchers("/admin/member/login").permitAll()
|
||||
.antMatchers("/creator-admin/member/login").permitAll()
|
||||
.antMatchers("/member/forgot-password").permitAll()
|
||||
.antMatchers("/stplat/terms_of_service").permitAll()
|
||||
|
||||
@@ -22,8 +22,6 @@ import kr.co.vividnext.sodalive.content.pin.PinContent
|
||||
import kr.co.vividnext.sodalive.content.pin.PinContentRepository
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
|
||||
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.content.translation.TranslatedContent
|
||||
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
||||
@@ -36,8 +34,7 @@ import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||
@@ -70,7 +67,7 @@ class AudioContentService(
|
||||
private val audioContentLikeRepository: AudioContentLikeRepository,
|
||||
private val pinContentRepository: PinContentRepository,
|
||||
|
||||
private val translationService: PapagoTranslationService,
|
||||
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
|
||||
private val contentTranslationRepository: ContentTranslationRepository,
|
||||
|
||||
private val s3Uploader: S3Uploader,
|
||||
@@ -770,7 +767,7 @@ class AudioContentService(
|
||||
* TranslatedContent로 가공한다
|
||||
*
|
||||
* 번역 콘텐츠가 없으면
|
||||
* 파파고 API를 통해 번역한 후 저장한다.
|
||||
* 누락 번역 job을 예약하고 원문 응답을 유지한다.
|
||||
*
|
||||
* 번역 대상: title, detail, tags
|
||||
*
|
||||
@@ -792,49 +789,11 @@ class AudioContentService(
|
||||
tags = payload.tags
|
||||
)
|
||||
} else {
|
||||
val texts = mutableListOf<String>()
|
||||
texts.add(audioContent.title)
|
||||
texts.add(audioContent.detail)
|
||||
texts.add(tag)
|
||||
|
||||
val sourceLanguage = audioContent.languageCode ?: "ko"
|
||||
|
||||
val response = translationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = sourceLanguage,
|
||||
resourceTranslationJobScheduler.scheduleResourceTranslation(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT,
|
||||
resourceId = audioContent.id!!,
|
||||
targetLanguage = langContext.lang.code
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
if (translatedTexts.size == texts.size) {
|
||||
var index = 0
|
||||
|
||||
val translatedTitle = translatedTexts[index++]
|
||||
val translatedDetail = translatedTexts[index++]
|
||||
val translatedTags = translatedTexts[index]
|
||||
|
||||
val payload = ContentTranslationPayload(
|
||||
title = translatedTitle,
|
||||
detail = translatedDetail,
|
||||
tags = translatedTags
|
||||
)
|
||||
|
||||
contentTranslationRepository.save(
|
||||
ContentTranslation(
|
||||
contentId = audioContent.id!!,
|
||||
locale = langContext.lang.code,
|
||||
renderedPayload = payload
|
||||
)
|
||||
)
|
||||
|
||||
translated = TranslatedContent(
|
||||
title = translatedTitle,
|
||||
detail = translatedDetail,
|
||||
tags = translatedTags
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ class LanguageDetectListener(
|
||||
private val seriesRepository: ContentSeriesRepository,
|
||||
private val originalWorkRepository: OriginalWorkRepository,
|
||||
private val categoryRepository: CategoryRepository,
|
||||
private val languageDetectionCacheService: LanguageDetectionCacheService,
|
||||
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
|
||||
@@ -116,7 +117,7 @@ class LanguageDetectListener(
|
||||
return
|
||||
}
|
||||
|
||||
val langCode = requestPapagoLanguageCode(event.query, characterId) ?: return
|
||||
val langCode = detectLanguageCode(event, characterId) ?: return
|
||||
|
||||
character.languageCode = langCode
|
||||
chatCharacterRepository.save(character)
|
||||
@@ -154,7 +155,7 @@ class LanguageDetectListener(
|
||||
return
|
||||
}
|
||||
|
||||
val langCode = requestPapagoLanguageCode(event.query, contentId) ?: return
|
||||
val langCode = detectLanguageCode(event, contentId) ?: return
|
||||
|
||||
audioContent.languageCode = langCode
|
||||
|
||||
@@ -194,7 +195,7 @@ class LanguageDetectListener(
|
||||
return
|
||||
}
|
||||
|
||||
val langCode = requestPapagoLanguageCode(event.query, commentId) ?: return
|
||||
val langCode = detectLanguageCode(event, commentId) ?: return
|
||||
|
||||
comment.languageCode = langCode
|
||||
audioContentCommentRepository.save(comment)
|
||||
@@ -226,7 +227,7 @@ class LanguageDetectListener(
|
||||
return
|
||||
}
|
||||
|
||||
val langCode = requestPapagoLanguageCode(event.query, commentId) ?: return
|
||||
val langCode = detectLanguageCode(event, commentId) ?: return
|
||||
|
||||
comment.languageCode = langCode
|
||||
characterCommentRepository.save(comment)
|
||||
@@ -257,7 +258,7 @@ class LanguageDetectListener(
|
||||
return
|
||||
}
|
||||
|
||||
val langCode = requestPapagoLanguageCode(event.query, cheersId) ?: return
|
||||
val langCode = detectLanguageCode(event, cheersId) ?: return
|
||||
|
||||
cheers.languageCode = langCode
|
||||
creatorCheersRepository.save(cheers)
|
||||
@@ -288,7 +289,7 @@ class LanguageDetectListener(
|
||||
return
|
||||
}
|
||||
|
||||
val langCode = requestPapagoLanguageCode(event.query, seriesId) ?: return
|
||||
val langCode = detectLanguageCode(event, seriesId) ?: return
|
||||
|
||||
series.languageCode = langCode
|
||||
seriesRepository.save(series)
|
||||
@@ -326,7 +327,7 @@ class LanguageDetectListener(
|
||||
return
|
||||
}
|
||||
|
||||
val langCode = requestPapagoLanguageCode(event.query, originalWorkId) ?: return
|
||||
val langCode = detectLanguageCode(event, originalWorkId) ?: return
|
||||
|
||||
originalWork.languageCode = langCode
|
||||
originalWorkRepository.save(originalWork)
|
||||
@@ -352,7 +353,7 @@ class LanguageDetectListener(
|
||||
val category = categoryRepository.findByIdOrNull(categoryId) ?: return
|
||||
if (!category.languageCode.isNullOrBlank()) return
|
||||
|
||||
val langCode = requestPapagoLanguageCode(event.query, categoryId) ?: return
|
||||
val langCode = detectLanguageCode(event, categoryId) ?: return
|
||||
|
||||
category.languageCode = langCode
|
||||
categoryRepository.save(category)
|
||||
@@ -365,6 +366,12 @@ class LanguageDetectListener(
|
||||
)
|
||||
}
|
||||
|
||||
private fun detectLanguageCode(event: LanguageDetectEvent, targetIdForLog: Long): String? {
|
||||
return languageDetectionCacheService.detectWithCache(event.query) {
|
||||
requestPapagoLanguageCode(event.query, targetIdForLog)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestPapagoLanguageCode(query: String, targetIdForLog: Long): String? {
|
||||
return try {
|
||||
val headers = HttpHeaders().apply {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package kr.co.vividnext.sodalive.content
|
||||
|
||||
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class LanguageDetectionCacheService(
|
||||
private val languageDetectionResultRepository: LanguageDetectionResultRepository
|
||||
) {
|
||||
@Transactional
|
||||
fun detectWithCache(
|
||||
query: String,
|
||||
provider: String = DEFAULT_PROVIDER,
|
||||
detector: () -> String?
|
||||
): String? {
|
||||
val normalizedQuery = SourceTextNormalizer.normalize(query)
|
||||
if (normalizedQuery.isBlank()) return null
|
||||
|
||||
val sourceHash = SourceTextNormalizer.hash(normalizedQuery)
|
||||
val cached = languageDetectionResultRepository.findBySourceHashAndProviderAndNormalizationVersion(
|
||||
sourceHash = sourceHash,
|
||||
provider = provider,
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
if (cached != null) return cached.detectedLanguage
|
||||
|
||||
val detectedLanguage = detector()?.takeIf { it.isNotBlank() } ?: return null
|
||||
languageDetectionResultRepository.save(
|
||||
LanguageDetectionResult(
|
||||
sourceHash = sourceHash,
|
||||
sourceTextSample = normalizedQuery.take(MAX_SAMPLE_LENGTH),
|
||||
detectedLanguage = detectedLanguage.lowercase(),
|
||||
provider = provider,
|
||||
confidence = null,
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
)
|
||||
return detectedLanguage.lowercase()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_PROVIDER = "papago"
|
||||
private const val MAX_SAMPLE_LENGTH = 500
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package kr.co.vividnext.sodalive.content
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "language_detection_result",
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(
|
||||
name = "uk_language_detection_result_hash_provider_version",
|
||||
columnNames = ["source_hash", "provider", "normalization_version"]
|
||||
)
|
||||
]
|
||||
)
|
||||
class LanguageDetectionResult(
|
||||
@Column(name = "source_hash", nullable = false, length = 64)
|
||||
val sourceHash: String,
|
||||
|
||||
@Column(name = "source_text_sample", nullable = false, length = 500)
|
||||
val sourceTextSample: String,
|
||||
|
||||
@Column(name = "detected_language", nullable = false, length = 10)
|
||||
val detectedLanguage: String,
|
||||
|
||||
@Column(name = "provider", nullable = false, length = 50)
|
||||
val provider: String,
|
||||
|
||||
@Column(name = "confidence")
|
||||
val confidence: Double? = null,
|
||||
|
||||
@Column(name = "normalization_version", nullable = false, length = 20)
|
||||
val normalizationVersion: String = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
) : BaseEntity()
|
||||
@@ -0,0 +1,11 @@
|
||||
package kr.co.vividnext.sodalive.content
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface LanguageDetectionResultRepository : JpaRepository<LanguageDetectionResult, Long> {
|
||||
fun findBySourceHashAndProviderAndNormalizationVersion(
|
||||
sourceHash: String,
|
||||
provider: String,
|
||||
normalizationVersion: String
|
||||
): LanguageDetectionResult?
|
||||
}
|
||||
@@ -7,8 +7,7 @@ import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
@@ -25,7 +24,7 @@ class CategoryService(
|
||||
|
||||
private val langContext: LangContext,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
private val translationService: PapagoTranslationService
|
||||
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler
|
||||
) {
|
||||
@Transactional
|
||||
fun createCategory(request: CreateCategoryRequest, member: Member) {
|
||||
@@ -148,7 +147,7 @@ class CategoryService(
|
||||
.findByCategoryIdInAndLocale(categoryIds, locale)
|
||||
.associateBy { it.categoryId }
|
||||
|
||||
// 각 항목에 대해 번역 적용. 없으면 Papago로 번역 저장 후 적용
|
||||
// 각 항목에 대해 번역 적용. 없으면 누락 번역 job만 예약하고 원문을 반환한다.
|
||||
val result = mutableListOf<GetCategoryListResponse>()
|
||||
for (item in baseList) {
|
||||
val entity = entityMap[item.categoryId]
|
||||
@@ -165,38 +164,11 @@ class CategoryService(
|
||||
continue
|
||||
}
|
||||
|
||||
// 번역본이 없으면 Papago 번역 후 저장
|
||||
val texts = listOf(entity.title)
|
||||
val response = translationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = sourceLang,
|
||||
resourceTranslationJobScheduler.scheduleResourceTranslation(
|
||||
resourceType = LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY,
|
||||
resourceId = entity.id!!,
|
||||
targetLanguage = locale
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
if (translatedTexts.size == texts.size) {
|
||||
val translatedCategory = translatedTexts[0]
|
||||
|
||||
val existingOne = categoryTranslationRepository
|
||||
.findByCategoryIdAndLocale(entity.id!!, locale)
|
||||
if (existingOne == null) {
|
||||
categoryTranslationRepository.save(
|
||||
CategoryTranslation(
|
||||
categoryId = entity.id!!,
|
||||
locale = locale,
|
||||
category = translatedCategory
|
||||
)
|
||||
)
|
||||
} else {
|
||||
existingOne.category = translatedCategory
|
||||
categoryTranslationRepository.save(existingOne)
|
||||
}
|
||||
|
||||
result.add(GetCategoryListResponse(categoryId = item.categoryId, category = translatedCategory))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 번역이 필요 없거나 실패한 경우 원본 사용
|
||||
|
||||
@@ -7,8 +7,6 @@ import kr.co.vividnext.sodalive.content.order.OrderType
|
||||
import kr.co.vividnext.sodalive.content.series.content.ContentSeriesContentRepository
|
||||
import kr.co.vividnext.sodalive.content.series.content.GetSeriesContentListResponse
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.series.translation.TranslatedSeries
|
||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||
@@ -17,8 +15,8 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesSortType
|
||||
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
||||
import kr.co.vividnext.sodalive.i18n.Lang
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||
@@ -41,7 +39,7 @@ class ContentSeriesService(
|
||||
private val seriesTranslationRepository: SeriesTranslationRepository,
|
||||
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
|
||||
private val contentTranslationRepository: ContentTranslationRepository,
|
||||
private val translationService: PapagoTranslationService,
|
||||
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val coverImageHost: String
|
||||
@@ -91,7 +89,7 @@ class ContentSeriesService(
|
||||
fun getGenreList(memberId: Long, isAdult: Boolean, contentType: ContentType): List<GetSeriesGenreListResponse> {
|
||||
/**
|
||||
* langContext.lang == Lang.EN || Lang.JA 일 때 번역된 콘텐츠 테마 반환
|
||||
* 번역이 없으면 번역 API 호출 후 저장하고 반환
|
||||
* 번역이 없으면 누락 번역 job만 예약하고 원문을 반환
|
||||
*/
|
||||
val genres = repository.getGenreList(memberId = memberId, isAdult = isAdult, contentType = contentType)
|
||||
|
||||
@@ -120,32 +118,12 @@ class ContentSeriesService(
|
||||
|
||||
// 미번역 항목 수집
|
||||
val untranslated = genres.filter { existingMap[it.id] == null }
|
||||
if (untranslated.isNotEmpty()) {
|
||||
val texts = untranslated.map { it.genre }
|
||||
val response = translationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = "ko",
|
||||
untranslated.forEach { item ->
|
||||
resourceTranslationJobScheduler.scheduleResourceTranslation(
|
||||
resourceType = LanguageTranslationTargetType.SERIES_GENRE,
|
||||
resourceId = item.id,
|
||||
targetLanguage = targetLocale
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
val toSave = mutableListOf<kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslation>()
|
||||
untranslated.forEachIndexed { index, item ->
|
||||
val translated = translatedTexts.getOrNull(index) ?: item.genre
|
||||
toSave.add(
|
||||
kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslation(
|
||||
seriesGenreId = item.id,
|
||||
locale = targetLocale,
|
||||
genre = translated
|
||||
)
|
||||
)
|
||||
}
|
||||
if (toSave.isNotEmpty()) {
|
||||
seriesGenreTranslationRepository.saveAll(toSave)
|
||||
toSave.forEach { saved -> existingMap[saved.seriesGenreId] = saved }
|
||||
}
|
||||
}
|
||||
|
||||
// 원래 순서 보존하여 결과 조립
|
||||
@@ -283,7 +261,7 @@ class ContentSeriesService(
|
||||
* TranslatedSeries로 가공한다
|
||||
*
|
||||
* 번역 콘텐츠가 없으면
|
||||
* 파파고 API를 통해 번역한 후 저장한다.
|
||||
* 누락 번역 job을 예약하고 원문 응답을 유지한다.
|
||||
*
|
||||
* 번역 대상: title, introduction, keywordList
|
||||
*
|
||||
@@ -309,54 +287,11 @@ class ContentSeriesService(
|
||||
keywords = kws
|
||||
)
|
||||
} else {
|
||||
val texts = mutableListOf<String>()
|
||||
texts.add(series.title)
|
||||
texts.add(series.introduction)
|
||||
// 키워드는 개별 항목으로 번역 요청하여 N회 호출을 방지한다.
|
||||
val keywordListForTranslate = keywordList
|
||||
texts.addAll(keywordListForTranslate)
|
||||
|
||||
val response = translationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = languageCode,
|
||||
resourceTranslationJobScheduler.scheduleResourceTranslation(
|
||||
resourceType = LanguageTranslationTargetType.SERIES,
|
||||
resourceId = seriesId,
|
||||
targetLanguage = locale
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
if (translatedTexts.size == texts.size) {
|
||||
var index = 0
|
||||
val translatedTitle = translatedTexts[index++]
|
||||
val translatedIntroduction = translatedTexts[index++]
|
||||
val translatedKeywords = if (keywordListForTranslate.isNotEmpty()) {
|
||||
translatedTexts.subList(index, translatedTexts.size)
|
||||
} else {
|
||||
// 번역할 키워드가 없으면 원본 키워드 반환 정책 적용
|
||||
keywordList
|
||||
}
|
||||
|
||||
val payload = SeriesTranslationPayload(
|
||||
title = translatedTitle,
|
||||
introduction = translatedIntroduction,
|
||||
keywords = translatedKeywords
|
||||
)
|
||||
|
||||
seriesTranslationRepository.save(
|
||||
SeriesTranslation(
|
||||
seriesId = seriesId,
|
||||
locale = locale,
|
||||
renderedPayload = payload
|
||||
)
|
||||
)
|
||||
|
||||
val kws = translatedKeywords.ifEmpty { keywordList }
|
||||
translated = TranslatedSeries(
|
||||
title = translatedTitle,
|
||||
introduction = translatedIntroduction,
|
||||
keywords = kws
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,11 @@ import kr.co.vividnext.sodalive.content.AudioContentRepository
|
||||
import kr.co.vividnext.sodalive.content.ContentType
|
||||
import kr.co.vividnext.sodalive.content.SortType
|
||||
import kr.co.vividnext.sodalive.content.theme.content.GetContentByThemeResponse
|
||||
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslation
|
||||
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
|
||||
import kr.co.vividnext.sodalive.i18n.Lang
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -22,7 +21,7 @@ class AudioContentThemeService(
|
||||
private val contentRepository: AudioContentRepository,
|
||||
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
||||
|
||||
private val papagoTranslationService: PapagoTranslationService,
|
||||
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
|
||||
private val langContext: LangContext
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
@@ -51,7 +50,7 @@ class AudioContentThemeService(
|
||||
|
||||
/**
|
||||
* langContext.lang == Lang.EN || Lang.JA 일 때 번역된 콘텐츠 테마 반환
|
||||
* 번역이 없으면 번역 API 호출 후 저장하고 반환
|
||||
* 번역이 없으면 누락 번역 job만 예약하고 원문을 반환
|
||||
*/
|
||||
val currentLang = langContext.lang
|
||||
if (currentLang == Lang.EN || currentLang == Lang.JA) {
|
||||
@@ -66,43 +65,14 @@ class AudioContentThemeService(
|
||||
|
||||
val existingMap = existingTranslations.associateBy { it.contentThemeId }
|
||||
|
||||
// 2) 미번역 항목만 수집하여 한 번에 번역 요청
|
||||
// 2) 미번역 항목은 조회 스레드에서 번역하지 않고 job만 예약
|
||||
val untranslatedPairs = themesWithIds.filter { existingMap[it.id] == null }
|
||||
|
||||
if (untranslatedPairs.isNotEmpty()) {
|
||||
val texts = untranslatedPairs.map { it.theme }
|
||||
|
||||
val response = papagoTranslationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = "ko",
|
||||
untranslatedPairs.forEach { pair ->
|
||||
resourceTranslationJobScheduler.scheduleResourceTranslation(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT_THEME,
|
||||
resourceId = pair.id,
|
||||
targetLanguage = targetLocale
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
val entitiesToSave = mutableListOf<ContentThemeTranslation>()
|
||||
|
||||
// translatedTexts 크기가 다르면 안전하게 원문으로 대체
|
||||
untranslatedPairs.forEachIndexed { index, pair ->
|
||||
val translated = translatedTexts.getOrNull(index) ?: pair.theme
|
||||
entitiesToSave.add(
|
||||
ContentThemeTranslation(
|
||||
contentThemeId = pair.id,
|
||||
locale = targetLocale,
|
||||
theme = translated
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (entitiesToSave.isNotEmpty()) {
|
||||
contentThemeTranslationRepository.saveAll(entitiesToSave)
|
||||
}
|
||||
|
||||
// 저장 후 맵을 갱신
|
||||
entitiesToSave.forEach { entity ->
|
||||
(existingMap as MutableMap)[entity.contentThemeId] = entity
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 원래 순서대로 결과 조립 (번역 없으면 원문 fallback)
|
||||
|
||||
@@ -19,7 +19,7 @@ class CreatorAdminMemberController(private val service: CreatorAdminMemberServic
|
||||
fun login(@RequestBody loginRequest: LoginRequest) = service.login(loginRequest)
|
||||
|
||||
@PostMapping("/logout")
|
||||
@PreAuthorize("hasRole('CREATOR')")
|
||||
@PreAuthorize("hasAnyRole('CREATOR', 'AGENT')")
|
||||
fun logout(
|
||||
@RequestHeader("Authorization") token: String,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
|
||||
@@ -1044,6 +1044,11 @@ class SodaMessageSource {
|
||||
Lang.KO to "봇",
|
||||
Lang.EN to "Bot",
|
||||
Lang.JA to "ボット"
|
||||
),
|
||||
"admin.member.role.content_manager" to mapOf(
|
||||
Lang.KO to "콘텐츠 관리자",
|
||||
Lang.EN to "Content Manager",
|
||||
Lang.JA to "コンテンツ管理者"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,35 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.i18n.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.AiCharacterTranslation
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRenderedPayload
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterBackground
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterPersonality
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslation
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationPayload
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.AudioContentRepository
|
||||
import kr.co.vividnext.sodalive.content.category.CategoryRepository
|
||||
import kr.co.vividnext.sodalive.content.category.CategoryTranslation
|
||||
import kr.co.vividnext.sodalive.content.category.CategoryTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslation
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload
|
||||
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.ContentThemeTranslation
|
||||
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
|
||||
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.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.scheduling.annotation.Async
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
@@ -58,24 +29,7 @@ class LanguageTranslationEvent(
|
||||
|
||||
@Component
|
||||
class LanguageTranslationListener(
|
||||
private val audioContentRepository: AudioContentRepository,
|
||||
private val chatCharacterRepository: ChatCharacterRepository,
|
||||
private val audioContentThemeRepository: AudioContentThemeQueryRepository,
|
||||
private val seriesRepository: AdminContentSeriesRepository,
|
||||
private val seriesGenreRepository: AdminContentSeriesGenreRepository,
|
||||
private val originalWorkRepository: OriginalWorkRepository,
|
||||
|
||||
private val contentTranslationRepository: ContentTranslationRepository,
|
||||
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
|
||||
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
||||
private val seriesTranslationRepository: SeriesTranslationRepository,
|
||||
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
|
||||
private val originalWorkTranslationRepository: OriginalWorkTranslationRepository,
|
||||
|
||||
private val categoryRepository: CategoryRepository,
|
||||
private val categoryTranslationRepository: CategoryTranslationRepository,
|
||||
|
||||
private val translationService: PapagoTranslationService
|
||||
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler
|
||||
) {
|
||||
@Async
|
||||
@EventListener(condition = "!#event.waitTransactionCommit")
|
||||
@@ -92,424 +46,6 @@ class LanguageTranslationListener(
|
||||
}
|
||||
|
||||
private fun processTranslation(event: LanguageTranslationEvent) {
|
||||
when (event.targetType) {
|
||||
LanguageTranslationTargetType.CONTENT -> handleContentLanguageTranslation(event)
|
||||
LanguageTranslationTargetType.CHARACTER -> handleCharacterLanguageTranslation(event)
|
||||
LanguageTranslationTargetType.CONTENT_THEME -> handleContentThemeLanguageTranslation(event)
|
||||
LanguageTranslationTargetType.SERIES -> handleSeriesLanguageTranslation(event)
|
||||
LanguageTranslationTargetType.SERIES_GENRE -> handleSeriesGenreLanguageTranslation(event)
|
||||
LanguageTranslationTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageTranslation(event)
|
||||
LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY -> handleCreatorContentCategoryLanguageTranslation(event)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleContentLanguageTranslation(event: LanguageTranslationEvent) {
|
||||
val audioContent = audioContentRepository.findByIdOrNull(event.id) ?: return
|
||||
val languageCode = audioContent.languageCode ?: return
|
||||
|
||||
getTranslatableLanguageCodes(languageCode).forEach { locale ->
|
||||
val tags = audioContent.audioContentHashTags
|
||||
.mapNotNull { it.hashTag?.tag }
|
||||
.joinToString(",")
|
||||
|
||||
val texts = mutableListOf<String>()
|
||||
texts.add(audioContent.title)
|
||||
texts.add(audioContent.detail)
|
||||
texts.add(tags)
|
||||
|
||||
val sourceLanguage = audioContent.languageCode ?: "ko"
|
||||
|
||||
val response = translationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = sourceLanguage,
|
||||
targetLanguage = locale
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
if (translatedTexts.size == texts.size) {
|
||||
var index = 0
|
||||
|
||||
val translatedTitle = translatedTexts[index++]
|
||||
val translatedDetail = translatedTexts[index++]
|
||||
val translatedTags = translatedTexts[index]
|
||||
|
||||
val payload = ContentTranslationPayload(
|
||||
title = translatedTitle,
|
||||
detail = translatedDetail,
|
||||
tags = translatedTags
|
||||
)
|
||||
|
||||
val existing = contentTranslationRepository
|
||||
.findByContentIdAndLocale(audioContent.id!!, locale)
|
||||
|
||||
if (existing == null) {
|
||||
contentTranslationRepository.save(
|
||||
ContentTranslation(
|
||||
contentId = audioContent.id!!,
|
||||
locale = locale,
|
||||
renderedPayload = payload
|
||||
)
|
||||
)
|
||||
} else {
|
||||
existing.renderedPayload = payload
|
||||
contentTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCharacterLanguageTranslation(event: LanguageTranslationEvent) {
|
||||
val character = chatCharacterRepository.findByIdOrNull(event.id) ?: return
|
||||
val languageCode = character.languageCode ?: return
|
||||
|
||||
getTranslatableLanguageCodes(languageCode).forEach { locale ->
|
||||
val personality = character.personalities.firstOrNull()
|
||||
val background = character.backgrounds.firstOrNull()
|
||||
|
||||
val tags = character.tagMappings.joinToString(",") { it.tag.tag }
|
||||
|
||||
val texts = mutableListOf<String>()
|
||||
texts.add(character.name)
|
||||
texts.add(character.description)
|
||||
texts.add(character.gender ?: "")
|
||||
|
||||
val hasPersonality = personality != null
|
||||
if (hasPersonality) {
|
||||
texts.add(personality!!.trait)
|
||||
texts.add(personality.description)
|
||||
}
|
||||
|
||||
val hasBackground = background != null
|
||||
if (hasBackground) {
|
||||
texts.add(background!!.topic)
|
||||
texts.add(background.description)
|
||||
}
|
||||
|
||||
texts.add(tags)
|
||||
|
||||
val sourceLanguage = character.languageCode ?: "ko"
|
||||
|
||||
val response = translationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = sourceLanguage,
|
||||
targetLanguage = locale
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
if (translatedTexts.size == texts.size) {
|
||||
var index = 0
|
||||
|
||||
val translatedName = translatedTexts[index++]
|
||||
val translatedDescription = translatedTexts[index++]
|
||||
val translatedGender = translatedTexts[index++]
|
||||
|
||||
var translatedPersonality: TranslatedAiCharacterPersonality? = null
|
||||
if (hasPersonality) {
|
||||
translatedPersonality = TranslatedAiCharacterPersonality(
|
||||
trait = translatedTexts[index++],
|
||||
description = translatedTexts[index++]
|
||||
)
|
||||
}
|
||||
|
||||
var translatedBackground: TranslatedAiCharacterBackground? = null
|
||||
if (hasBackground) {
|
||||
translatedBackground = TranslatedAiCharacterBackground(
|
||||
topic = translatedTexts[index++],
|
||||
description = translatedTexts[index++]
|
||||
)
|
||||
}
|
||||
|
||||
val translatedTags = translatedTexts[index]
|
||||
|
||||
val payload = AiCharacterTranslationRenderedPayload(
|
||||
name = translatedName,
|
||||
description = translatedDescription,
|
||||
gender = translatedGender,
|
||||
personalityTrait = translatedPersonality?.trait ?: "",
|
||||
personalityDescription = translatedPersonality?.description ?: "",
|
||||
backgroundTopic = translatedBackground?.topic ?: "",
|
||||
backgroundDescription = translatedBackground?.description ?: "",
|
||||
tags = translatedTags
|
||||
)
|
||||
|
||||
val existing = aiCharacterTranslationRepository
|
||||
.findByCharacterIdAndLocale(character.id!!, locale)
|
||||
|
||||
if (existing == null) {
|
||||
val entity = AiCharacterTranslation(
|
||||
characterId = character.id!!,
|
||||
locale = locale,
|
||||
renderedPayload = payload
|
||||
)
|
||||
|
||||
aiCharacterTranslationRepository.save(entity)
|
||||
} else {
|
||||
existing.renderedPayload = payload
|
||||
aiCharacterTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleContentThemeLanguageTranslation(event: LanguageTranslationEvent) {
|
||||
val contentTheme = audioContentThemeRepository.findThemeByIdAndActive(event.id) ?: return
|
||||
|
||||
val sourceLanguage = "ko"
|
||||
getTranslatableLanguageCodes(sourceLanguage).forEach { locale ->
|
||||
val texts = mutableListOf<String>()
|
||||
texts.add(contentTheme.theme)
|
||||
|
||||
val response = translationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = sourceLanguage,
|
||||
targetLanguage = locale
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
if (translatedTexts.size == texts.size) {
|
||||
val translatedTheme = translatedTexts[0]
|
||||
|
||||
val existing = contentThemeTranslationRepository
|
||||
.findByContentThemeIdAndLocale(contentTheme.id!!, locale)
|
||||
|
||||
if (existing == null) {
|
||||
contentThemeTranslationRepository.save(
|
||||
ContentThemeTranslation(
|
||||
contentThemeId = contentTheme.id!!,
|
||||
locale = locale,
|
||||
theme = translatedTheme
|
||||
)
|
||||
)
|
||||
} else {
|
||||
existing.theme = translatedTheme
|
||||
contentThemeTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSeriesLanguageTranslation(event: LanguageTranslationEvent) {
|
||||
val series = seriesRepository.findByIdOrNull(event.id) ?: return
|
||||
val languageCode = series.languageCode ?: return
|
||||
|
||||
getTranslatableLanguageCodes(languageCode).forEach { locale ->
|
||||
val keywords = series.keywordList
|
||||
.mapNotNull { it.keyword?.tag }
|
||||
.joinToString(", ")
|
||||
val texts = mutableListOf<String>()
|
||||
texts.add(series.title)
|
||||
texts.add(series.introduction)
|
||||
texts.add(keywords)
|
||||
|
||||
val sourceLanguage = series.languageCode ?: "ko"
|
||||
|
||||
val response = translationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = sourceLanguage,
|
||||
targetLanguage = locale
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
if (translatedTexts.size == texts.size) {
|
||||
var index = 0
|
||||
val translatedTitle = translatedTexts[index++]
|
||||
val translatedIntroduction = translatedTexts[index++]
|
||||
val translatedKeywordsJoined = translatedTexts[index]
|
||||
|
||||
val translatedKeywords = translatedKeywordsJoined
|
||||
.split(",")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
|
||||
val payload = SeriesTranslationPayload(
|
||||
title = translatedTitle,
|
||||
introduction = translatedIntroduction,
|
||||
keywords = translatedKeywords
|
||||
)
|
||||
|
||||
val existing = seriesTranslationRepository
|
||||
.findBySeriesIdAndLocale(series.id!!, locale)
|
||||
|
||||
if (existing == null) {
|
||||
seriesTranslationRepository.save(
|
||||
SeriesTranslation(
|
||||
seriesId = series.id!!,
|
||||
locale = locale,
|
||||
renderedPayload = payload
|
||||
)
|
||||
)
|
||||
} else {
|
||||
existing.renderedPayload = payload
|
||||
seriesTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSeriesGenreLanguageTranslation(event: LanguageTranslationEvent) {
|
||||
val seriesGenre = seriesGenreRepository.findActiveSeriesGenreById(event.id) ?: return
|
||||
|
||||
val sourceLanguage = "ko"
|
||||
getTranslatableLanguageCodes(sourceLanguage).forEach { locale ->
|
||||
val texts = mutableListOf<String>()
|
||||
texts.add(seriesGenre.genre)
|
||||
|
||||
val response = translationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = sourceLanguage,
|
||||
targetLanguage = locale
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
if (translatedTexts.size == texts.size) {
|
||||
val translatedGenre = translatedTexts[0]
|
||||
|
||||
val existing = seriesGenreTranslationRepository
|
||||
.findBySeriesGenreIdAndLocale(seriesGenre.id!!, locale)
|
||||
|
||||
if (existing == null) {
|
||||
seriesGenreTranslationRepository.save(
|
||||
SeriesGenreTranslation(
|
||||
seriesGenreId = seriesGenre.id!!,
|
||||
locale = locale,
|
||||
genre = translatedGenre
|
||||
)
|
||||
)
|
||||
} else {
|
||||
existing.genre = translatedGenre
|
||||
seriesGenreTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleOriginalWorkLanguageTranslation(event: LanguageTranslationEvent) {
|
||||
val originalWork = originalWorkRepository.findByIdOrNull(event.id) ?: return
|
||||
val languageCode = originalWork.languageCode ?: return
|
||||
|
||||
/**
|
||||
* handleSeriesLanguageTranslation 참조하여 원작 번역 구현
|
||||
*
|
||||
* originalWorkTranslationRepository
|
||||
*
|
||||
* 번역대상
|
||||
* - title
|
||||
* - contentType
|
||||
* - category
|
||||
* - description
|
||||
* - tags
|
||||
*/
|
||||
getTranslatableLanguageCodes(languageCode).forEach { locale ->
|
||||
val tagsJoined = originalWork.tagMappings
|
||||
.mapNotNull { it.tag.tag }
|
||||
.joinToString(", ")
|
||||
|
||||
val texts = mutableListOf<String>()
|
||||
texts.add(originalWork.title)
|
||||
texts.add(originalWork.contentType)
|
||||
texts.add(originalWork.category)
|
||||
texts.add(originalWork.description)
|
||||
texts.add(tagsJoined)
|
||||
|
||||
val sourceLanguage = originalWork.languageCode ?: "ko"
|
||||
|
||||
val response = translationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = sourceLanguage,
|
||||
targetLanguage = locale
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
if (translatedTexts.size == texts.size) {
|
||||
var index = 0
|
||||
val translatedTitle = translatedTexts[index++]
|
||||
val translatedContentType = translatedTexts[index++]
|
||||
val translatedCategory = translatedTexts[index++]
|
||||
val translatedDescription = translatedTexts[index++]
|
||||
val translatedTagsJoined = translatedTexts[index]
|
||||
|
||||
val translatedTags = translatedTagsJoined
|
||||
.split(",")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
|
||||
val payload = OriginalWorkTranslationPayload(
|
||||
title = translatedTitle,
|
||||
contentType = translatedContentType,
|
||||
category = translatedCategory,
|
||||
description = translatedDescription,
|
||||
tags = translatedTags
|
||||
)
|
||||
|
||||
val existing = originalWorkTranslationRepository
|
||||
.findByOriginalWorkIdAndLocale(originalWork.id!!, locale)
|
||||
|
||||
if (existing == null) {
|
||||
originalWorkTranslationRepository.save(
|
||||
OriginalWorkTranslation(
|
||||
originalWorkId = originalWork.id!!,
|
||||
locale = locale,
|
||||
renderedPayload = payload
|
||||
)
|
||||
)
|
||||
} else {
|
||||
existing.renderedPayload = payload
|
||||
originalWorkTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCreatorContentCategoryLanguageTranslation(event: LanguageTranslationEvent) {
|
||||
val category = categoryRepository.findByIdOrNull(event.id)
|
||||
|
||||
if (category == null || !category.isActive || category.languageCode.isNullOrBlank()) return
|
||||
|
||||
val sourceLanguage = category.languageCode ?: "ko"
|
||||
getTranslatableLanguageCodes(sourceLanguage).forEach { locale ->
|
||||
val texts = mutableListOf<String>()
|
||||
texts.add(category.title)
|
||||
|
||||
val response = translationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = sourceLanguage,
|
||||
targetLanguage = locale
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
if (translatedTexts.size == texts.size) {
|
||||
val translatedCategory = translatedTexts[0]
|
||||
|
||||
val existing = categoryTranslationRepository
|
||||
.findByCategoryIdAndLocale(category.id!!, locale)
|
||||
|
||||
if (existing == null) {
|
||||
categoryTranslationRepository.save(
|
||||
CategoryTranslation(
|
||||
categoryId = category.id!!,
|
||||
locale = locale,
|
||||
category = translatedCategory
|
||||
)
|
||||
)
|
||||
} else {
|
||||
existing.category = translatedCategory
|
||||
categoryTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
}
|
||||
resourceTranslationJobScheduler.scheduleResourceTranslations(event.targetType, event.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,12 +15,16 @@ class PapagoTranslationService(
|
||||
|
||||
@Value("\${cloud.naver.papago-client-secret}")
|
||||
private val papagoClientSecret: String
|
||||
) {
|
||||
) : TranslationProvider {
|
||||
private val restTemplate: RestTemplate = RestTemplate()
|
||||
|
||||
private val papagoTranslateUrl = "https://papago.apigw.ntruss.com/nmt/v1/translation"
|
||||
|
||||
fun translate(request: TranslateRequest): TranslateResult {
|
||||
override val providerName: String = "papago"
|
||||
|
||||
override val providerVersion: String = "nmt-v1"
|
||||
|
||||
override fun translate(request: TranslateRequest): TranslateResult {
|
||||
if (request.texts.isEmpty() || request.sourceLanguage == request.targetLanguage) {
|
||||
return TranslateResult(emptyList())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class ResourceTranslationJobScheduler(
|
||||
private val sourceExtractor: TranslationSourceExtractor,
|
||||
private val translationJobScheduler: TranslationJobScheduler
|
||||
) {
|
||||
fun scheduleResourceTranslations(resourceType: LanguageTranslationTargetType, resourceId: Long) {
|
||||
val source = sourceExtractor.extract(resourceType, resourceId) ?: return
|
||||
getTranslatableLanguageCodes(source.sourceLanguage).forEach { targetLanguage ->
|
||||
scheduleSource(source, targetLanguage)
|
||||
}
|
||||
}
|
||||
|
||||
fun scheduleResourceTranslation(
|
||||
resourceType: LanguageTranslationTargetType,
|
||||
resourceId: Long,
|
||||
targetLanguage: String
|
||||
) {
|
||||
val source = sourceExtractor.extract(resourceType, resourceId) ?: return
|
||||
scheduleSource(source, targetLanguage)
|
||||
}
|
||||
|
||||
private fun scheduleSource(source: TranslationSource, targetLanguage: String) {
|
||||
source.fields.forEach { field ->
|
||||
translationJobScheduler.scheduleMissingTranslation(
|
||||
resourceType = source.resourceType,
|
||||
resourceId = source.resourceId,
|
||||
fieldKey = field.fieldKey,
|
||||
sourceText = field.sourceText,
|
||||
sourceLanguage = source.sourceLanguage,
|
||||
targetLanguage = targetLanguage
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import java.security.MessageDigest
|
||||
import java.text.Normalizer
|
||||
|
||||
object SourceTextNormalizer {
|
||||
const val NORMALIZATION_VERSION = "v1"
|
||||
|
||||
private val whitespaceRegex = Regex("\\s+")
|
||||
|
||||
fun normalize(sourceText: String): String {
|
||||
return Normalizer.normalize(sourceText, Normalizer.Form.NFC)
|
||||
.replace(whitespaceRegex, " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
fun hash(sourceText: String): String {
|
||||
val normalized = normalize(sourceText)
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(normalized.toByteArray(Charsets.UTF_8))
|
||||
return digest.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.EnumType
|
||||
import javax.persistence.Enumerated
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
enum class TranslationJobStatus {
|
||||
PENDING,
|
||||
RUNNING,
|
||||
COMPLETED,
|
||||
FAILED
|
||||
}
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "translation_job",
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(
|
||||
name = "uk_translation_job_resource_field_target_hash",
|
||||
columnNames = ["resource_type", "resource_id", "field_key", "target_language", "source_hash"]
|
||||
)
|
||||
]
|
||||
)
|
||||
class TranslationJob(
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "resource_type", nullable = false, length = 50)
|
||||
val resourceType: LanguageTranslationTargetType,
|
||||
|
||||
@Column(name = "resource_id", nullable = false)
|
||||
val resourceId: Long,
|
||||
|
||||
@Column(name = "field_key", nullable = false, length = 80)
|
||||
val fieldKey: String,
|
||||
|
||||
@Column(name = "source_hash", nullable = false, length = 64)
|
||||
val sourceHash: String,
|
||||
|
||||
@Column(name = "source_text", nullable = false, columnDefinition = "text")
|
||||
val sourceText: String,
|
||||
|
||||
@Column(name = "source_language", nullable = false, length = 10)
|
||||
val sourceLanguage: String,
|
||||
|
||||
@Column(name = "target_language", nullable = false, length = 10)
|
||||
val targetLanguage: String,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
var status: TranslationJobStatus = TranslationJobStatus.PENDING,
|
||||
|
||||
@Column(name = "retry_count", nullable = false)
|
||||
var retryCount: Int = 0,
|
||||
|
||||
@Column(name = "last_error_message", columnDefinition = "text")
|
||||
var lastErrorMessage: String? = null,
|
||||
|
||||
@Column(name = "next_retry_at", nullable = false)
|
||||
var nextRetryAt: LocalDateTime = LocalDateTime.now()
|
||||
) : BaseEntity()
|
||||
@@ -0,0 +1,58 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface TranslationJobRepository : JpaRepository<TranslationJob, Long> {
|
||||
fun findByResourceTypeAndResourceIdAndFieldKeyAndTargetLanguageAndSourceHash(
|
||||
resourceType: LanguageTranslationTargetType,
|
||||
resourceId: Long,
|
||||
fieldKey: String,
|
||||
targetLanguage: String,
|
||||
sourceHash: String
|
||||
): TranslationJob?
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select j from TranslationJob j
|
||||
where j.resourceType = :resourceType
|
||||
and j.resourceId = :resourceId
|
||||
and j.fieldKey = :fieldKey
|
||||
and j.targetLanguage = :targetLanguage
|
||||
and j.sourceHash = :sourceHash
|
||||
and j.status in (kr.co.vividnext.sodalive.i18n.translation.TranslationJobStatus.PENDING, kr.co.vividnext.sodalive.i18n.translation.TranslationJobStatus.RUNNING)
|
||||
"""
|
||||
)
|
||||
fun findActiveJob(
|
||||
@Param("resourceType") resourceType: LanguageTranslationTargetType,
|
||||
@Param("resourceId") resourceId: Long,
|
||||
@Param("fieldKey") fieldKey: String,
|
||||
@Param("targetLanguage") targetLanguage: String,
|
||||
@Param("sourceHash") sourceHash: String
|
||||
): TranslationJob?
|
||||
|
||||
@Query(
|
||||
value = """
|
||||
select j.id
|
||||
from translation_job j
|
||||
join (
|
||||
select resource_type, resource_id, target_language
|
||||
from translation_job
|
||||
where status = 'PENDING'
|
||||
and next_retry_at <= :now
|
||||
order by created_at asc
|
||||
limit 1
|
||||
) g on j.resource_type = g.resource_type
|
||||
and j.resource_id = g.resource_id
|
||||
and j.target_language = g.target_language
|
||||
where j.status = 'PENDING'
|
||||
and j.next_retry_at <= :now
|
||||
order by j.created_at asc
|
||||
for update skip locked
|
||||
""",
|
||||
nativeQuery = true
|
||||
)
|
||||
fun findNextPendingGroupJobIdsForUpdate(@Param("now") now: LocalDateTime): List<Long>
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Service
|
||||
class TranslationJobScheduler(
|
||||
private val translationJobRepository: TranslationJobRepository
|
||||
) {
|
||||
@Transactional
|
||||
fun scheduleMissingTranslation(
|
||||
resourceType: LanguageTranslationTargetType,
|
||||
resourceId: Long,
|
||||
fieldKey: String,
|
||||
sourceText: String,
|
||||
sourceLanguage: String,
|
||||
targetLanguage: String
|
||||
) {
|
||||
val normalizedText = SourceTextNormalizer.normalize(sourceText)
|
||||
if (normalizedText.isBlank()) return
|
||||
|
||||
val normalizedSourceLanguage = sourceLanguage.lowercase()
|
||||
if (!SUPPORTED_SOURCE_LANGUAGE_CODES.contains(normalizedSourceLanguage)) return
|
||||
|
||||
val normalizedTargetLanguage = targetLanguage.lowercase()
|
||||
if (normalizedSourceLanguage == normalizedTargetLanguage) return
|
||||
|
||||
val sourceHash = SourceTextNormalizer.hash(normalizedText)
|
||||
val existingJob = translationJobRepository.findByResourceTypeAndResourceIdAndFieldKeyAndTargetLanguageAndSourceHash(
|
||||
resourceType = resourceType,
|
||||
resourceId = resourceId,
|
||||
fieldKey = fieldKey,
|
||||
targetLanguage = normalizedTargetLanguage,
|
||||
sourceHash = sourceHash
|
||||
)
|
||||
if (existingJob != null) return
|
||||
|
||||
translationJobRepository.save(
|
||||
TranslationJob(
|
||||
resourceType = resourceType,
|
||||
resourceId = resourceId,
|
||||
fieldKey = fieldKey,
|
||||
sourceHash = sourceHash,
|
||||
sourceText = normalizedText,
|
||||
sourceLanguage = normalizedSourceLanguage,
|
||||
targetLanguage = normalizedTargetLanguage,
|
||||
nextRetryAt = LocalDateTime.now()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val SUPPORTED_SOURCE_LANGUAGE_CODES = setOf("ko", "en", "ja")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Component
|
||||
class TranslationJobWorker(
|
||||
private val translationJobRepository: TranslationJobRepository,
|
||||
private val translationMemoryRepository: TranslationMemoryRepository,
|
||||
private val translationProvider: TranslationProvider,
|
||||
private val materializer: TranslationReadModelMaterializer,
|
||||
transactionManager: PlatformTransactionManager
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
private val transactionTemplate = TransactionTemplate(transactionManager)
|
||||
|
||||
@Scheduled(fixedDelayString = "\${sodalive.translation-job.fixed-delay-ms:600000}")
|
||||
fun runPendingJobs() {
|
||||
repeat(MAX_GROUPS_PER_TICK) {
|
||||
if (!processNextGroup()) return
|
||||
}
|
||||
}
|
||||
|
||||
fun processNextGroup(): Boolean {
|
||||
val jobs = claimNextGroup()
|
||||
if (jobs.isEmpty()) return false
|
||||
|
||||
val firstJob = jobs.first()
|
||||
val succeededJobs = mutableListOf<TranslationJob>()
|
||||
val failedJobs = mutableListOf<Pair<TranslationJob, Exception>>()
|
||||
jobs.forEach { job ->
|
||||
try {
|
||||
ensureMemory(job)
|
||||
succeededJobs.add(job)
|
||||
} catch (ex: Exception) {
|
||||
failedJobs.add(job to ex)
|
||||
}
|
||||
}
|
||||
|
||||
if (failedJobs.isNotEmpty()) {
|
||||
succeededJobs.forEach { completeJob(it.id!!) }
|
||||
failedJobs.forEach { (job, ex) -> failJob(job.id!!, ex) }
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
materializer.materialize(firstJob.resourceType, firstJob.resourceId, firstJob.targetLanguage)
|
||||
succeededJobs.forEach { completeJob(it.id!!) }
|
||||
} catch (ex: Exception) {
|
||||
succeededJobs.forEach { failJob(it.id!!, ex) }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun claimNextGroup(): List<TranslationJob> {
|
||||
return transactionTemplate.execute {
|
||||
val jobIds = translationJobRepository.findNextPendingGroupJobIdsForUpdate(LocalDateTime.now())
|
||||
jobIds.mapNotNull { jobId ->
|
||||
val job = translationJobRepository.findById(jobId).orElse(null) ?: return@mapNotNull null
|
||||
job.status = TranslationJobStatus.RUNNING
|
||||
translationJobRepository.save(job)
|
||||
job
|
||||
}
|
||||
}.orEmpty()
|
||||
}
|
||||
|
||||
private fun ensureMemory(job: TranslationJob) {
|
||||
val existing = translationMemoryRepository
|
||||
.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
|
||||
sourceHash = job.sourceHash,
|
||||
sourceLanguage = job.sourceLanguage,
|
||||
targetLanguage = job.targetLanguage,
|
||||
provider = translationProvider.providerName,
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
if (existing != null) return
|
||||
|
||||
val response = translationProvider.translate(
|
||||
TranslateRequest(
|
||||
texts = listOf(job.sourceText),
|
||||
sourceLanguage = job.sourceLanguage,
|
||||
targetLanguage = job.targetLanguage
|
||||
)
|
||||
)
|
||||
val translated = response.translatedText.firstOrNull()?.takeIf { it.isNotBlank() }
|
||||
?: throw IllegalStateException("empty translation result")
|
||||
|
||||
transactionTemplate.executeWithoutResult {
|
||||
val memory = translationMemoryRepository
|
||||
.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
|
||||
sourceHash = job.sourceHash,
|
||||
sourceLanguage = job.sourceLanguage,
|
||||
targetLanguage = job.targetLanguage,
|
||||
provider = translationProvider.providerName,
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
if (memory == null) {
|
||||
translationMemoryRepository.save(
|
||||
TranslationMemory(
|
||||
sourceHash = job.sourceHash,
|
||||
sourceText = job.sourceText,
|
||||
sourceLanguage = job.sourceLanguage,
|
||||
targetLanguage = job.targetLanguage,
|
||||
translatedText = translated,
|
||||
provider = translationProvider.providerName,
|
||||
providerVersion = translationProvider.providerVersion
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun completeJob(jobId: Long) {
|
||||
transactionTemplate.executeWithoutResult {
|
||||
val job = translationJobRepository.findById(jobId).orElse(null) ?: return@executeWithoutResult
|
||||
job.status = TranslationJobStatus.COMPLETED
|
||||
job.lastErrorMessage = null
|
||||
translationJobRepository.save(job)
|
||||
}
|
||||
}
|
||||
|
||||
private fun failJob(jobId: Long, ex: Exception) {
|
||||
log.warn("Failed to process translation job. jobId={}, error={}", jobId, ex.message)
|
||||
transactionTemplate.executeWithoutResult {
|
||||
val job = translationJobRepository.findById(jobId).orElse(null) ?: return@executeWithoutResult
|
||||
job.retryCount += 1
|
||||
job.lastErrorMessage = ex.message?.take(MAX_ERROR_LENGTH)
|
||||
if (job.retryCount >= MAX_RETRY_COUNT) {
|
||||
job.status = TranslationJobStatus.FAILED
|
||||
} else {
|
||||
job.status = TranslationJobStatus.PENDING
|
||||
job.nextRetryAt = LocalDateTime.now().plusMinutes(backoffMinutes(job.retryCount))
|
||||
}
|
||||
translationJobRepository.save(job)
|
||||
}
|
||||
}
|
||||
|
||||
private fun backoffMinutes(retryCount: Int): Long {
|
||||
return when (retryCount) {
|
||||
1 -> 1L
|
||||
2 -> 5L
|
||||
else -> 15L
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_GROUPS_PER_TICK = 5
|
||||
private const val MAX_ERROR_LENGTH = 1000
|
||||
private const val MAX_RETRY_COUNT = 3
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
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 = "translation_memory",
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(
|
||||
name = "uk_translation_memory_source_target_provider",
|
||||
columnNames = ["source_hash", "source_language", "target_language", "provider", "normalization_version"]
|
||||
)
|
||||
]
|
||||
)
|
||||
class TranslationMemory(
|
||||
@Column(name = "source_hash", nullable = false, length = 64)
|
||||
val sourceHash: String,
|
||||
|
||||
@Column(name = "source_text", nullable = false, columnDefinition = "text")
|
||||
val sourceText: String,
|
||||
|
||||
@Column(name = "source_language", nullable = false, length = 10)
|
||||
val sourceLanguage: String,
|
||||
|
||||
@Column(name = "target_language", nullable = false, length = 10)
|
||||
val targetLanguage: String,
|
||||
|
||||
@Column(name = "translated_text", nullable = false, columnDefinition = "text")
|
||||
val translatedText: String,
|
||||
|
||||
@Column(name = "provider", nullable = false, length = 50)
|
||||
val provider: String,
|
||||
|
||||
@Column(name = "provider_version", nullable = false, length = 50)
|
||||
val providerVersion: String,
|
||||
|
||||
@Column(name = "normalization_version", nullable = false, length = 20)
|
||||
val normalizationVersion: String = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
) : BaseEntity()
|
||||
@@ -0,0 +1,13 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface TranslationMemoryRepository : JpaRepository<TranslationMemory, Long> {
|
||||
fun findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
|
||||
sourceHash: String,
|
||||
sourceLanguage: String,
|
||||
targetLanguage: String,
|
||||
provider: String,
|
||||
normalizationVersion: String
|
||||
): TranslationMemory?
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
interface TranslationProvider {
|
||||
val providerName: String
|
||||
val providerVersion: String
|
||||
|
||||
fun translate(request: TranslateRequest): TranslateResult
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslation
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRenderedPayload
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslation
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationPayload
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.category.CategoryTranslation
|
||||
import kr.co.vividnext.sodalive.content.category.CategoryTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslation
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslation
|
||||
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
|
||||
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 org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class TranslationReadModelMaterializer(
|
||||
private val sourceExtractor: TranslationSourceExtractor,
|
||||
private val translationMemoryRepository: TranslationMemoryRepository,
|
||||
private val contentTranslationRepository: ContentTranslationRepository,
|
||||
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
|
||||
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
||||
private val seriesTranslationRepository: SeriesTranslationRepository,
|
||||
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
|
||||
private val originalWorkTranslationRepository: OriginalWorkTranslationRepository,
|
||||
private val categoryTranslationRepository: CategoryTranslationRepository
|
||||
) {
|
||||
@Transactional
|
||||
fun materialize(resourceType: LanguageTranslationTargetType, resourceId: Long, targetLanguage: String): Boolean {
|
||||
val source = sourceExtractor.extract(resourceType, resourceId) ?: return false
|
||||
val translations = resolveTranslatedFields(source, targetLanguage.lowercase()) ?: return false
|
||||
|
||||
when (resourceType) {
|
||||
LanguageTranslationTargetType.CONTENT -> upsertContent(resourceId, targetLanguage, translations)
|
||||
LanguageTranslationTargetType.CHARACTER -> upsertCharacter(resourceId, targetLanguage, translations)
|
||||
LanguageTranslationTargetType.CONTENT_THEME -> upsertContentTheme(resourceId, targetLanguage, translations)
|
||||
LanguageTranslationTargetType.SERIES -> upsertSeries(resourceId, targetLanguage, translations)
|
||||
LanguageTranslationTargetType.SERIES_GENRE -> upsertSeriesGenre(resourceId, targetLanguage, translations)
|
||||
LanguageTranslationTargetType.ORIGINAL_WORK -> upsertOriginalWork(resourceId, targetLanguage, translations)
|
||||
LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY -> upsertCategory(resourceId, targetLanguage, translations)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun resolveTranslatedFields(source: TranslationSource, targetLanguage: String): Map<String, String>? {
|
||||
val result = mutableMapOf<String, String>()
|
||||
source.fields.forEach { field ->
|
||||
val normalizedText = SourceTextNormalizer.normalize(field.sourceText)
|
||||
if (normalizedText.isBlank()) {
|
||||
result[field.fieldKey] = ""
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val memory = translationMemoryRepository
|
||||
.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
|
||||
sourceHash = SourceTextNormalizer.hash(normalizedText),
|
||||
sourceLanguage = source.sourceLanguage.lowercase(),
|
||||
targetLanguage = targetLanguage,
|
||||
provider = DEFAULT_PROVIDER,
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
) ?: return null
|
||||
|
||||
result[field.fieldKey] = memory.translatedText
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun upsertContent(resourceId: Long, targetLanguage: String, translations: Map<String, String>) {
|
||||
val payload = ContentTranslationPayload(
|
||||
title = translations["title"].orEmpty(),
|
||||
detail = translations["detail"].orEmpty(),
|
||||
tags = translations["tags"].orEmpty()
|
||||
)
|
||||
val existing = contentTranslationRepository.findByContentIdAndLocale(resourceId, targetLanguage)
|
||||
if (existing == null) {
|
||||
contentTranslationRepository.save(ContentTranslation(resourceId, targetLanguage, payload))
|
||||
} else {
|
||||
existing.renderedPayload = payload
|
||||
contentTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertCharacter(resourceId: Long, targetLanguage: String, translations: Map<String, String>) {
|
||||
val payload = AiCharacterTranslationRenderedPayload(
|
||||
name = translations["name"].orEmpty(),
|
||||
description = translations["description"].orEmpty(),
|
||||
gender = translations["gender"].orEmpty(),
|
||||
personalityTrait = translations["personalityTrait"].orEmpty(),
|
||||
personalityDescription = translations["personalityDescription"].orEmpty(),
|
||||
backgroundTopic = translations["backgroundTopic"].orEmpty(),
|
||||
backgroundDescription = translations["backgroundDescription"].orEmpty(),
|
||||
tags = translations["tags"].orEmpty()
|
||||
)
|
||||
val existing = aiCharacterTranslationRepository.findByCharacterIdAndLocale(resourceId, targetLanguage)
|
||||
if (existing == null) {
|
||||
aiCharacterTranslationRepository.save(AiCharacterTranslation(resourceId, targetLanguage, payload))
|
||||
} else {
|
||||
existing.renderedPayload = payload
|
||||
aiCharacterTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertContentTheme(resourceId: Long, targetLanguage: String, translations: Map<String, String>) {
|
||||
val theme = translations["theme"].orEmpty()
|
||||
val existing = contentThemeTranslationRepository.findByContentThemeIdAndLocale(resourceId, targetLanguage)
|
||||
if (existing == null) {
|
||||
contentThemeTranslationRepository.save(ContentThemeTranslation(resourceId, targetLanguage, theme))
|
||||
} else {
|
||||
existing.theme = theme
|
||||
contentThemeTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertSeries(resourceId: Long, targetLanguage: String, translations: Map<String, String>) {
|
||||
val payload = SeriesTranslationPayload(
|
||||
title = translations["title"].orEmpty(),
|
||||
introduction = translations["introduction"].orEmpty(),
|
||||
keywords = translations["keywords"].orEmpty()
|
||||
.split(",")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
)
|
||||
val existing = seriesTranslationRepository.findBySeriesIdAndLocale(resourceId, targetLanguage)
|
||||
if (existing == null) {
|
||||
seriesTranslationRepository.save(SeriesTranslation(resourceId, targetLanguage, payload))
|
||||
} else {
|
||||
existing.renderedPayload = payload
|
||||
seriesTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertSeriesGenre(resourceId: Long, targetLanguage: String, translations: Map<String, String>) {
|
||||
val genre = translations["genre"].orEmpty()
|
||||
val existing = seriesGenreTranslationRepository.findBySeriesGenreIdAndLocale(resourceId, targetLanguage)
|
||||
if (existing == null) {
|
||||
seriesGenreTranslationRepository.save(SeriesGenreTranslation(resourceId, targetLanguage, genre))
|
||||
} else {
|
||||
existing.genre = genre
|
||||
seriesGenreTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertOriginalWork(resourceId: Long, targetLanguage: String, translations: Map<String, String>) {
|
||||
val payload = OriginalWorkTranslationPayload(
|
||||
title = translations["title"].orEmpty(),
|
||||
contentType = translations["contentType"].orEmpty(),
|
||||
category = translations["category"].orEmpty(),
|
||||
description = translations["description"].orEmpty(),
|
||||
tags = translations["tags"].orEmpty()
|
||||
.split(",")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
)
|
||||
val existing = originalWorkTranslationRepository.findByOriginalWorkIdAndLocale(resourceId, targetLanguage)
|
||||
if (existing == null) {
|
||||
originalWorkTranslationRepository.save(OriginalWorkTranslation(resourceId, targetLanguage, payload))
|
||||
} else {
|
||||
existing.renderedPayload = payload
|
||||
originalWorkTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertCategory(resourceId: Long, targetLanguage: String, translations: Map<String, String>) {
|
||||
val category = translations["category"].orEmpty()
|
||||
val existing = categoryTranslationRepository.findByCategoryIdAndLocale(resourceId, targetLanguage)
|
||||
if (existing == null) {
|
||||
categoryTranslationRepository.save(CategoryTranslation(resourceId, targetLanguage, category))
|
||||
} else {
|
||||
existing.category = category
|
||||
categoryTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_PROVIDER = "papago"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package kr.co.vividnext.sodalive.i18n.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.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 org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
data class TranslationSourceField(
|
||||
val fieldKey: String,
|
||||
val sourceText: String
|
||||
)
|
||||
|
||||
data class TranslationSource(
|
||||
val resourceType: LanguageTranslationTargetType,
|
||||
val resourceId: Long,
|
||||
val sourceLanguage: String,
|
||||
val fields: List<TranslationSourceField>
|
||||
)
|
||||
|
||||
@Component
|
||||
class TranslationSourceExtractor(
|
||||
private val audioContentRepository: AudioContentRepository,
|
||||
private val chatCharacterRepository: ChatCharacterRepository,
|
||||
private val audioContentThemeRepository: AudioContentThemeQueryRepository,
|
||||
private val seriesRepository: AdminContentSeriesRepository,
|
||||
private val seriesGenreRepository: AdminContentSeriesGenreRepository,
|
||||
private val originalWorkRepository: OriginalWorkRepository,
|
||||
private val categoryRepository: CategoryRepository
|
||||
) {
|
||||
fun extract(resourceType: LanguageTranslationTargetType, resourceId: Long): TranslationSource? {
|
||||
return when (resourceType) {
|
||||
LanguageTranslationTargetType.CONTENT -> extractContent(resourceId)
|
||||
LanguageTranslationTargetType.CHARACTER -> extractCharacter(resourceId)
|
||||
LanguageTranslationTargetType.CONTENT_THEME -> extractContentTheme(resourceId)
|
||||
LanguageTranslationTargetType.SERIES -> extractSeries(resourceId)
|
||||
LanguageTranslationTargetType.SERIES_GENRE -> extractSeriesGenre(resourceId)
|
||||
LanguageTranslationTargetType.ORIGINAL_WORK -> extractOriginalWork(resourceId)
|
||||
LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY -> extractCategory(resourceId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractContent(resourceId: Long): TranslationSource? {
|
||||
val content = audioContentRepository.findByIdOrNull(resourceId) ?: return null
|
||||
val sourceLanguage = content.languageCode?.takeIf { it.isNotBlank() } ?: return null
|
||||
val tags = content.audioContentHashTags
|
||||
.mapNotNull { it.hashTag?.tag }
|
||||
.joinToString(",")
|
||||
return TranslationSource(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT,
|
||||
resourceId = resourceId,
|
||||
sourceLanguage = sourceLanguage,
|
||||
fields = listOf(
|
||||
TranslationSourceField("title", content.title),
|
||||
TranslationSourceField("detail", content.detail),
|
||||
TranslationSourceField("tags", tags)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractCharacter(resourceId: Long): TranslationSource? {
|
||||
val character = chatCharacterRepository.findByIdOrNull(resourceId) ?: return null
|
||||
val sourceLanguage = character.languageCode?.takeIf { it.isNotBlank() } ?: return null
|
||||
val personality = character.personalities.firstOrNull()
|
||||
val background = character.backgrounds.firstOrNull()
|
||||
val tags = character.tagMappings.joinToString(",") { it.tag.tag }
|
||||
return TranslationSource(
|
||||
resourceType = LanguageTranslationTargetType.CHARACTER,
|
||||
resourceId = resourceId,
|
||||
sourceLanguage = sourceLanguage,
|
||||
fields = listOf(
|
||||
TranslationSourceField("name", character.name),
|
||||
TranslationSourceField("description", character.description),
|
||||
TranslationSourceField("gender", character.gender ?: ""),
|
||||
TranslationSourceField("personalityTrait", personality?.trait ?: ""),
|
||||
TranslationSourceField("personalityDescription", personality?.description ?: ""),
|
||||
TranslationSourceField("backgroundTopic", background?.topic ?: ""),
|
||||
TranslationSourceField("backgroundDescription", background?.description ?: ""),
|
||||
TranslationSourceField("tags", tags)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractContentTheme(resourceId: Long): TranslationSource? {
|
||||
val contentTheme = audioContentThemeRepository.findThemeByIdAndActive(resourceId) ?: return null
|
||||
return TranslationSource(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT_THEME,
|
||||
resourceId = resourceId,
|
||||
sourceLanguage = "ko",
|
||||
fields = listOf(TranslationSourceField("theme", contentTheme.theme))
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractSeries(resourceId: Long): TranslationSource? {
|
||||
val series = seriesRepository.findByIdOrNull(resourceId) ?: return null
|
||||
val sourceLanguage = series.languageCode?.takeIf { it.isNotBlank() } ?: return null
|
||||
val keywords = series.keywordList
|
||||
.mapNotNull { it.keyword?.tag }
|
||||
.joinToString(", ")
|
||||
return TranslationSource(
|
||||
resourceType = LanguageTranslationTargetType.SERIES,
|
||||
resourceId = resourceId,
|
||||
sourceLanguage = sourceLanguage,
|
||||
fields = listOf(
|
||||
TranslationSourceField("title", series.title),
|
||||
TranslationSourceField("introduction", series.introduction),
|
||||
TranslationSourceField("keywords", keywords)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractSeriesGenre(resourceId: Long): TranslationSource? {
|
||||
val seriesGenre = seriesGenreRepository.findActiveSeriesGenreById(resourceId) ?: return null
|
||||
return TranslationSource(
|
||||
resourceType = LanguageTranslationTargetType.SERIES_GENRE,
|
||||
resourceId = resourceId,
|
||||
sourceLanguage = "ko",
|
||||
fields = listOf(TranslationSourceField("genre", seriesGenre.genre))
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractOriginalWork(resourceId: Long): TranslationSource? {
|
||||
val originalWork = originalWorkRepository.findByIdOrNull(resourceId) ?: return null
|
||||
val sourceLanguage = originalWork.languageCode?.takeIf { it.isNotBlank() } ?: return null
|
||||
val tags = originalWork.tagMappings.joinToString(", ") { it.tag.tag }
|
||||
return TranslationSource(
|
||||
resourceType = LanguageTranslationTargetType.ORIGINAL_WORK,
|
||||
resourceId = resourceId,
|
||||
sourceLanguage = sourceLanguage,
|
||||
fields = listOf(
|
||||
TranslationSourceField("title", originalWork.title),
|
||||
TranslationSourceField("contentType", originalWork.contentType),
|
||||
TranslationSourceField("category", originalWork.category),
|
||||
TranslationSourceField("description", originalWork.description),
|
||||
TranslationSourceField("tags", tags)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractCategory(resourceId: Long): TranslationSource? {
|
||||
val category = categoryRepository.findByIdOrNull(resourceId) ?: return null
|
||||
val sourceLanguage = category.languageCode?.takeIf { it.isNotBlank() } ?: return null
|
||||
if (!category.isActive) return null
|
||||
return TranslationSource(
|
||||
resourceType = LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY,
|
||||
resourceId = resourceId,
|
||||
sourceLanguage = sourceLanguage,
|
||||
fields = listOf(TranslationSourceField("category", category.title))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -177,7 +177,7 @@ enum class Gender {
|
||||
}
|
||||
|
||||
enum class MemberRole {
|
||||
ADMIN, BOT, USER, CREATOR, AGENT
|
||||
ADMIN, BOT, USER, CREATOR, AGENT, CONTENT_MANAGER
|
||||
}
|
||||
|
||||
enum class MemberProvider {
|
||||
|
||||
@@ -252,7 +252,7 @@ class MemberService(
|
||||
gender = gender,
|
||||
signupDate = signUpDate,
|
||||
chargeCount = chargeCount,
|
||||
role = member.role,
|
||||
role = if (member.role == MemberRole.CREATOR) MemberRole.CREATOR else MemberRole.USER,
|
||||
messageNotice = member.notification?.message,
|
||||
followingChannelLiveNotice = member.notification?.live,
|
||||
followingChannelUploadContentNotice = member.notification?.uploadContent,
|
||||
|
||||
@@ -2,7 +2,7 @@ package kr.co.vividnext.sodalive.member.contentpreference
|
||||
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
|
||||
private val FORCED_KR_MEMBER_IDS = setOf(16L, 17L, 17958L)
|
||||
private val FORCED_KR_MEMBER_IDS = setOf(16L, 17L, 17958L, 44144L)
|
||||
private val FORCED_JP_MEMBER_IDS = setOf(2L, 29721L, 32050L, 37543L, 40850L)
|
||||
|
||||
fun resolveCountryCodeWithForcedMapping(member: Member, requestCountryCode: String?): String {
|
||||
|
||||
@@ -13,7 +13,7 @@ import org.springframework.web.bind.annotation.RestController
|
||||
@RequestMapping("/menu")
|
||||
class MenuController(private val service: MenuService) {
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAnyRole('AGENT', 'ADMIN', 'CREATOR')")
|
||||
@PreAuthorize("hasAnyRole('AGENT', 'ADMIN', 'CREATOR', 'CONTENT_MANAGER')")
|
||||
fun getMenus(
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
|
||||
@@ -23,6 +23,9 @@ payverse:
|
||||
usdMid: ${PAYVERSE_USD_MID}
|
||||
usdClientKey: ${PAYVERSE_USD_CLIENT_KEY}
|
||||
usdSecretKey: ${PAYVERSE_USD_SECRET_KEY}
|
||||
jpyMid: ${PAYVERSE_JPY_MID}
|
||||
jpyClientKey: ${PAYVERSE_JPY_CLIENT_KEY}
|
||||
jpySecretKey: ${PAYVERSE_JPY_SECRET_KEY}
|
||||
|
||||
bootpay:
|
||||
applicationId: ${BOOTPAY_APPLICATION_ID}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package kr.co.vividnext.sodalive.admin.content
|
||||
|
||||
import kr.co.vividnext.sodalive.common.CountryContext
|
||||
import kr.co.vividnext.sodalive.content.main.tab.GetContentMainTabItem
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
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.web.servlet.WebMvcTest
|
||||
import org.springframework.boot.test.context.TestConfiguration
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.mock.web.MockMultipartFile
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||
import org.springframework.security.web.SecurityFilterChain
|
||||
import org.springframework.security.web.authentication.HttpStatusEntryPoint
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import javax.servlet.http.HttpServletResponse
|
||||
|
||||
@WebMvcTest(AdminContentController::class)
|
||||
@Import(AdminContentControllerSecurityTest.TestSecurityConfig::class)
|
||||
class AdminContentControllerSecurityTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var service: AdminContentService
|
||||
|
||||
@MockBean
|
||||
private lateinit var countryContext: CountryContext
|
||||
|
||||
@MockBean
|
||||
private lateinit var langContext: LangContext
|
||||
|
||||
@MockBean
|
||||
private lateinit var sodaMessageSource: SodaMessageSource
|
||||
|
||||
@TestConfiguration
|
||||
@EnableGlobalMethodSecurity(prePostEnabled = true)
|
||||
class TestSecurityConfig {
|
||||
@Bean
|
||||
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
return http
|
||||
.csrf().disable()
|
||||
.authorizeRequests()
|
||||
.anyRequest().authenticated()
|
||||
.and()
|
||||
.exceptionHandling()
|
||||
.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
|
||||
.accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) }
|
||||
.and()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("콘텐츠 관리자 권한이면 관리자 콘텐츠 목록 조회에 성공한다")
|
||||
fun shouldAllowContentManagerRoleForContentList() {
|
||||
Mockito.`when`(
|
||||
service.getAudioContentList(
|
||||
status = ContentReleaseStatus.OPEN,
|
||||
pageable = PageRequest.of(0, 20)
|
||||
)
|
||||
).thenReturn(GetAdminContentListResponse(totalCount = 0, items = emptyList()))
|
||||
|
||||
mockMvc.perform(
|
||||
get("/admin/audio-content/list")
|
||||
.param("page", "0")
|
||||
.param("size", "20")
|
||||
.with(user("content-manager").roles("CONTENT_MANAGER"))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.totalCount").value(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("콘텐츠 관리자 권한이면 관리자 콘텐츠 검색에 성공한다")
|
||||
fun shouldAllowContentManagerRoleForContentSearch() {
|
||||
Mockito.`when`(
|
||||
service.searchAudioContent(
|
||||
status = ContentReleaseStatus.OPEN,
|
||||
searchWord = "title",
|
||||
pageable = PageRequest.of(0, 20)
|
||||
)
|
||||
).thenReturn(GetAdminContentListResponse(totalCount = 0, items = emptyList()))
|
||||
|
||||
mockMvc.perform(
|
||||
get("/admin/audio-content/search")
|
||||
.param("search_word", "title")
|
||||
.param("page", "0")
|
||||
.param("size", "20")
|
||||
.with(user("content-manager").roles("CONTENT_MANAGER"))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("콘텐츠 관리자 권한이면 관리자 콘텐츠 메인 탭 조회에 성공한다")
|
||||
fun shouldAllowContentManagerRoleForContentMainTab() {
|
||||
Mockito.`when`(service.getContentMainTabList()).thenReturn(
|
||||
listOf(GetContentMainTabItem(tabId = 1L, title = "홈"))
|
||||
)
|
||||
|
||||
mockMvc.perform(
|
||||
get("/admin/audio-content/main/tab")
|
||||
.with(user("content-manager").roles("CONTENT_MANAGER"))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data[0].tabId").value(1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("콘텐츠 관리자 권한이면 관리자 콘텐츠 수정에 접근할 수 없다")
|
||||
fun shouldRejectContentManagerRoleForContentUpdate() {
|
||||
val requestPart = MockMultipartFile(
|
||||
"request",
|
||||
"request.json",
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
"{\"id\":1,\"isDefaultCoverImage\":false}".toByteArray()
|
||||
)
|
||||
|
||||
mockMvc.perform(
|
||||
multipart("/admin/audio-content")
|
||||
.file(requestPart)
|
||||
.with { request ->
|
||||
request.method = "PUT"
|
||||
request
|
||||
}
|
||||
.with(user("content-manager").roles("CONTENT_MANAGER"))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자 권한이면 관리자 콘텐츠 수정에 접근할 수 있다")
|
||||
fun shouldAllowAdminRoleForContentUpdate() {
|
||||
val requestPart = MockMultipartFile(
|
||||
"request",
|
||||
"request.json",
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
"{\"id\":1,\"isDefaultCoverImage\":false}".toByteArray()
|
||||
)
|
||||
|
||||
mockMvc.perform(
|
||||
multipart("/admin/audio-content")
|
||||
.file(requestPart)
|
||||
.with { request ->
|
||||
request.method = "PUT"
|
||||
request
|
||||
}
|
||||
.with(user("admin").roles("ADMIN"))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("익명 사용자는 관리자 콘텐츠 목록 조회에 접근할 수 없다")
|
||||
fun shouldRejectAnonymousUserForContentList() {
|
||||
mockMvc.perform(
|
||||
get("/admin/audio-content/list")
|
||||
.param("page", "0")
|
||||
.param("size", "20")
|
||||
.with(anonymous())
|
||||
)
|
||||
.andExpect(status().isUnauthorized)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package kr.co.vividnext.sodalive.admin.member
|
||||
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.login.LoginRequest
|
||||
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.mockito.Mockito
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders
|
||||
|
||||
class AdminMemberLoginControllerTest {
|
||||
private lateinit var service: AdminMemberLoginService
|
||||
private lateinit var controller: AdminMemberLoginController
|
||||
private lateinit var mockMvc: MockMvc
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
service = mock()
|
||||
controller = AdminMemberLoginController(service = service)
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build()
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("POST /admin/member/login은 token과 role을 응답한다")
|
||||
fun shouldReturnTokenAndRole() {
|
||||
val request = LoginRequest(email = "admin@test.com", password = "password")
|
||||
val loginResponse = AdminMemberLoginResponse(token = "admin-token", role = MemberRole.ADMIN)
|
||||
Mockito.`when`(service.login(request)).thenReturn(loginResponse)
|
||||
|
||||
val response = controller.login(request)
|
||||
|
||||
assertTrue(response.success)
|
||||
assertEquals("admin-token", response.data?.token)
|
||||
assertEquals(MemberRole.ADMIN, response.data?.role)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("POST /admin/member/login은 JSON으로 token과 role을 응답한다")
|
||||
fun shouldReturnTokenAndRoleJson() {
|
||||
val request = LoginRequest(email = "content@test.com", password = "password")
|
||||
Mockito.`when`(service.login(request)).thenReturn(
|
||||
AdminMemberLoginResponse(token = "content-token", role = MemberRole.CONTENT_MANAGER)
|
||||
)
|
||||
|
||||
mockMvc.perform(
|
||||
post("/admin/member/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"email":"content@test.com","password":"password"}""")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.token").value("content-token"))
|
||||
.andExpect(jsonPath("$.data.role").value("CONTENT_MANAGER"))
|
||||
}
|
||||
|
||||
private inline fun <reified T> mock(): T {
|
||||
return Mockito.mock(T::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package kr.co.vividnext.sodalive.admin.member
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||
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.login.LoginRequest
|
||||
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
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.security.crypto.password.PasswordEncoder
|
||||
|
||||
class AdminMemberLoginServiceTest {
|
||||
private lateinit var repository: AdminMemberRepository
|
||||
private lateinit var passwordEncoder: PasswordEncoder
|
||||
private lateinit var tokenRepository: MemberTokenRepository
|
||||
private lateinit var service: AdminMemberLoginService
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
repository = mock()
|
||||
passwordEncoder = mock()
|
||||
tokenRepository = mock()
|
||||
val tokenProvider = TokenProvider(
|
||||
secret = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
|
||||
tokenValidityInSeconds = 3600,
|
||||
repository = mock<MemberRepository>(),
|
||||
tokenRepository = tokenRepository
|
||||
)
|
||||
tokenProvider.afterPropertiesSet()
|
||||
service = AdminMemberLoginService(
|
||||
repository = repository,
|
||||
passwordEncoder = passwordEncoder,
|
||||
tokenProvider = tokenProvider
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자는 관리자 로그인 API로 token과 role을 받는다")
|
||||
fun shouldLoginAdmin() {
|
||||
val member = createMember(id = 1L, role = MemberRole.ADMIN)
|
||||
Mockito.`when`(repository.findByEmail("admin@test.com")).thenReturn(member)
|
||||
Mockito.`when`(passwordEncoder.matches("password", "encoded-password")).thenReturn(true)
|
||||
|
||||
val response = service.login(LoginRequest(email = "admin@test.com", password = "password"))
|
||||
|
||||
assertTrue(response.token.isNotBlank())
|
||||
assertEquals(MemberRole.ADMIN, response.role)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("콘텐츠 관리자는 관리자 로그인 API로 token과 role을 받는다")
|
||||
fun shouldLoginContentManager() {
|
||||
val member = createMember(id = 2L, role = MemberRole.CONTENT_MANAGER)
|
||||
Mockito.`when`(repository.findByEmail("content@test.com")).thenReturn(member)
|
||||
Mockito.`when`(passwordEncoder.matches("password", "encoded-password")).thenReturn(true)
|
||||
|
||||
val response = service.login(LoginRequest(email = "content@test.com", password = "password"))
|
||||
|
||||
assertTrue(response.token.isNotBlank())
|
||||
assertEquals(MemberRole.CONTENT_MANAGER, response.role)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("일반 사용자는 관리자 로그인 API를 사용할 수 없다")
|
||||
fun shouldRejectUser() {
|
||||
val member = createMember(id = 3L, role = MemberRole.USER)
|
||||
Mockito.`when`(repository.findByEmail("user@test.com")).thenReturn(member)
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
service.login(LoginRequest(email = "user@test.com", password = "password"))
|
||||
}
|
||||
|
||||
assertEquals("common.error.bad_credentials", exception.messageKey)
|
||||
Mockito.verifyNoInteractions(tokenRepository)
|
||||
}
|
||||
|
||||
private fun createMember(id: Long, role: MemberRole): Member {
|
||||
val member = Member(
|
||||
email = "member$id@test.com",
|
||||
password = "encoded-password",
|
||||
nickname = "member$id",
|
||||
role = role
|
||||
)
|
||||
member.id = id
|
||||
return member
|
||||
}
|
||||
|
||||
private inline fun <reified T> mock(): T {
|
||||
return Mockito.mock(T::class.java)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationR
|
||||
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
|
||||
import kr.co.vividnext.sodalive.i18n.Lang
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
@@ -26,7 +26,7 @@ class ChatCharacterControllerTest {
|
||||
private val chatRoomService = Mockito.mock(ChatRoomService::class.java)
|
||||
private val characterCommentService = Mockito.mock(CharacterCommentService::class.java)
|
||||
private val curationQueryService = Mockito.mock(CharacterCurationQueryService::class.java)
|
||||
private val translationService = Mockito.mock(PapagoTranslationService::class.java)
|
||||
private val resourceTranslationJobScheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
|
||||
private val aiCharacterTranslationRepository = Mockito.mock(AiCharacterTranslationRepository::class.java)
|
||||
private val langContext = LangContext().apply { setLang(Lang.JA) }
|
||||
private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||
@@ -36,7 +36,7 @@ class ChatCharacterControllerTest {
|
||||
chatRoomService = chatRoomService,
|
||||
characterCommentService = characterCommentService,
|
||||
curationQueryService = curationQueryService,
|
||||
translationService = translationService,
|
||||
resourceTranslationJobScheduler = resourceTranslationJobScheduler,
|
||||
aiCharacterTranslationRepository = aiCharacterTranslationRepository,
|
||||
langContext = langContext,
|
||||
memberContentPreferenceService = memberContentPreferenceService,
|
||||
@@ -73,7 +73,7 @@ class ChatCharacterControllerTest {
|
||||
chatRoomService = chatRoomService,
|
||||
characterCommentService = characterCommentService,
|
||||
curationQueryService = curationQueryService,
|
||||
translationService = translationService,
|
||||
resourceTranslationJobScheduler = resourceTranslationJobScheduler,
|
||||
aiCharacterTranslationRepository = aiCharacterTranslationRepository,
|
||||
langContext = LangContext().apply { setLang(Lang.EN) },
|
||||
memberContentPreferenceService = memberContentPreferenceService,
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package kr.co.vividnext.sodalive.chat.quota.room
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
import kr.co.vividnext.sodalive.chat.quota.ChatQuotaService
|
||||
import kr.co.vividnext.sodalive.chat.room.ChatParticipant
|
||||
import kr.co.vividnext.sodalive.chat.room.ChatRoom
|
||||
import kr.co.vividnext.sodalive.chat.room.ParticipantType
|
||||
import kr.co.vividnext.sodalive.chat.room.repository.ChatParticipantRepository
|
||||
import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository
|
||||
import kr.co.vividnext.sodalive.content.ContentType
|
||||
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.member.contentpreference.ViewerContentPreference
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
|
||||
class ChatRoomQuotaControllerTest {
|
||||
private lateinit var chatRoomRepository: ChatRoomRepository
|
||||
private lateinit var participantRepository: ChatParticipantRepository
|
||||
private lateinit var chatRoomQuotaService: ChatRoomQuotaService
|
||||
private lateinit var chatQuotaService: ChatQuotaService
|
||||
private lateinit var memberContentPreferenceService: MemberContentPreferenceService
|
||||
private lateinit var controller: ChatRoomQuotaController
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
chatRoomRepository = Mockito.mock(ChatRoomRepository::class.java)
|
||||
participantRepository = Mockito.mock(ChatParticipantRepository::class.java)
|
||||
chatRoomQuotaService = Mockito.mock(ChatRoomQuotaService::class.java)
|
||||
chatQuotaService = Mockito.mock(ChatQuotaService::class.java)
|
||||
memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||
controller = ChatRoomQuotaController(
|
||||
chatRoomRepository = chatRoomRepository,
|
||||
participantRepository = participantRepository,
|
||||
chatRoomQuotaService = chatRoomQuotaService,
|
||||
chatQuotaService = chatQuotaService,
|
||||
memberContentPreferenceService = memberContentPreferenceService
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("캔 충전 요청은 선택한 캔 옵션으로 서비스에 전달된다")
|
||||
fun shouldDelegateCanPurchaseWithSelectedCanOption() {
|
||||
val member = createMember(id = 7L, nickname = "user")
|
||||
val room = createRoom(id = 101L)
|
||||
val character = createCharacter(id = 202L)
|
||||
stubAccessibleRoom(member, room, character)
|
||||
Mockito.`when`(
|
||||
chatRoomQuotaService.purchaseWithCan(
|
||||
memberId = member.id!!,
|
||||
chatRoomId = room.id!!,
|
||||
characterId = character.id!!,
|
||||
canOption = ChatRoomQuotaCanOption.CAN_20,
|
||||
container = "aos"
|
||||
)
|
||||
).thenReturn(ChatRoomQuotaService.RoomQuotaStatus(50, null, 10, 40))
|
||||
|
||||
val response = controller.purchaseRoomQuota(
|
||||
member = member,
|
||||
chatRoomId = room.id!!,
|
||||
req = ChatRoomQuotaController.PurchaseRoomQuotaRequest(
|
||||
container = "aos",
|
||||
chargeType = ChatRoomQuotaChargeType.CAN,
|
||||
canOption = ChatRoomQuotaCanOption.CAN_20
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(true, response.success)
|
||||
assertEquals(50, response.data!!.totalRemaining)
|
||||
assertEquals(40, response.data!!.remainingPaid)
|
||||
Mockito.verify(chatRoomQuotaService).purchaseWithCan(
|
||||
memberId = member.id!!,
|
||||
chatRoomId = room.id!!,
|
||||
characterId = character.id!!,
|
||||
canOption = ChatRoomQuotaCanOption.CAN_20,
|
||||
container = "aos"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("container만 있는 기존 요청은 기본적으로 10캔 충전으로 처리한다")
|
||||
fun shouldFallbackToCan10WhenOnlyContainerIsProvided() {
|
||||
val member = createMember(id = 17L, nickname = "legacy")
|
||||
val room = createRoom(id = 301L)
|
||||
val character = createCharacter(id = 402L)
|
||||
stubAccessibleRoom(member, room, character)
|
||||
Mockito.`when`(
|
||||
chatRoomQuotaService.purchaseWithCan(
|
||||
memberId = member.id!!,
|
||||
chatRoomId = room.id!!,
|
||||
characterId = character.id!!,
|
||||
canOption = ChatRoomQuotaCanOption.CAN_10,
|
||||
container = "aos"
|
||||
)
|
||||
).thenReturn(ChatRoomQuotaService.RoomQuotaStatus(25, null, 10, 15))
|
||||
|
||||
val response = controller.purchaseRoomQuota(
|
||||
member = member,
|
||||
chatRoomId = room.id!!,
|
||||
req = ChatRoomQuotaController.PurchaseRoomQuotaRequest(
|
||||
container = "aos"
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(true, response.success)
|
||||
assertEquals(25, response.data!!.totalRemaining)
|
||||
assertEquals(15, response.data!!.remainingPaid)
|
||||
Mockito.verify(chatRoomQuotaService).purchaseWithCan(
|
||||
memberId = member.id!!,
|
||||
chatRoomId = room.id!!,
|
||||
characterId = character.id!!,
|
||||
canOption = ChatRoomQuotaCanOption.CAN_10,
|
||||
container = "aos"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("광고 충전 요청은 캔 차감 없이 광고 서비스 경로로 전달된다")
|
||||
fun shouldDelegateAdPurchaseWithoutCanOption() {
|
||||
val member = createMember(id = 8L, nickname = "user")
|
||||
val room = createRoom(id = 111L)
|
||||
val character = createCharacter(id = 222L)
|
||||
stubAccessibleRoom(member, room, character)
|
||||
Mockito.`when`(
|
||||
chatRoomQuotaService.purchaseWithAd(
|
||||
memberId = member.id!!,
|
||||
chatRoomId = room.id!!,
|
||||
characterId = character.id!!
|
||||
)
|
||||
).thenReturn(ChatRoomQuotaService.RoomQuotaStatus(15, null, 10, 5))
|
||||
|
||||
val response = controller.purchaseRoomQuota(
|
||||
member = member,
|
||||
chatRoomId = room.id!!,
|
||||
req = ChatRoomQuotaController.PurchaseRoomQuotaRequest(
|
||||
container = "aos",
|
||||
chargeType = ChatRoomQuotaChargeType.AD,
|
||||
canOption = null
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(true, response.success)
|
||||
assertEquals(15, response.data!!.totalRemaining)
|
||||
assertEquals(5, response.data!!.remainingPaid)
|
||||
Mockito.verify(chatRoomQuotaService).purchaseWithAd(
|
||||
memberId = member.id!!,
|
||||
chatRoomId = room.id!!,
|
||||
characterId = character.id!!
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("광고 충전 요청에 캔 옵션이 포함되어도 무시하고 광고 충전을 처리한다")
|
||||
fun shouldIgnoreCanOptionWhenAdPurchaseContainsIt() {
|
||||
val member = createMember(id = 9L, nickname = "user")
|
||||
val room = createRoom(id = 121L)
|
||||
val character = createCharacter(id = 232L)
|
||||
stubAccessibleRoom(member, room, character)
|
||||
Mockito.`when`(
|
||||
chatRoomQuotaService.purchaseWithAd(
|
||||
memberId = member.id!!,
|
||||
chatRoomId = room.id!!,
|
||||
characterId = character.id!!
|
||||
)
|
||||
).thenReturn(ChatRoomQuotaService.RoomQuotaStatus(15, null, 10, 5))
|
||||
|
||||
val response = controller.purchaseRoomQuota(
|
||||
member = member,
|
||||
chatRoomId = room.id!!,
|
||||
req = ChatRoomQuotaController.PurchaseRoomQuotaRequest(
|
||||
container = "aos",
|
||||
chargeType = ChatRoomQuotaChargeType.AD,
|
||||
canOption = ChatRoomQuotaCanOption.CAN_10
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(true, response.success)
|
||||
assertEquals(15, response.data!!.totalRemaining)
|
||||
assertEquals(5, response.data!!.remainingPaid)
|
||||
Mockito.verify(chatRoomQuotaService).purchaseWithAd(
|
||||
memberId = member.id!!,
|
||||
chatRoomId = room.id!!,
|
||||
characterId = character.id!!
|
||||
)
|
||||
}
|
||||
|
||||
private fun stubAccessibleRoom(member: Member, room: ChatRoom, character: ChatCharacter) {
|
||||
Mockito.`when`(memberContentPreferenceService.getStoredPreference(member)).thenReturn(
|
||||
ViewerContentPreference(
|
||||
countryCode = "KR",
|
||||
isAdultContentVisible = true,
|
||||
contentType = ContentType.ALL,
|
||||
isAdult = true
|
||||
)
|
||||
)
|
||||
Mockito.`when`(chatRoomRepository.findByIdAndIsActiveTrue(room.id!!)).thenReturn(room)
|
||||
Mockito.`when`(participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)).thenReturn(
|
||||
ChatParticipant(chatRoom = room, participantType = ParticipantType.USER, member = member)
|
||||
)
|
||||
Mockito.`when`(
|
||||
participantRepository.findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER)
|
||||
).thenReturn(
|
||||
ChatParticipant(chatRoom = room, participantType = ParticipantType.CHARACTER, character = character)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createMember(id: Long, nickname: String): Member {
|
||||
val member = Member(
|
||||
email = "$nickname@test.com",
|
||||
password = "password",
|
||||
nickname = nickname,
|
||||
role = MemberRole.USER
|
||||
)
|
||||
member.id = id
|
||||
return member
|
||||
}
|
||||
|
||||
private fun createRoom(id: Long): ChatRoom {
|
||||
val room = ChatRoom(sessionId = "session-$id", title = "room-$id")
|
||||
room.id = id
|
||||
return room
|
||||
}
|
||||
|
||||
private fun createCharacter(id: Long): ChatCharacter {
|
||||
val character = ChatCharacter(
|
||||
characterUUID = "character-$id",
|
||||
name = "character-$id",
|
||||
description = "desc",
|
||||
systemPrompt = "prompt",
|
||||
characterType = CharacterType.Character
|
||||
)
|
||||
character.id = id
|
||||
return character
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package kr.co.vividnext.sodalive.chat.quota.room
|
||||
|
||||
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
|
||||
class ChatRoomQuotaServiceTest {
|
||||
private lateinit var repo: ChatRoomQuotaRepository
|
||||
private lateinit var canPaymentService: CanPaymentService
|
||||
private lateinit var service: ChatRoomQuotaService
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
repo = Mockito.mock(ChatRoomQuotaRepository::class.java)
|
||||
canPaymentService = Mockito.mock(CanPaymentService::class.java)
|
||||
service = ChatRoomQuotaService(repo, canPaymentService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("10캔 충전은 15개 유료 quota를 지급한다")
|
||||
fun shouldAddFifteenPaidQuotaWhenPurchasingCan10Option() {
|
||||
val quota = ChatRoomQuota(memberId = 1L, chatRoomId = 2L, characterId = 3L, remainingFree = 10, remainingPaid = 0)
|
||||
Mockito.`when`(repo.findForUpdate(1L, 2L)).thenReturn(quota)
|
||||
|
||||
val result = service.purchaseWithCan(
|
||||
memberId = 1L,
|
||||
chatRoomId = 2L,
|
||||
characterId = 3L,
|
||||
canOption = ChatRoomQuotaCanOption.CAN_10,
|
||||
container = "aos"
|
||||
)
|
||||
|
||||
Mockito.verify(canPaymentService).spendCan(
|
||||
1L,
|
||||
10,
|
||||
CanUsage.CHAT_QUOTA_PURCHASE,
|
||||
2L,
|
||||
3L,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"aos"
|
||||
)
|
||||
assertEquals(15, result.remainingPaid)
|
||||
assertEquals(25, result.totalRemaining)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("20캔 충전은 40개 유료 quota를 지급한다")
|
||||
fun shouldAddFortyPaidQuotaWhenPurchasingCan20Option() {
|
||||
val quota = ChatRoomQuota(memberId = 11L, chatRoomId = 12L, characterId = 13L, remainingFree = 10, remainingPaid = 2)
|
||||
Mockito.`when`(repo.findForUpdate(11L, 12L)).thenReturn(quota)
|
||||
|
||||
val result = service.purchaseWithCan(
|
||||
memberId = 11L,
|
||||
chatRoomId = 12L,
|
||||
characterId = 13L,
|
||||
canOption = ChatRoomQuotaCanOption.CAN_20,
|
||||
container = "ios"
|
||||
)
|
||||
|
||||
Mockito.verify(canPaymentService).spendCan(
|
||||
11L,
|
||||
20,
|
||||
CanUsage.CHAT_QUOTA_PURCHASE,
|
||||
12L,
|
||||
13L,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"ios"
|
||||
)
|
||||
assertEquals(42, result.remainingPaid)
|
||||
assertEquals(52, result.totalRemaining)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("광고 충전은 캔 차감 없이 5개 유료 quota를 지급한다")
|
||||
fun shouldAddFivePaidQuotaWithoutSpendingCanWhenPurchasingWithAd() {
|
||||
val quota = ChatRoomQuota(memberId = 21L, chatRoomId = 22L, characterId = 23L, remainingFree = 10, remainingPaid = 1)
|
||||
Mockito.`when`(repo.findForUpdate(21L, 22L)).thenReturn(quota)
|
||||
|
||||
val result = service.purchaseWithAd(
|
||||
memberId = 21L,
|
||||
chatRoomId = 22L,
|
||||
characterId = 23L
|
||||
)
|
||||
|
||||
Mockito.verifyNoInteractions(canPaymentService)
|
||||
assertEquals(6, result.remainingPaid)
|
||||
assertEquals(16, result.totalRemaining)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
@@ -44,7 +44,7 @@ class AudioContentServiceTest {
|
||||
private lateinit var commentRepository: AudioContentCommentRepository
|
||||
private lateinit var audioContentLikeRepository: AudioContentLikeRepository
|
||||
private lateinit var pinContentRepository: PinContentRepository
|
||||
private lateinit var translationService: PapagoTranslationService
|
||||
private lateinit var resourceTranslationJobScheduler: ResourceTranslationJobScheduler
|
||||
private lateinit var contentTranslationRepository: ContentTranslationRepository
|
||||
private lateinit var s3Uploader: S3Uploader
|
||||
private lateinit var audioContentCloudFront: AudioContentCloudFront
|
||||
@@ -66,7 +66,7 @@ class AudioContentServiceTest {
|
||||
commentRepository = Mockito.mock(AudioContentCommentRepository::class.java)
|
||||
audioContentLikeRepository = Mockito.mock(AudioContentLikeRepository::class.java)
|
||||
pinContentRepository = Mockito.mock(PinContentRepository::class.java)
|
||||
translationService = Mockito.mock(PapagoTranslationService::class.java)
|
||||
resourceTranslationJobScheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
|
||||
contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java)
|
||||
s3Uploader = Mockito.mock(S3Uploader::class.java)
|
||||
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java)
|
||||
@@ -85,7 +85,7 @@ class AudioContentServiceTest {
|
||||
commentRepository = commentRepository,
|
||||
audioContentLikeRepository = audioContentLikeRepository,
|
||||
pinContentRepository = pinContentRepository,
|
||||
translationService = translationService,
|
||||
resourceTranslationJobScheduler = resourceTranslationJobScheduler,
|
||||
contentTranslationRepository = contentTranslationRepository,
|
||||
s3Uploader = s3Uploader,
|
||||
objectMapper = ObjectMapper(),
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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.Test
|
||||
import org.mockito.Mockito
|
||||
|
||||
class LanguageDetectionCacheServiceTest {
|
||||
@Test
|
||||
fun shouldReuseCachedLanguageDetectionForSameNormalizedText() {
|
||||
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(
|
||||
LanguageDetectionResult(
|
||||
sourceHash = sourceHash,
|
||||
sourceTextSample = "Hello world",
|
||||
detectedLanguage = "en",
|
||||
provider = "papago",
|
||||
confidence = null,
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
)
|
||||
|
||||
var providerCalls = 0
|
||||
val detected = service.detectWithCache("Hello world") {
|
||||
providerCalls++
|
||||
"ko"
|
||||
}
|
||||
|
||||
assertEquals("en", detected)
|
||||
assertEquals(0, providerCalls)
|
||||
Mockito.verify(repository, Mockito.never()).save(Mockito.any(LanguageDetectionResult::class.java))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package kr.co.vividnext.sodalive.creator.admin.member
|
||||
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
|
||||
class CreatorAdminMemberControllerTest {
|
||||
@Test
|
||||
@DisplayName("로그아웃은 크리에이터와 에이전트 권한을 허용한다")
|
||||
fun shouldAllowCreatorAndAgentRolesForLogout() {
|
||||
val logout = CreatorAdminMemberController::class.java.getDeclaredMethod(
|
||||
"logout",
|
||||
String::class.java,
|
||||
Member::class.java
|
||||
)
|
||||
|
||||
val preAuthorize = logout.getAnnotation(PreAuthorize::class.java)
|
||||
|
||||
assertEquals("hasAnyRole('CREATOR', 'AGENT')", preAuthorize.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SourceTextNormalizerTest {
|
||||
@Test
|
||||
fun shouldNormalizeWhitespaceAndUnicodeBeforeHashing() {
|
||||
val composed = "카페\n\t소개"
|
||||
val decomposed = "카페 소개"
|
||||
|
||||
assertEquals("카페 소개", SourceTextNormalizer.normalize(composed))
|
||||
assertEquals(SourceTextNormalizer.hash(composed), SourceTextNormalizer.hash(decomposed))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito
|
||||
|
||||
class TranslationJobSchedulerTest {
|
||||
@Test
|
||||
fun shouldCreateOnePendingJobForMissingNormalizedText() {
|
||||
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
|
||||
val scheduler = TranslationJobScheduler(jobRepository)
|
||||
|
||||
Mockito.`when`(
|
||||
jobRepository.findActiveJob(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT,
|
||||
resourceId = 10L,
|
||||
fieldKey = "title",
|
||||
targetLanguage = "en",
|
||||
sourceHash = SourceTextNormalizer.hash("제목")
|
||||
)
|
||||
).thenReturn(null)
|
||||
|
||||
scheduler.scheduleMissingTranslation(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT,
|
||||
resourceId = 10L,
|
||||
fieldKey = "title",
|
||||
sourceText = " 제목\n",
|
||||
sourceLanguage = "ko",
|
||||
targetLanguage = "EN"
|
||||
)
|
||||
|
||||
val captor = ArgumentCaptor.forClass(TranslationJob::class.java)
|
||||
Mockito.verify(jobRepository).save(captor.capture())
|
||||
|
||||
val saved = captor.value
|
||||
assertEquals(LanguageTranslationTargetType.CONTENT, saved.resourceType)
|
||||
assertEquals(10L, saved.resourceId)
|
||||
assertEquals("title", saved.fieldKey)
|
||||
assertEquals("제목", saved.sourceText)
|
||||
assertEquals(SourceTextNormalizer.hash("제목"), saved.sourceHash)
|
||||
assertEquals("ko", saved.sourceLanguage)
|
||||
assertEquals("en", saved.targetLanguage)
|
||||
assertEquals(TranslationJobStatus.PENDING, saved.status)
|
||||
assertNotNull(saved.nextRetryAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldNotCreateDuplicateJobWhenSameCompletedJobAlreadyExists() {
|
||||
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
|
||||
val scheduler = TranslationJobScheduler(jobRepository)
|
||||
val sourceHash = SourceTextNormalizer.hash("제목")
|
||||
|
||||
Mockito.`when`(
|
||||
jobRepository.findByResourceTypeAndResourceIdAndFieldKeyAndTargetLanguageAndSourceHash(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT,
|
||||
resourceId = 10L,
|
||||
fieldKey = "title",
|
||||
targetLanguage = "en",
|
||||
sourceHash = sourceHash
|
||||
)
|
||||
).thenReturn(
|
||||
TranslationJob(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT,
|
||||
resourceId = 10L,
|
||||
fieldKey = "title",
|
||||
sourceHash = sourceHash,
|
||||
sourceText = "제목",
|
||||
sourceLanguage = "ko",
|
||||
targetLanguage = "en",
|
||||
status = TranslationJobStatus.COMPLETED
|
||||
)
|
||||
)
|
||||
|
||||
scheduler.scheduleMissingTranslation(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT,
|
||||
resourceId = 10L,
|
||||
fieldKey = "title",
|
||||
sourceText = "제목",
|
||||
sourceLanguage = "ko",
|
||||
targetLanguage = "en"
|
||||
)
|
||||
|
||||
Mockito.verify(jobRepository, Mockito.never()).save(Mockito.any(TranslationJob::class.java))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldNotCreateJobWhenSourceLanguageIsUnsupported() {
|
||||
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
|
||||
val scheduler = TranslationJobScheduler(jobRepository)
|
||||
|
||||
scheduler.scheduleMissingTranslation(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT,
|
||||
resourceId = 10L,
|
||||
fieldKey = "title",
|
||||
sourceText = "제목",
|
||||
sourceLanguage = "fr",
|
||||
targetLanguage = "en"
|
||||
)
|
||||
|
||||
Mockito.verifyNoInteractions(jobRepository)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.transaction.support.AbstractPlatformTransactionManager
|
||||
import org.springframework.transaction.support.DefaultTransactionStatus
|
||||
import java.time.LocalDateTime
|
||||
import java.util.Optional
|
||||
|
||||
class TranslationJobWorkerTest {
|
||||
@Test
|
||||
fun shouldRunEveryTenMinutesByDefault() {
|
||||
val scheduled = TranslationJobWorker::class.java
|
||||
.getDeclaredMethod("runPendingJobs")
|
||||
.getAnnotation(Scheduled::class.java)
|
||||
|
||||
assertEquals("\${sodalive.translation-job.fixed-delay-ms:600000}", scheduled.fixedDelayString)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldClaimPendingJobGroupByLockedRepositoryMethod() {
|
||||
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
|
||||
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
|
||||
val provider = successfulProvider()
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
val worker = TranslationJobWorker(
|
||||
translationJobRepository = jobRepository,
|
||||
translationMemoryRepository = memoryRepository,
|
||||
translationProvider = provider,
|
||||
materializer = materializer,
|
||||
transactionManager = TestTransactionManager()
|
||||
)
|
||||
val job = translationJob()
|
||||
job.id = 100L
|
||||
|
||||
Mockito.`when`(jobRepository.findNextPendingGroupJobIdsForUpdate(anyLocalDateTime())).thenReturn(listOf(100L))
|
||||
Mockito.`when`(jobRepository.findById(100L)).thenReturn(Optional.of(job))
|
||||
|
||||
worker.processNextGroup()
|
||||
|
||||
Mockito.verify(jobRepository).findNextPendingGroupJobIdsForUpdate(anyLocalDateTime())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldProcessAllJobsInClaimedGroupBeforeMaterializing() {
|
||||
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
|
||||
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
|
||||
val provider = successfulProvider()
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
val worker = TranslationJobWorker(
|
||||
translationJobRepository = jobRepository,
|
||||
translationMemoryRepository = memoryRepository,
|
||||
translationProvider = provider,
|
||||
materializer = materializer,
|
||||
transactionManager = TestTransactionManager()
|
||||
)
|
||||
val titleJob = translationJob(fieldKey = "title", sourceText = "제목")
|
||||
titleJob.id = 100L
|
||||
val detailJob = translationJob(fieldKey = "detail", sourceText = "설명")
|
||||
detailJob.id = 101L
|
||||
|
||||
Mockito.`when`(jobRepository.findNextPendingGroupJobIdsForUpdate(anyLocalDateTime()))
|
||||
.thenReturn(listOf(100L, 101L))
|
||||
Mockito.`when`(jobRepository.findById(100L)).thenReturn(Optional.of(titleJob))
|
||||
Mockito.`when`(jobRepository.findById(101L)).thenReturn(Optional.of(detailJob))
|
||||
|
||||
worker.processNextGroup()
|
||||
|
||||
assertEquals(TranslationJobStatus.COMPLETED, titleJob.status)
|
||||
assertEquals(TranslationJobStatus.COMPLETED, detailJob.status)
|
||||
Mockito.verify(materializer, Mockito.times(1)).materialize(LanguageTranslationTargetType.CONTENT, 10L, "en")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldRetryGroupWhenMaterializationFails() {
|
||||
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
|
||||
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
|
||||
val provider = successfulProvider()
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
val worker = TranslationJobWorker(
|
||||
translationJobRepository = jobRepository,
|
||||
translationMemoryRepository = memoryRepository,
|
||||
translationProvider = provider,
|
||||
materializer = materializer,
|
||||
transactionManager = TestTransactionManager()
|
||||
)
|
||||
val titleJob = translationJob(fieldKey = "title", sourceText = "제목")
|
||||
titleJob.id = 100L
|
||||
val detailJob = translationJob(fieldKey = "detail", sourceText = "설명")
|
||||
detailJob.id = 101L
|
||||
val beforeRetryAt = titleJob.nextRetryAt
|
||||
|
||||
Mockito.`when`(jobRepository.findNextPendingGroupJobIdsForUpdate(anyLocalDateTime()))
|
||||
.thenReturn(listOf(100L, 101L))
|
||||
Mockito.`when`(jobRepository.findById(100L)).thenReturn(Optional.of(titleJob))
|
||||
Mockito.`when`(jobRepository.findById(101L)).thenReturn(Optional.of(detailJob))
|
||||
Mockito.`when`(materializer.materialize(LanguageTranslationTargetType.CONTENT, 10L, "en"))
|
||||
.thenThrow(IllegalStateException("materialize down"))
|
||||
|
||||
worker.processNextGroup()
|
||||
|
||||
assertEquals(TranslationJobStatus.PENDING, titleJob.status)
|
||||
assertEquals(TranslationJobStatus.PENDING, detailJob.status)
|
||||
assertEquals(1, titleJob.retryCount)
|
||||
assertEquals(1, detailJob.retryCount)
|
||||
assertTrue(titleJob.nextRetryAt.isAfter(beforeRetryAt))
|
||||
assertTrue(detailJob.nextRetryAt.isAfter(beforeRetryAt))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldLimitRunToFiveGroupsPerTick() {
|
||||
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
|
||||
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
|
||||
val provider = successfulProvider()
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
val worker = TranslationJobWorker(
|
||||
translationJobRepository = jobRepository,
|
||||
translationMemoryRepository = memoryRepository,
|
||||
translationProvider = provider,
|
||||
materializer = materializer,
|
||||
transactionManager = TestTransactionManager()
|
||||
)
|
||||
val jobs = (1L..6L).map { id ->
|
||||
translationJob(resourceId = id).also { it.id = id }
|
||||
}
|
||||
Mockito.`when`(jobRepository.findNextPendingGroupJobIdsForUpdate(anyLocalDateTime()))
|
||||
.thenReturn(listOf(1L), listOf(2L), listOf(3L), listOf(4L), listOf(5L), listOf(6L))
|
||||
jobs.forEach { job ->
|
||||
Mockito.`when`(jobRepository.findById(job.id!!)).thenReturn(Optional.of(job))
|
||||
}
|
||||
|
||||
worker.runPendingJobs()
|
||||
|
||||
val savedJobCaptor = ArgumentCaptor.forClass(TranslationJob::class.java)
|
||||
Mockito.verify(jobRepository, Mockito.atLeastOnce()).save(savedJobCaptor.capture())
|
||||
val completedResourceIds = savedJobCaptor.allValues
|
||||
.filter { it.status == TranslationJobStatus.COMPLETED }
|
||||
.map { it.resourceId }
|
||||
.toSet()
|
||||
assertEquals(setOf(1L, 2L, 3L, 4L, 5L), completedResourceIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldRetryFailedJobWithBackoff() {
|
||||
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()
|
||||
job.id = 200L
|
||||
val beforeRetryAt = job.nextRetryAt
|
||||
|
||||
Mockito.`when`(jobRepository.findNextPendingGroupJobIdsForUpdate(anyLocalDateTime())).thenReturn(listOf(200L))
|
||||
Mockito.`when`(jobRepository.findById(200L)).thenReturn(Optional.of(job))
|
||||
|
||||
worker.processNextGroup()
|
||||
|
||||
assertEquals(TranslationJobStatus.PENDING, job.status)
|
||||
assertEquals(1, job.retryCount)
|
||||
assertEquals("provider down", job.lastErrorMessage)
|
||||
assertTrue(job.nextRetryAt.isAfter(beforeRetryAt))
|
||||
}
|
||||
|
||||
private fun translationJob(
|
||||
resourceId: Long = 10L,
|
||||
fieldKey: String = "title",
|
||||
sourceText: String = "제목"
|
||||
): TranslationJob {
|
||||
return TranslationJob(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT,
|
||||
resourceId = resourceId,
|
||||
fieldKey = fieldKey,
|
||||
sourceHash = SourceTextNormalizer.hash(sourceText),
|
||||
sourceText = sourceText,
|
||||
sourceLanguage = "ko",
|
||||
targetLanguage = "en"
|
||||
)
|
||||
}
|
||||
|
||||
private fun successfulProvider(): TranslationProvider {
|
||||
return object : TranslationProvider {
|
||||
override val providerName: String = "papago"
|
||||
override val providerVersion: String = "nmt-v1"
|
||||
|
||||
override fun translate(request: TranslateRequest): TranslateResult {
|
||||
return TranslateResult(listOf("title"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun failingProvider(): TranslationProvider {
|
||||
return object : TranslationProvider {
|
||||
override val providerName: String = "papago"
|
||||
override val providerVersion: String = "nmt-v1"
|
||||
|
||||
override fun translate(request: TranslateRequest): TranslateResult {
|
||||
throw IllegalStateException("provider down")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun anyLocalDateTime(): LocalDateTime {
|
||||
return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
|
||||
}
|
||||
}
|
||||
|
||||
private class TestTransactionManager : AbstractPlatformTransactionManager() {
|
||||
override fun doGetTransaction(): Any {
|
||||
return Any()
|
||||
}
|
||||
|
||||
override fun doBegin(transaction: Any, definition: org.springframework.transaction.TransactionDefinition) {
|
||||
}
|
||||
|
||||
override fun doCommit(status: DefaultTransactionStatus) {
|
||||
}
|
||||
|
||||
override fun doRollback(status: DefaultTransactionStatus) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package kr.co.vividnext.sodalive.menu
|
||||
|
||||
import kr.co.vividnext.sodalive.common.CountryContext
|
||||
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.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
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.web.servlet.WebMvcTest
|
||||
import org.springframework.boot.test.context.TestConfiguration
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||
import org.springframework.security.web.SecurityFilterChain
|
||||
import org.springframework.security.web.authentication.HttpStatusEntryPoint
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import javax.servlet.http.HttpServletResponse
|
||||
|
||||
@WebMvcTest(MenuController::class)
|
||||
@Import(MenuControllerSecurityTest.TestSecurityConfig::class)
|
||||
class MenuControllerSecurityTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var service: MenuService
|
||||
|
||||
@MockBean
|
||||
private lateinit var countryContext: CountryContext
|
||||
|
||||
@MockBean
|
||||
private lateinit var langContext: LangContext
|
||||
|
||||
@MockBean
|
||||
private lateinit var sodaMessageSource: SodaMessageSource
|
||||
|
||||
@TestConfiguration
|
||||
@EnableGlobalMethodSecurity(prePostEnabled = true)
|
||||
class TestSecurityConfig {
|
||||
@Bean
|
||||
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
return http
|
||||
.csrf().disable()
|
||||
.authorizeRequests()
|
||||
.anyRequest().authenticated()
|
||||
.and()
|
||||
.exceptionHandling()
|
||||
.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
|
||||
.accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) }
|
||||
.and()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("콘텐츠 관리자 권한이면 메뉴 조회에 성공한다")
|
||||
fun shouldAllowContentManagerRole() {
|
||||
val member = createMember(role = MemberRole.CONTENT_MANAGER)
|
||||
Mockito.`when`(service.getMenus(member)).thenReturn(
|
||||
listOf(GetMenuResponse(title = "콘텐츠 리스트", route = "/content/list"))
|
||||
)
|
||||
|
||||
mockMvc.perform(
|
||||
get("/menu")
|
||||
.with(user(MemberAdapter(member)))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data[0].route").value("/content/list"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("일반 사용자 권한이면 메뉴 조회에 접근할 수 없다")
|
||||
fun shouldRejectUserRole() {
|
||||
val member = createMember(role = MemberRole.USER)
|
||||
|
||||
mockMvc.perform(
|
||||
get("/menu")
|
||||
.with(user(MemberAdapter(member)))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("익명 사용자는 메뉴 조회에 접근할 수 없다")
|
||||
fun shouldRejectAnonymousUser() {
|
||||
mockMvc.perform(
|
||||
get("/menu")
|
||||
.with(anonymous())
|
||||
)
|
||||
.andExpect(status().isUnauthorized)
|
||||
}
|
||||
|
||||
private fun createMember(role: MemberRole): Member {
|
||||
return Member(
|
||||
email = "${role.name.lowercase()}@test.com",
|
||||
password = "password",
|
||||
nickname = role.name.lowercase(),
|
||||
role = role
|
||||
).apply {
|
||||
id = role.ordinal.toLong() + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user