Merge pull request 'test' (#415) from test into main

Reviewed-on: #415
This commit is contained in:
2026-04-08 01:50:50 +00:00
75 changed files with 2457 additions and 92 deletions

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
HELP.md
.gradle
.envrc
.omx/
build/
!**/src/main/**/build/
!**/src/test/**/build/

View File

@@ -15,7 +15,8 @@ subtask: true
1. 로드한 `commit-policy` 스킬의 Hard Requirements와 Execution Flow를 그대로 수행한다.
2. `AGENTS.md`의 최소 정책(형식/한글 description/검증 스크립트)을 항상 만족한다.
3. `$ARGUMENTS`가 있으면 scope 또는 description 의도에 반영하되, 스킬 규칙과 형식을 깨지 않는다.
4. 마지막에 실행 명령과 pre-check/post-check PASS/FAIL 핵심 결과를 간단히 보고한다.
4. 가능하면 메시지 파일을 검증한 뒤 같은 파일을 `git commit -F`에 전달해 검증을 통과한 메시지를 그대로 사용하고, `Ultraworked with [Sisyphus]...``Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>` 라인이 본문에 추가되지 않도록 확인한다.
5. 마지막에 실행 명령과 pre-check/post-check PASS/FAIL 핵심 결과를 간단히 보고한다.
추가 사용자 의도:
$ARGUMENTS

View File

@@ -20,6 +20,7 @@ Use this workflow whenever the task includes creating a commit.
4. Optional footer must use `Refs: #123` or `Refs: #123, #456` format.
5. Never commit secret files (`.env`, key/token/secret credential files).
6. Never bypass hooks with `--no-verify`.
7. Never include `Ultraworked with [Sisyphus]...` or `Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>` in the commit body.
## Execution Flow
@@ -31,12 +32,13 @@ Use this workflow whenever the task includes creating a commit.
2. Stage commit target files only. Exclude suspicious secret-bearing files.
3. Draft commit message from the change intent (focus on why, not only what).
4. Run pre-commit validation with the full draft message:
- `./work/scripts/check-commit-message-rules.sh --message "<full message>"`
- `./work/scripts/check-commit-message-rules.sh --message "<full message>"`
5. If validation fails, revise message and re-run until PASS.
6. Commit using the validated message.
6. Prefer validating a message file with `./work/scripts/check-commit-message-rules.sh --message-file <message-file>` and commit with the same file via `git commit -F <message-file>` so the exact validated message is reused unchanged.
7. Run post-commit validation:
- `./work/scripts/check-commit-message-rules.sh`
8. Report executed commands and PASS/FAIL summary.
- `./work/scripts/check-commit-message-rules.sh`
8. If post-commit validation fails because an automatic footer was appended, stop and report the failure instead of treating the commit as valid.
9. Report executed commands and PASS/FAIL summary.
## Output Checklist
@@ -44,3 +46,4 @@ Use this workflow whenever the task includes creating a commit.
- Whether pre-check passed.
- Whether post-check passed.
- Any excluded files and reason.
- Whether forbidden Sisyphus footer lines were absent in the final commit body.

View File

@@ -113,17 +113,20 @@
- `type`은 소문자(`feat`, `fix`, `chore`, `docs`, `refactor`, `test` 등)를 사용한다.
- 제목(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가 없는지 함께 검증한다.
## 작업 계획 문서 규칙 (docs)
- 모든 작업 시작 전에 `docs` 폴더 아래에 계획 문서를 먼저 생성하고, 해당 문서를 기준으로 구현을 진행한다.

View File

@@ -0,0 +1,22 @@
- [x] chat 패키지의 AI 캐릭터 상세/채팅 본인인증 적용 지점을 확인한다.
- [x] 기존 캐릭터 상세의 국가별 본인인증 분기 방식을 확인한다.
- [x] chat 패키지의 AI 캐릭터 및 AI 캐릭터 채팅 로직에 동일한 국가별 인증 방식을 반영한다.
- [x] 변경 사항에 대한 진단 및 관련 검증을 수행한다.
## 검증 기록
### 1차 구현
- 무엇을: `ChatRoomController`, `ChatQuotaController`, `ChatRoomQuotaController`의 본인인증 체크를 `member.auth` 직접 검사에서 `MemberContentPreferenceService.getStoredPreference(member).isAdult` 기반 국가별 판정으로 변경했다.
- 왜: AI 캐릭터 상세와 동일하게 한국은 본인인증이 필요하고, 그 외 국가는 저장된 성인 노출 설정 기준으로 접근하도록 맞추기 위해서다.
- 어떻게:
- `./gradlew compileKotlin` → 성공
- `./gradlew test` → 성공
- 변경 컨트롤러 3개에서 `member.auth == null` 직접 검사가 제거되고 `resolveIsAdultAccessible(...)`로 치환된 것을 확인함
### 2차 수정
- 무엇을: `OriginalWorkController`의 목록/상세 본인인증 체크도 동일한 국가별 판정으로 변경했다.
- 왜: `chat/original` 하위에 `member.auth` 직접 검사 잔여 지점이 남아 있어, 최초 요청 범위인 `chat` 패키지 전체 기준으로 정책이 완전히 일치하지 않았기 때문이다.
- 어떻게:
- `./gradlew compileKotlin` → 성공
- `./gradlew test` → 성공
- `src/main/kotlin/kr/co/vividnext/sodalive/chat` 전체에서 `member.auth == null|member?.auth != null` 검색 → 결과 없음

View File

@@ -0,0 +1,41 @@
SET @schema_name := DATABASE();
SET @lang_column_exists := (
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'content_banner'
AND column_name = 'lang'
);
SET @add_lang_column_sql := IF(
@lang_column_exists = 0,
'ALTER TABLE content_banner ADD COLUMN lang VARCHAR(10) NULL COMMENT ''배너 노출 언어'' AFTER type',
'SELECT ''content_banner.lang already exists'' AS message'
);
PREPARE add_lang_column_stmt FROM @add_lang_column_sql;
EXECUTE add_lang_column_stmt;
DEALLOCATE PREPARE add_lang_column_stmt;
UPDATE content_banner
SET lang = 'KO'
WHERE lang IS NULL;
SET @lang_column_nullable := (
SELECT IS_NULLABLE
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'content_banner'
AND column_name = 'lang'
);
SET @alter_lang_column_sql := IF(
@lang_column_nullable = 'YES',
'ALTER TABLE content_banner MODIFY COLUMN lang VARCHAR(10) NOT NULL DEFAULT ''KO'' COMMENT ''배너 노출 언어 (KO 기본, EN/JA 추가 가능)''',
'SELECT ''content_banner.lang already normalized'' AS message'
);
PREPARE alter_lang_column_stmt FROM @alter_lang_column_sql;
EXECUTE alter_lang_column_stmt;
DEALLOCATE PREPARE alter_lang_column_stmt;

View File

@@ -0,0 +1,41 @@
SET @schema_name := DATABASE();
SET @lang_column_exists := (
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'chat_character_banner'
AND column_name = 'lang'
);
SET @add_lang_column_sql := IF(
@lang_column_exists = 0,
'ALTER TABLE chat_character_banner ADD COLUMN lang VARCHAR(10) NULL COMMENT ''배너 노출 언어'' AFTER sort_order',
'SELECT ''chat_character_banner.lang already exists'' AS message'
);
PREPARE add_lang_column_stmt FROM @add_lang_column_sql;
EXECUTE add_lang_column_stmt;
DEALLOCATE PREPARE add_lang_column_stmt;
UPDATE chat_character_banner
SET lang = 'KO'
WHERE lang IS NULL;
SET @lang_column_nullable := (
SELECT IS_NULLABLE
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'chat_character_banner'
AND column_name = 'lang'
);
SET @alter_lang_column_sql := IF(
@lang_column_nullable = 'YES',
'ALTER TABLE chat_character_banner MODIFY COLUMN lang VARCHAR(10) NOT NULL DEFAULT ''KO'' COMMENT ''배너 노출 언어 (KO 기본, EN/JA 추가 가능)''',
'SELECT ''chat_character_banner.lang already normalized'' AS message'
);
PREPARE alter_lang_column_stmt FROM @alter_lang_column_sql;
EXECUTE alter_lang_column_stmt;
DEALLOCATE PREPARE alter_lang_column_stmt;

View File

@@ -0,0 +1,41 @@
SET @schema_name := DATABASE();
SET @lang_column_exists := (
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'recommend_live_creator_banner'
AND column_name = 'lang'
);
SET @add_lang_column_sql := IF(
@lang_column_exists = 0,
'ALTER TABLE recommend_live_creator_banner ADD COLUMN lang VARCHAR(10) NULL COMMENT ''배너 노출 언어'' AFTER is_adult',
'SELECT ''recommend_live_creator_banner.lang already exists'' AS message'
);
PREPARE add_lang_column_stmt FROM @add_lang_column_sql;
EXECUTE add_lang_column_stmt;
DEALLOCATE PREPARE add_lang_column_stmt;
UPDATE recommend_live_creator_banner
SET lang = 'KO'
WHERE lang IS NULL;
SET @lang_column_nullable := (
SELECT IS_NULLABLE
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'recommend_live_creator_banner'
AND column_name = 'lang'
);
SET @alter_lang_column_sql := IF(
@lang_column_nullable = 'YES',
'ALTER TABLE recommend_live_creator_banner MODIFY COLUMN lang VARCHAR(10) NOT NULL DEFAULT ''KO'' COMMENT ''배너 노출 언어 (KO 기본, EN/JA 추가 가능)''',
'SELECT ''recommend_live_creator_banner.lang already normalized'' AS message'
);
PREPARE alter_lang_column_stmt FROM @alter_lang_column_sql;
EXECUTE alter_lang_column_stmt;
DEALLOCATE PREPARE alter_lang_column_stmt;

View File

@@ -0,0 +1,10 @@
- [x] 배너 목록 조회 응답 생성 경로와 언어 정보 위치를 확인한다.
- [x] 배너 목록 응답의 연결 캐릭터 이름에 배너 등록 언어를 `(언어)` 형식으로 추가한다.
- [x] 변경 파일 기준으로 검증을 수행하고 결과를 기록한다.
## 검증 기록
### 1차 구현
- 무엇을: 관리자 배너 목록 조회 응답에서 연결 캐릭터 이름 뒤에 배너 등록 언어를 `(언어)` 형식으로 붙이도록 수정했다.
- 왜: 같은 이름과 같은 이미지의 배너라도 등록 언어가 다르면 관리자 페이지에서 즉시 구분할 수 있어야 하기 때문이다.
- 어떻게: `./gradlew test --tests "kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest"` 실행으로 컨트롤러 테스트를 검증했고, 새 테스트에서 목록 조회 응답 이름이 `character-12 (일본어)`로 반환되는 것을 확인했다. 결과는 `BUILD SUCCESSFUL`이다.

View File

@@ -0,0 +1,11 @@
- [x] 추천 크리에이터 배너 등록·조회 경로와 언어 처리 기준을 확인한다.
- [x] 추천 크리에이터 등록 API에 `lang` 파라미터를 추가하고 `Lang` 기준으로 저장하도록 수정한다.
- [x] 관리자 추천 크리에이터 목록은 전체 언어를 유지하고, `LiveApiService.fetchData`의 추천 크리에이터 조회는 사용자 언어에 맞는 배너만 반환하도록 수정한다.
- [x] 변경 파일 기준으로 검증을 수행하고 결과를 기록한다.
## 검증 기록
### 1차 구현
- 무엇을: 추천 크리에이터 배너 엔티티와 관리자 등록 API에 `lang`을 추가하고, 라이브 메인 `fetchData``/live/recommend` 조회가 현재 요청 언어와 일치하는 배너만 조회하도록 수정했다. 운영 반영용으로 `recommend_live_creator_banner.lang` 컬럼 DDL 문서도 추가했다.
- 왜: 관리자에서는 언어별 추천 크리에이터 배너를 등록할 수 있어야 하고, 사용자 라이브 화면에서는 자신의 언어와 맞는 추천 크리에이터만 노출되어야 하기 때문이다. 관리자 목록 API는 기존처럼 전체 언어 배너를 그대로 조회해야 한다.
- 어떻게: Kotlin LSP가 없어 정적 진단은 Gradle 검증으로 대체했고, `./gradlew test --tests "kr.co.vividnext.sodalive.admin.live.AdminLiveServiceTest" --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendServiceTest" --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendRepositoryTest"`로 소문자 `lang` 저장, 서비스 언어 전달, 언어별 추천 배너 조회를 검증했다. 이어서 `./gradlew ktlintCheck``./gradlew build`를 실행했고 모두 `BUILD SUCCESSFUL`이다.

View File

@@ -0,0 +1,10 @@
- [x] 시리즈 배너 등록·조회 경로와 언어 처리 기준을 확인한다.
- [x] 배너 등록 시 언어를 저장하고 관리자 목록에서 시리즈 제목에 `(언어)` 표기를 추가한다.
- [x] 사용자 시리즈 메인 조회에서 요청 언어와 일치하는 배너만 반환하도록 수정하고 검증 결과를 기록한다.
## 검증 기록
### 1차 구현
- 무엇을: 시리즈 배너 등록 요청에 `lang`을 추가하고, 관리자 목록에서는 `seriesTitle (언어)` 형태로 응답하며, 사용자 시리즈 메인에서는 `LangContext`와 일치하는 언어 배너만 조회하도록 수정했다.
- 왜: 관리자 화면에서는 같은 시리즈명의 다국어 배너를 구분할 수 있어야 하고, 사용자 화면에서는 요청 언어와 맞는 배너만 노출되어야 하기 때문이다.
- 어떻게: Kotlin LSP가 없어 정적 진단은 Gradle 컴파일로 대체했고, `./gradlew test --tests "kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceTest" --tests "kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerTest" --tests "kr.co.vividnext.sodalive.content.series.main.SeriesMainControllerTest"`를 실행해 등록 언어 저장, 관리자 목록 언어 표기, 사용자 언어별 배너 조회를 검증했다. 결과는 `BUILD SUCCESSFUL`이다.

View File

@@ -0,0 +1,11 @@
- [x] 오디오 콘텐츠 배너 등록·조회 경로와 언어 처리 기준을 확인한다.
- [x] 배너 등록 API에 `lang` 파라미터를 추가하고 지원 언어를 `Lang` 기준으로 저장하도록 수정한다.
- [x] 관리자 배너 목록은 전체 언어 배너를 유지하고, HomeService `fetchData`는 사용자 언어와 일치하는 배너만 조회하도록 수정한다.
- [x] 변경 파일 기준으로 검증을 수행하고 결과를 기록한다.
## 검증 기록
### 1차 구현
- 무엇을: 오디오 콘텐츠 배너 엔티티와 등록 요청에 `lang`을 추가하고, 홈 `fetchData`에서 현재 사용자 언어를 넘겨 해당 언어 배너만 조회하도록 수정했다. 운영 반영용으로 `content_banner.lang` 컬럼 DDL도 추가했다.
- 왜: 관리자 등록 시 언어별 배너를 구분해 저장해야 하고, 홈에서는 사용자 언어와 맞는 배너만 노출되어야 하기 때문이다. 관리자 목록 API는 기존처럼 언어 전체 배너를 그대로 조회해야 한다.
- 어떻게: Kotlin LSP가 없어 정적 진단은 Gradle 컴파일/테스트로 대체했고, `./gradlew test --tests "kr.co.vividnext.sodalive.admin.content.banner.AdminContentBannerServiceTest" --tests "kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerRepositoryTest" --tests "kr.co.vividnext.sodalive.api.home.HomeServiceTest"`로 등록 언어 저장, 언어별 배너 조회, 홈 언어 전달을 검증했다. 이어서 `./gradlew ktlintCheck`를 실행해 스타일 검증까지 확인했고 두 명령 모두 `BUILD SUCCESSFUL`이다.

View File

@@ -0,0 +1,25 @@
- [x] ChatCharacterBanner 엔티티에 한국어 기본 배너와 일본어/영어 배너를 구분할 언어 필드를 유지한다.
- [x] 관리자 배너 등록 API가 기본언어 한국어를 기본값으로 사용하고, 일본어/영어 배너도 등록할 수 있도록 요청값과 서비스 로직을 수정한다.
- [x] 캐릭터 메인 배너 조회가 요청 언어 배너를 우선 조회하고, 없으면 한국어 배너를 fallback 하도록 수정한다.
- [x] 관련 테스트 또는 검증을 수행하고 결과를 기록한다.
- [x] 관리자 배너 등록 요청의 `lang`이 ISO 639 언어코드(`ko`, `en`, `ja`)로 들어와도 `Lang` enum으로 역직렬화되도록 수정한다.
## 검증 기록
### 1차 구현
- 무엇을: 채팅 캐릭터 배너를 기본 배너와 일본어 배너 행으로 분리해 저장하도록 `lang` 필드를 추가하고, 관리자 등록 API와 메인 배너 조회 로직을 일본어 기준으로 분기했다. 운영 반영용 MySQL DDL 문서 `docs/20260402_chat_character_banner_lang_ddl.sql`도 함께 추가했다.
- 왜: 현재 배너 구조는 이미지 1개 기준 행 모델이라 동일 목적지에 여러 언어 이미지를 한 레코드에 묶는 것보다, 언어별 행 분리가 기존 정렬/활성화/수정 흐름을 가장 적게 건드리는 방식이기 때문이다.
- 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceTest`로 서비스 분기와 언어 검증을 확인했고, `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest --tests kr.co.vividnext.sodalive.chat.character.controller.ChatCharacterControllerTest --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceTest`로 등록 API와 메인 조회 API 흐름을 실행 검증했다. 이어서 `./gradlew ktlintCheck`, `./gradlew build`를 실행했다.
- 결과: 지정한 테스트는 모두 성공했고, `ktlintCheck``build`도 모두 성공했다. Kotlin LSP 서버가 없어 `lsp_diagnostics`는 수행할 수 없었다.
### 2차 수정
- 무엇을: 배너 기본언어를 명시적 `KO`로 변경하고, 등록 가능 언어를 `KO`, `EN`, `JA`로 확장했다. 또한 메인 배너 조회는 요청 언어 배너가 없을 때 `KO` 배너로 fallback 하도록 수정했고, MySQL DDL도 `NULL -> KO` 데이터 정규화와 `NOT NULL DEFAULT 'KO'`로 보강했다.
- 왜: 기본 배너를 `null`로 해석하는 방식보다 `KO`를 명시 저장하는 방식이 등록 규칙과 조회 fallback 규칙을 더 일관되게 표현하고, 영어 배너 추가 요구사항도 자연스럽게 수용할 수 있기 때문이다.
- 어떻게: 서비스 로직과 테스트를 `KO/EN/JA` 기준으로 재작성하고, 관리자 등록 API 기본값과 메인 조회 경로를 대상으로 단위 테스트를 추가·수정했다. 이후 `ktlintCheck`, 대상 테스트, 전체 빌드를 다시 실행했다.
- 결과: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceTest --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest --tests kr.co.vividnext.sodalive.chat.character.controller.ChatCharacterControllerTest`, `./gradlew ktlintCheck`, `./gradlew build`를 모두 실행했고 전부 성공했다. 관리자 등록 테스트에서는 `lang`이 없을 때 `registerBanner(2L, "", null)` 호출이 발생하고 성공 응답이 반환되는 것을 확인했다. Kotlin LSP 서버는 없어 `lsp_diagnostics`는 이번에도 수행하지 못했다.
### 3차 수정
- 무엇을: `Lang` enum에 Jackson `@JsonCreator` 기반 역직렬화 진입점을 추가해 관리자 배너 등록 요청의 `lang``ko`, `en`, `ja` 같은 ISO 639 코드로 들어와도 `Lang.KO`, `Lang.EN`, `Lang.JA`로 파싱되도록 수정했다. 기존 enum 이름(`KO`, `EN`, `JA`) 입력도 계속 허용했다.
- 왜: 관리자 요청에서 `Lang` enum을 직접 받고 있으므로, 외부에서 ISO 639 코드 값을 보내더라도 별도 DTO 변환 없이 안전하게 처리되게 해야 하기 때문이다.
- 어떻게: `Lang.fromCode(...)`를 Jackson 역직렬화 팩토리로 연결하고, 관리자 배너 컨트롤러 테스트 요청값을 `"ja"`로 바꿨다. 또한 `ObjectMapper().readValue(...)``"en"` 입력이 실제 `Lang.EN`으로 역직렬화되는 테스트를 추가했다.
- 결과: `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest`, `./gradlew ktlintCheck`, `./gradlew build`를 실행했고 모두 성공했다. 관리자 배너 등록 테스트는 실제 요청 문자열 `{"characterId":1,"lang":"ja"}` 를 사용해 성공 응답과 `registerBanner(..., Lang.JA)` 호출을 확인했다. Kotlin LSP 서버는 없어 `lsp_diagnostics`는 수행하지 못했다.

View File

@@ -0,0 +1,10 @@
- [x] `CanCouponService.useCanCoupon`의 기존 본인인증 요구 조건과 국가/성인노출 관련 패턴을 확인한다.
- [x] 한국이 아닌 국가에서 `MemberContentPreference.isAdultContentVisibl``true`이면 본인인증 없이 쿠폰 사용이 가능하도록 수정한다.
- [x] 변경 파일 진단과 관련 검증을 수행하고 결과를 기록한다.
## 검증 기록
### 1차 구현
- 무엇을: `CanCouponService.useCanCoupon``MemberContentPreferenceService.getStoredPreference(member).isAdult`를 기준으로 쿠폰 사용 가능 여부를 판단하도록 수정하고, 해당 분기 회귀 테스트를 추가했다.
- 왜: 한국 사용자는 기존처럼 본인인증이 필요하고, 한국이 아닌 사용자는 성인 노출 설정이 `true`이면 본인인증 없이 쿠폰을 사용할 수 있어야 하기 때문이다.
- 어떻게: `./gradlew test --tests "kr.co.vividnext.sodalive.can.coupon.CanCouponServiceTest"` 실행 성공, `./gradlew ktlintCheck` 실행 성공.

View File

@@ -0,0 +1,15 @@
- [x] sendMessage의 외부 채팅 API 호출 경로와 요청 payload 구성을 확인한다.
- [x] 외부 `/api/chat` 요청 body에 `member.nickname``userName` 파라미터로 전달하도록 수정한다.
- [x] 변경 파일 기준으로 검증을 수행하고 결과를 기록한다.
## 검증 기록
### 1차 구현
- 무엇을: `ChatRoomService.sendMessage`가 외부 `/api/chat` 호출 시 `member.nickname``username` 파라미터로 함께 전달하도록 수정했다.
- 왜: 외부 채팅 API가 사용자 닉네임을 함께 받아야 하는 요구사항을 기존 메시지 전송 흐름 안에서 최소 범위로 반영해야 했기 때문이다.
- 어떻게: 내부 탐색으로 `/api/chat` payload 생성 위치가 `ChatRoomService.callExternalApiForChatSend`임을 확인한 뒤 `./gradlew compileKotlin``./gradlew test`를 실행했고 둘 다 `BUILD SUCCESSFUL`이었다. 추가로 `./gradlew test --tests '*ChatRoom*'`를 시도했지만 해당 패턴의 테스트 클래스는 없어 필터 검증은 불가했다.
### 2차 수정
- 무엇을: 외부 `/api/chat` 요청 body의 키 이름을 `username`에서 `userName`으로 변경했다.
- 왜: 외부 API 계약에서 사용자명 필드명이 camelCase인 `userName`으로 요구되기 때문이다.
- 어떻게: `ChatRoomService.callExternalApiForChatSend`의 request body 키가 `"userName"`으로 생성되는 것을 코드에서 재확인했다. 이 환경에는 Kotlin LSP가 구성되어 있지 않아 별도 diagnostics는 수행할 수 없었고, 대신 `./gradlew compileKotlin test`를 실행해 `BUILD SUCCESSFUL`을 확인했다.

View File

@@ -0,0 +1,10 @@
# `.omx/` Git 제외 처리
- [x] `.gitignore``.omx/`를 추가한다.
- [x] `git status``git check-ignore`로 제외가 정상 동작하는지 확인한다.
## 검증 기록
- 무엇을: `.gitignore``.omx/`를 추가하고 `.omx/` 하위 파일들이 무시되는지 확인했다.
- 왜: `.omx/`는 런타임 상태, 로그, 메트릭 파일이라 버전 관리 대상이 아니기 때문이다.
- 어떻게: `git check-ignore -v .omx/tmux-hook.json .omx/state/hud-state.json .omx/logs/turns-2026-04-06.jsonl`로 무시 규칙을 확인했고, `git status --short``.omx/`가 더 이상 추적 대상이 아니고 문서만 남는지 확인했다.

View File

@@ -0,0 +1,19 @@
SET @schema_name := DATABASE();
SET @settlement_ratio_column_exists := (
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'content'
AND column_name = 'settlement_ratio'
);
SET @add_settlement_ratio_column_sql := IF(
@settlement_ratio_column_exists = 0,
'ALTER TABLE content ADD COLUMN settlement_ratio INT NULL COMMENT ''콘텐츠별 정산 요율(%)'' AFTER price',
'SELECT ''content.settlement_ratio already exists'' AS message'
);
PREPARE add_settlement_ratio_column_stmt FROM @add_settlement_ratio_column_sql;
EXECUTE add_settlement_ratio_column_stmt;
DEALLOCATE PREPARE add_settlement_ratio_column_stmt;

View File

@@ -0,0 +1,22 @@
# 20260407 커밋 footer 자동 추가 차단
## 구현 계획
- [x] oh-my-openagent 기본 footer 동작과 저장소 로컬 커밋 워크플로우의 영향 범위를 문서화한다.
- [x] `AGENTS.md`에 커밋 본문에서 Sisyphus footer와 자동 `Co-authored-by` 라인을 허용하지 않는 규칙을 추가한다.
- [x] `.opencode/skills/commit-policy/SKILL.md`에 검증된 메시지를 그대로 `git commit`에 전달하고 자동 footer를 금지하는 절차를 반영한다.
- [x] `.opencode/commands/commit.md``/commit` 커맨드가 자동 footer 없는 최종 메시지를 사용하도록 지시를 보강한다.
- [x] `work/scripts/check-commit-message-rules.sh`에 Sisyphus footer 및 자동 `Co-authored-by` 라인 차단 검증을 추가한다.
- [x] 변경 문서와 스크립트에 대해 진단 및 실행 검증을 수행한다.
## 검증 기록
- [x] 작업 완료 후 검증 결과를 기록한다.
- 1차 구현
- 무엇을: `AGENTS.md`, `.opencode/skills/commit-policy/SKILL.md`, `.opencode/commands/commit.md`, `work/scripts/check-commit-message-rules.sh`를 수정해 커밋 본문에서 `Ultraworked with [Sisyphus]...``Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>` 자동 footer를 금지하고, `/commit` 경로가 검증된 메시지를 그대로 `git commit`에 전달하도록 명시했다.
- 왜: oh-my-openagent 기본 설정과 알려진 버그로 자동 footer가 붙을 수 있으므로, 저장소 로컬 규칙과 검증 스크립트에서 이를 명시적으로 차단해야 커밋 결과를 일관되게 통제할 수 있기 때문이다.
- 어떻게: `lsp_diagnostics``AGENTS.md`, `.opencode/skills/commit-policy/SKILL.md`, `.opencode/commands/commit.md`, `work/scripts/check-commit-message-rules.sh`, `docs/20260407_커밋footer자동추가차단.md`에 대해 모두 `No diagnostics found`를 확인했다. `bash -n work/scripts/check-commit-message-rules.sh`로 문법을 검증했고, `./work/scripts/check-commit-message-rules.sh --message`로 정상 메시지/`Refs` footer 허용 케이스는 PASS, Sisyphus footer와 자동 `Co-authored-by` 케이스는 FAIL을 확인했다. 추가로 `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 2차 수정
- 무엇을: Oracle 검토 의견을 반영해 `.opencode/skills/commit-policy/SKILL.md``.opencode/commands/commit.md`에서 `--message-file` 검증 후 같은 파일을 `git commit -F`에 전달하는 경로를 권장하도록 보강했고, `work/scripts/check-commit-message-rules.sh``Co-authored-by` 차단 조건을 공백 변형까지 탐지하도록 확장했다.
- 왜: exact string 하나만 금지하면 footer 형식이 조금만 달라져도 놓칠 수 있으므로, 외부 기본 동작이나 버그로 인한 변형까지 더 안정적으로 차단해야 하기 때문이다.
- 어떻게: `lsp_diagnostics``.opencode/skills/commit-policy/SKILL.md`, `.opencode/commands/commit.md`, `work/scripts/check-commit-message-rules.sh`에 대해 모두 `No diagnostics found`를 확인했다. `bash -n work/scripts/check-commit-message-rules.sh`를 다시 실행해 문법을 검증했고, `./work/scripts/check-commit-message-rules.sh --message`로 기본 메시지와 `Refs` footer는 PASS, Sisyphus footer/기본 `Co-authored-by`/공백 변형 `Co-authored-by` 케이스는 모두 FAIL을 확인했다.

View File

@@ -0,0 +1,85 @@
- [x] 변수명 확정: 엔티티 내부 추가 변수는 `AudioContent.settlementRatio: Int?`로 사용한다.
- 이유: `AudioContent`는 이미 콘텐츠 도메인 엔티티이므로 `contentSettlementRatio`는 중복 표현에 가깝다.
- 근거: 이 저장소의 엔티티 필드는 `AudioContent.price`, `LiveRoom.price`, `CreatorCommunity.price`처럼 엔티티 스코프 안에서는 도메인 접두어를 반복하지 않는다.
- 예외 기준: `CreatorSettlementRatio.contentSettlementRatio`처럼 하나의 엔티티 안에서 `live/content/community` 여러 정산 대상을 함께 구분해야 할 때만 `content` 접두어가 필요하다.
- DTO/API 정책: 해당 값은 관리자에서만 사용하므로 관리자 요청/응답 DTO와 API 필드명도 예외 없이 `settlementRatio`로 통일한다.
- nullable 정책: 기존 데이터와 크리에이터 정산 요율 미등록 케이스를 안전하게 수용하기 위해 초기 도입 시 `Int?`로 두고, 계산 시 `콘텐츠별 요율 -> 크리에이터 기본 요율 -> 70% 기본값` 순서로 fallback 하도록 설계한다.
- [x] `AudioContent` 엔티티에 콘텐츠별 정산 요율 필드를 추가한다.
- 대상 파일: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt`
- 작업 내용: `price` 인접 위치에 `settlementRatio: Int?` 필드를 추가하고, 기존 생성자 호출부가 모두 컴파일되도록 생성 경로를 함께 정리한다.
- [x] 관리자 콘텐츠 목록 조회 응답에 콘텐츠별 정산 요율을 노출한다.
- 대상 파일: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentRepository.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/GetAdminContentListResponse.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentService.kt`
- 작업 내용:
- `QGetAdminContentListItem(...)` QueryProjection에 `audioContent.settlementRatio`를 추가한다.
- `GetAdminContentListItem``settlementRatio: Int?`를 추가하고, 관리자 목록 응답 필드명도 동일하게 `settlementRatio`로 맞춘다.
- `AdminContentController.getAudioContentList` 응답에 정산 요율이 함께 내려가도록 조회 체인을 맞춘다.
- [x] 관리자 콘텐츠 수정 API에서 콘텐츠별 정산 요율을 수정할 수 있게 한다.
- 대상 파일: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/UpdateAdminContentRequest.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentService.kt`
- 작업 내용:
- `UpdateAdminContentRequest``settlementRatio: Int?`를 추가하고, 관리자 수정 요청 필드명도 동일하게 `settlementRatio`로 맞춘다.
- `AdminContentService.updateAudioContent`에서 요청값이 들어오면 `audioContent.settlementRatio`를 갱신한다.
- 숫자 범위 정책은 `0~100`으로 검증한다.
- 개별 콘텐츠 정산 요율 삭제는 `isSettlementRatioDeleted: true` 플래그로만 처리하고, `settlementRatio`와 동시 전달 시 invalid request 로 처리한다.
- [x] 실제 콘텐츠 정산 계산이 크리에이터 기본 요율이 아니라 콘텐츠별 요율을 우선 사용하도록 변경한다.
- 대상 파일: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateContentQueryData.kt`
- 작업 내용:
- 콘텐츠 판매/누적 판매 집계 쿼리에서 `creatorSettlementRatio.contentSettlementRatio` 직접 사용 부분을 점검한다.
- 정산 대상 비율은 `audioContent.settlementRatio`를 우선 사용하고, 값이 없을 때만 `creatorSettlementRatio.contentSettlementRatio`를 fallback 하도록 쿼리 또는 계산 DTO를 조정한다.
- 현재 `GetCalculateContentQueryData`의 70% 기본값 정책은 마지막 fallback 으로 유지한다.
- [x] 크리에이터 관리자 정산 조회도 동일 기준을 사용하도록 맞춘다.
- 대상 파일: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt`
- 작업 내용: 관리자 정산 조회와 동일하게 콘텐츠별 정산 요율 우선 정책을 반영해 관리자/크리에이터 화면 간 계산 기준이 달라지지 않게 한다.
- [x] 기존 데이터 처리 정책을 정리한다.
- 대상 범위: 운영 DB 스키마/기존 콘텐츠 데이터
- 작업 내용:
- 신규 컬럼 추가 시 nullable 로 도입하고, DDL은 `docs/20260407_audio_content_settlement_ratio_ddl.sql` 기준으로 관리한다.
- 콘텐츠 등록 시 크리에이터 기본 정산 요율을 복사하지 않고, `NULL` 상태에서도 계산이 가능하도록 fallback 순서를 유지한다.
- 운영 정책상 기존 콘텐츠에도 즉시 고정값이 필요하면 별도 SQL 또는 배치 백필 계획을 추가로 작성한다.
- [x] 영향 DTO/Q 클래스/컴파일 산출물을 재생성하고 검증한다.
- 작업 내용:
- QueryDSL projection 변경 후 Q 클래스 재생성이 필요한지 확인하고 빌드로 반영한다.
- 엔티티/관리자 DTO/API/QueryProjection 필드명이 모두 `settlementRatio`로 일치하는지 확인해 매핑 누락 가능성을 제거한다.
- [x] 검증을 단계별로 수행한다.
- 작업 내용:
- `AdminContentController.getAudioContentList`에서 정산 요율 조회 포함 여부를 확인한다.
- `AdminContentController.modifyAudioContent`에서 정산 요율 수정 반영 여부를 확인한다.
- `AdminContentController.modifyAudioContent`에서 `isSettlementRatioDeleted = true` 요청 시 개별 콘텐츠 정산 요율이 삭제되는지 확인한다.
- 콘텐츠 등록 시 `settlementRatio`가 nullable 상태로 유지되어도 계산 fallback 이 정상 동작하는지 확인한다.
- 콘텐츠 정산 조회(`AdminCalculateQueryRepository`, `CreatorAdminCalculateQueryRepository`)가 콘텐츠별 요율을 우선 적용하는지 확인한다.
- 실행 검증은 최소 `./gradlew build`, 필요 시 `./gradlew test`까지 수행한다.
## 1차 구현 검증 기록
- 무엇을: `AudioContent.settlementRatio` nullable 컬럼 추가, 관리자 목록/수정 API 반영, 콘텐츠/누적 정산 쿼리의 콘텐츠별 요율 우선 fallback 반영, 생성 시 기본 요율 복사 제거.
- 왜: 기존 콘텐츠와 미설정 콘텐츠를 `NULL`로 유지하면서도 관리자에서 개별 요율을 조회/수정하고 정산 시 올바른 fallback 순서를 적용하기 위해.
- 어떻게:
- `./gradlew build` → 성공
- `./gradlew test``build` 과정에 포함되어 성공
- 관리자/정산 API의 실서버 수동 호출 검증 → 이 로컬 작업 세션에서는 애플리케이션 실행 및 인증 가능한 테스트 데이터가 없어 미실행
## 2차 수정 검증 기록
- 무엇을: `@SpringBootTest` 없이 콘텐츠 정산 계산 DTO의 명시 비율 적용과 `null -> 70% fallback`을 검증하는 순수 단위 테스트를 추가했다.
- 왜: 정산 쿼리 변경만으로는 계산 결과가 코드상에서 충분히 고정되지 않아, 문서에 적힌 계산 규칙을 재현 가능한 테스트로 보장하기 위해.
- 어떻게:
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.ContentSettlementCalculationTest` → 성공
- 검증 대상: `GetCalculateContentQueryData`, `GetCumulativeSalesByContentQueryData`
- 검증 시나리오: `settlementRatio = 80` 적용, `settlementRatio = null` 시 70% fallback 적용
## 3차 수정 검증 기록
- 무엇을: 관리자 콘텐츠 수정 API의 개별 콘텐츠 정산 요율 삭제 방식을 명시적 null 대신 `isSettlementRatioDeleted` 플래그로 전환한다.
- 왜: 부분 업데이트 요청에서 필드 생략과 null 삭제 의미가 섞이지 않도록 API 계약을 명확히 하기 위해.
- 어떻게:
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.content.AdminContentServiceTest` → 성공
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.ContentSettlementCalculationTest` → 성공
- `./gradlew build` → 성공
- 검증 시나리오: 유효한 요율 설정, 삭제 플래그 삭제, 값/삭제 플래그 동시 전달 충돌, `null` 키/삭제 플래그 동시 전달 충돌, 범위 초과 거부, 정산 fallback 회귀 확인

View File

@@ -86,6 +86,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
val pointGroup = CaseBuilder()
.`when`(order.point.loe(0)).then(0)
.otherwise(1)
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
return queryFactory
.select(audioContent.id)
@@ -108,7 +109,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
orderFormattedDate,
order.can,
pointGroup,
creatorSettlementRatio.contentSettlementRatio
contentSettlementRatio
)
.fetch()
.size
@@ -124,6 +125,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
val pointGroup = CaseBuilder()
.`when`(order.point.loe(0)).then(0)
.otherwise(1)
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
return queryFactory
.select(
@@ -137,7 +139,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
order.id.count(),
order.can.sum(),
order.point.sum(),
creatorSettlementRatio.contentSettlementRatio
contentSettlementRatio
)
)
.from(order)
@@ -159,7 +161,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
orderFormattedDate,
order.can,
pointGroup,
creatorSettlementRatio.contentSettlementRatio
contentSettlementRatio
)
.orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc())
.offset(offset)
@@ -182,13 +184,23 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
}
fun getCumulativeSalesByContentTotalCount(): Int {
val pointGroup = CaseBuilder()
.`when`(order.point.loe(0)).then(0)
.otherwise(1)
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
return queryFactory
.select(audioContent.id)
.from(order)
.innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio)
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(order.isActive.isTrue)
.groupBy(member.id, audioContent.id, order.can)
.groupBy(member.id, audioContent.id, order.type, order.can, pointGroup, contentSettlementRatio)
.fetch()
.size
}
@@ -197,6 +209,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
val pointGroup = CaseBuilder()
.`when`(order.point.loe(0)).then(0)
.otherwise(1)
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
return queryFactory
.select(
@@ -209,7 +222,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
order.id.count(),
order.can.sum(),
order.point.sum(),
creatorSettlementRatio.contentSettlementRatio
contentSettlementRatio
)
)
.from(order)
@@ -227,7 +240,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
order.type,
order.can,
pointGroup,
creatorSettlementRatio.contentSettlementRatio
contentSettlementRatio
)
.offset(offset)
.limit(limit)

View File

@@ -62,7 +62,13 @@ class AdminChatBannerController(
val banners = bannerService.getActiveBanners(pageable)
val response = ChatCharacterBannerListPageResponse(
totalCount = banners.totalElements,
content = banners.content.map { ChatCharacterBannerResponse.from(it, imageHost) }
content = banners.content.map {
ChatCharacterBannerResponse.from(
banner = it,
imageHost = imageHost,
appendLanguageToCharacterName = true
)
}
)
ApiResponse.ok(response)
@@ -127,7 +133,8 @@ class AdminChatBannerController(
// 1. 먼저 빈 이미지 경로로 배너 등록 (정렬 순서 포함)
val banner = bannerService.registerBanner(
characterId = request.characterId,
imagePath = ""
imagePath = "",
lang = request.lang
)
// 2. 배너 ID를 사용하여 이미지 업로드

View File

@@ -1,13 +1,15 @@
package kr.co.vividnext.sodalive.admin.chat.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.i18n.Lang
/**
* 캐릭터 배너 등록 요청 DTO
*/
data class ChatCharacterBannerRegisterRequest(
// 캐릭터 ID
@JsonProperty("characterId") val characterId: Long
@JsonProperty("characterId") val characterId: Long,
@JsonProperty("lang") val lang: Lang? = null
)
/**

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.admin.chat.dto
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
import kr.co.vividnext.sodalive.i18n.Lang
/**
* 캐릭터 배너 응답 DTO
@@ -12,14 +13,30 @@ data class ChatCharacterBannerResponse(
val characterName: String
) {
companion object {
fun from(banner: ChatCharacterBanner, imageHost: String): ChatCharacterBannerResponse {
fun from(
banner: ChatCharacterBanner,
imageHost: String,
appendLanguageToCharacterName: Boolean = false
): ChatCharacterBannerResponse {
return ChatCharacterBannerResponse(
id = banner.id!!,
imagePath = "$imageHost/${banner.imagePath}",
characterId = banner.chatCharacter.id!!,
characterName = banner.chatCharacter.name
characterName = if (appendLanguageToCharacterName) {
"${banner.chatCharacter.name} (${getLanguageLabel(banner.lang)})"
} else {
banner.chatCharacter.name
}
)
}
private fun getLanguageLabel(lang: Lang): String {
return when (lang) {
Lang.KO -> "한국어"
Lang.EN -> "영어"
Lang.JA -> "일본어"
}
}
}
}

View File

@@ -110,6 +110,7 @@ class AdminAudioContentQueryRepositoryImpl(
audioContentTheme.theme,
audioContentTheme.id,
audioContent.price,
audioContent.settlementRatio,
audioContent.limited,
audioContent.remaining,
audioContent.isAdult,

View File

@@ -93,7 +93,8 @@ class AdminContentService(
@Transactional
fun updateAudioContent(coverImage: MultipartFile?, requestString: String) {
val request = objectMapper.readValue(requestString, UpdateAdminContentRequest::class.java)
val requestNode = objectMapper.readTree(requestString)
val request = objectMapper.treeToValue(requestNode, UpdateAdminContentRequest::class.java)
val audioContent = repository.findByIdOrNull(id = request.id)
?: throw SodaException(messageKey = "admin.content.not_found")
@@ -145,6 +146,18 @@ class AdminContentService(
val theme = themeRepository.findByIdAndActive(id = request.themeId)
audioContent.theme = theme
}
if (request.isSettlementRatioDeleted == true) {
if (requestNode.has("settlementRatio")) {
throw SodaException(messageKey = "common.error.invalid_request")
}
audioContent.settlementRatio = null
} else if (request.settlementRatio != null) {
if (request.settlementRatio !in 0..100) {
throw SodaException(messageKey = "common.error.invalid_request")
}
audioContent.settlementRatio = request.settlementRatio
}
}
fun getContentMainTabList(): List<GetContentMainTabItem> {

View File

@@ -18,6 +18,7 @@ data class GetAdminContentListItem @QueryProjection constructor(
val theme: String,
val themeId: Long,
val price: Int,
val settlementRatio: Int?,
val totalContentCount: Int?,
val remainingContentCount: Int?,
val isAdult: Boolean,

View File

@@ -7,6 +7,8 @@ data class UpdateAdminContentRequest(
val detail: String?,
val curationId: Long?,
val themeId: Long?,
val settlementRatio: Int?,
val isSettlementRatioDeleted: Boolean?,
val isAdult: Boolean?,
val isActive: Boolean?,
val isCommentAvailable: Boolean?

View File

@@ -72,7 +72,7 @@ class AdminContentBannerService(
null
}
val audioContentBanner = AudioContentBanner(type = request.type)
val audioContentBanner = AudioContentBanner(type = request.type, lang = request.lang)
audioContentBanner.link = request.link
audioContentBanner.isAdult = request.isAdult
audioContentBanner.event = event

View File

@@ -1,9 +1,11 @@
package kr.co.vividnext.sodalive.admin.content.banner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.i18n.Lang
data class CreateContentBannerRequest(
val type: AudioContentBannerType,
val lang: Lang,
val tabId: Long?,
val eventId: Long?,
val creatorId: Long?,

View File

@@ -56,7 +56,9 @@ class AdminContentSeriesBannerController(
val banners = bannerService.getActiveBanners(pageable)
val response = SeriesBannerListPageResponse(
totalCount = banners.totalElements,
content = banners.content.map { SeriesBannerResponse.from(it, imageHost) }
content = banners.content.map {
SeriesBannerResponse.from(it, imageHost, appendLanguageToSeriesTitle = true)
}
)
ApiResponse.ok(response)
}
@@ -82,7 +84,7 @@ class AdminContentSeriesBannerController(
val objectMapper = ObjectMapper()
val request = objectMapper.readValue(requestString, SeriesBannerRegisterRequest::class.java)
val banner = bannerService.registerBanner(seriesId = request.seriesId, imagePath = "")
val banner = bannerService.registerBanner(seriesId = request.seriesId, imagePath = "", lang = request.lang)
val imagePath = saveImage(banner.id!!, image)
val updatedBanner = bannerService.updateBanner(banner.id!!, imagePath)
val response = SeriesBannerResponse.from(updatedBanner, imageHost)

View File

@@ -2,10 +2,12 @@ package kr.co.vividnext.sodalive.admin.content.series.banner.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBanner
import kr.co.vividnext.sodalive.i18n.Lang
// 시리즈 배너 등록 요청 DTO
data class SeriesBannerRegisterRequest(
@JsonProperty("seriesId") val seriesId: Long
@JsonProperty("seriesId") val seriesId: Long,
@JsonProperty("lang") val lang: Lang? = null
)
// 시리즈 배너 수정 요청 DTO
@@ -22,14 +24,30 @@ data class SeriesBannerResponse(
val seriesTitle: String
) {
companion object {
fun from(banner: SeriesBanner, imageHost: String): SeriesBannerResponse {
fun from(
banner: SeriesBanner,
imageHost: String,
appendLanguageToSeriesTitle: Boolean = false
): SeriesBannerResponse {
return SeriesBannerResponse(
id = banner.id!!,
imagePath = "$imageHost/${banner.imagePath}",
seriesId = banner.series.id!!,
seriesTitle = banner.series.title
seriesTitle = if (appendLanguageToSeriesTitle) {
"${banner.series.title} (${getLanguageLabel(banner.lang)})"
} else {
banner.series.title
}
)
}
private fun getLanguageLabel(lang: Lang): String {
return when (lang) {
Lang.KO -> "한국어"
Lang.EN -> "영어"
Lang.JA -> "일본어"
}
}
}
}

View File

@@ -39,9 +39,10 @@ class AdminLiveController(private val service: AdminLiveService) {
@RequestParam("creator_id") creatorId: Long,
@RequestParam("start_date") startDate: String,
@RequestParam("end_date") endDate: String,
@RequestParam("is_adult") isAdult: Boolean
@RequestParam("is_adult") isAdult: Boolean,
@RequestParam("lang") lang: String
) = ApiResponse.ok(
service.createRecommendCreatorBanner(image, creatorId, startDate, endDate, isAdult),
service.createRecommendCreatorBanner(image, creatorId, startDate, endDate, isAdult, lang),
"등록되었습니다."
)

View File

@@ -17,6 +17,7 @@ import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBanner
@@ -122,7 +123,8 @@ class AdminLiveService(
creatorId: Long,
startDateString: String,
endDateString: String,
isAdult: Boolean
isAdult: Boolean,
lang: String
): Long {
if (creatorId < 1) throw SodaException(messageKey = "admin.live.creator_required")
@@ -150,10 +152,17 @@ class AdminLiveService(
if (endDate < nowDate) throw SodaException(messageKey = "admin.live.end_after_now")
if (endDate <= startDate) throw SodaException(messageKey = "admin.live.start_before_end")
val bannerLang = try {
Lang.fromCode(lang)
} catch (_: IllegalArgumentException) {
throw SodaException(messageKey = "common.error.invalid_request")
}
val recommendCreatorBanner = RecommendLiveCreatorBanner(
startDate = startDate,
endDate = endDate,
isAdult = isAdult
isAdult = isAdult,
lang = bannerLang
)
recommendCreatorBanner.creator = creator
recommendCreatorBannerRepository.save(recommendCreatorBanner)

View File

@@ -124,7 +124,8 @@ class HomeService(
val bannerList = bannerService.getBannerList(
tabId = 1,
memberId = member?.id,
isAdult = isAdult
isAdult = isAdult,
lang = langContext.lang
)
val originalAudioDramaList = seriesService.getOriginalAudioDramaList(

View File

@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.api.live
import kr.co.vividnext.sodalive.content.AudioContentService
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.live.recommend.LiveRecommendService
import kr.co.vividnext.sodalive.live.room.LiveRoomService
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
@@ -20,8 +21,8 @@ class LiveApiService(
private val recommendService: LiveRecommendService,
private val creatorCommunityService: CreatorCommunityService,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val blockMemberRepository: BlockMemberRepository
private val blockMemberRepository: BlockMemberRepository,
private val langContext: LangContext
) {
fun fetchData(
timezone: String,
@@ -49,7 +50,7 @@ class LiveApiService(
listOf()
}
val recommendLiveList = recommendService.getRecommendLive(member)
val recommendLiveList = recommendService.getRecommendLive(member, langContext.lang)
val latestFinishedLiveList = liveService.getLatestFinishedLive(member)

View File

@@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.repository.findByIdOrNull
@@ -29,6 +30,7 @@ class CanCouponService(
private val couponNumberRepository: CanCouponNumberRepository,
private val memberRepository: MemberRepository,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val objectMapper: ObjectMapper,
private val applicationEventPublisher: ApplicationEventPublisher,
@@ -133,7 +135,8 @@ class CanCouponService(
val member = memberRepository.findByIdOrNull(id = memberId)
?: throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException(messageKey = "can.coupon.auth_required")
val viewerContentPreference = memberContentPreferenceService.getStoredPreference(member)
if (!viewerContentPreference.isAdult) throw SodaException(messageKey = "can.coupon.auth_required")
issueService.validateAvailableUseCoupon(couponNumber, memberId)

View File

@@ -1,7 +1,11 @@
package kr.co.vividnext.sodalive.chat.character
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.i18n.Lang
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
@@ -24,6 +28,10 @@ class ChatCharacterBanner(
// 정렬 순서 (낮을수록 먼저 표시)
var sortOrder: Int = 0,
@Column(nullable = false)
@Enumerated(EnumType.STRING)
var lang: Lang = Lang.KO,
// 활성화 여부 (소프트 삭제용)
var isActive: Boolean = true
) : BaseEntity()

View File

@@ -62,7 +62,7 @@ class ChatCharacterController(
val isAdultAccessible = resolveIsAdultAccessible(member)
// 배너 조회 (최대 10개)
val banners = bannerService.getActiveBanners(PageRequest.of(0, 10))
val banners = bannerService.getDisplayBanners(PageRequest.of(0, 10), langContext.lang)
.content
.map {
CharacterBannerResponse(

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.chat.character.repository
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
import kr.co.vividnext.sodalive.i18n.Lang
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
@@ -12,6 +13,8 @@ interface ChatCharacterBannerRepository : JpaRepository<ChatCharacterBanner, Lon
// 활성화된 배너 목록 조회 (정렬 순서대로)
fun findByIsActiveTrueOrderBySortOrderAsc(pageable: Pageable): Page<ChatCharacterBanner>
fun findByIsActiveTrueAndLangOrderBySortOrderAsc(lang: Lang, pageable: Pageable): Page<ChatCharacterBanner>
// 활성화된 배너 중 최대 정렬 순서 값 조회
@Query("SELECT MAX(b.sortOrder) FROM ChatCharacterBanner b WHERE b.isActive = true")
fun findMaxSortOrder(): Int?

View File

@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterBannerRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.Lang
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
@@ -21,6 +22,19 @@ class ChatCharacterBannerService(
return bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable)
}
fun getDisplayBanners(pageable: Pageable, lang: Lang): Page<ChatCharacterBanner> {
if (lang == Lang.KO) {
return bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.KO, pageable)
}
val localizedBanners = bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(lang, pageable)
return if (localizedBanners.hasContent()) {
localizedBanners
} else {
bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.KO, pageable)
}
}
/**
* 배너 상세 조회
*/
@@ -37,7 +51,10 @@ class ChatCharacterBannerService(
* @return 등록된 배너
*/
@Transactional
fun registerBanner(characterId: Long, imagePath: String): ChatCharacterBanner {
fun registerBanner(characterId: Long, imagePath: String, lang: Lang? = null): ChatCharacterBanner {
val finalLang = lang ?: Lang.KO
validateRegisterLang(finalLang)
val character = characterRepository.findById(characterId)
.orElseThrow { SodaException(messageKey = "chat.character.not_found") }
@@ -51,12 +68,21 @@ class ChatCharacterBannerService(
val banner = ChatCharacterBanner(
imagePath = imagePath,
chatCharacter = character,
sortOrder = finalSortOrder
sortOrder = finalSortOrder,
lang = finalLang
)
return bannerRepository.save(banner)
}
private fun validateRegisterLang(lang: Lang) {
if (lang == Lang.KO || lang == Lang.EN || lang == Lang.JA) {
return
}
throw SodaException(messageKey = "common.error.invalid_request")
}
/**
* 배너 수정
*

View File

@@ -14,6 +14,7 @@ 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.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
@@ -33,6 +34,7 @@ import java.time.LocalDateTime
class OriginalWorkController(
private val queryService: OriginalWorkQueryService,
private val characterImageRepository: CharacterImageRepository,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val langContext: LangContext,
@@ -58,7 +60,7 @@ class OriginalWorkController(
@RequestParam(defaultValue = "20") size: Int,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
val includeAdult = member?.auth != null
val includeAdult = resolveIsAdultAccessible(member)
val pageRes = queryService.listForAppPage(includeAdult, page, size)
val content = pageRes.content.map { OriginalWorkListItemResponse.from(it, imageHost) }
@@ -127,7 +129,7 @@ class OriginalWorkController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required")
val ow = queryService.getOriginalWork(id)
val chars = queryService.getActiveCharactersPage(id, page = 0, size = 20).content
@@ -196,4 +198,12 @@ class OriginalWorkController(
)
)
}
private fun resolveIsAdultAccessible(member: Member?): Boolean {
if (member == null) {
return false
}
return memberContentPreferenceService.getStoredPreference(member).isAdult
}
}

View File

@@ -5,6 +5,7 @@ import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
@@ -16,7 +17,8 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/api/chat/quota")
class ChatQuotaController(
private val chatQuotaService: ChatQuotaService,
private val canPaymentService: CanPaymentService
private val canPaymentService: CanPaymentService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
data class ChatQuotaStatusResponse(
@@ -33,7 +35,7 @@ class ChatQuotaController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
): ApiResponse<ChatQuotaStatusResponse> = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required")
val s = chatQuotaService.getStatus(member.id!!)
ApiResponse.ok(ChatQuotaStatusResponse(s.totalRemaining, s.nextRechargeAtEpochMillis))
@@ -45,10 +47,9 @@ class ChatQuotaController(
@RequestBody request: ChatQuotaPurchaseRequest
): ApiResponse<ChatQuotaStatusResponse> = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required")
if (request.container.isBlank()) throw SodaException(messageKey = "chat.quota.container_required")
// 30캔 차감 처리 (결제 기록 남김)
canPaymentService.spendCan(
memberId = member.id!!,
needCan = 30,
@@ -56,8 +57,15 @@ class ChatQuotaController(
container = request.container
)
// 글로벌 유료 개념 제거됨: 구매 성공 시에도 글로벌 쿼터 증액 없음
val s = chatQuotaService.getStatus(member.id!!)
ApiResponse.ok(ChatQuotaStatusResponse(s.totalRemaining, s.nextRechargeAtEpochMillis))
}
private fun resolveIsAdultAccessible(member: Member?): Boolean {
if (member == null) {
return false
}
return memberContentPreferenceService.getStoredPreference(member).isAdult
}
}

View File

@@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
@@ -21,7 +22,8 @@ class ChatRoomQuotaController(
private val chatRoomRepository: ChatRoomRepository,
private val participantRepository: ChatParticipantRepository,
private val chatRoomQuotaService: ChatRoomQuotaService,
private val chatQuotaService: ChatQuotaService
private val chatQuotaService: ChatQuotaService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
data class PurchaseRoomQuotaRequest(
@@ -53,17 +55,15 @@ class ChatRoomQuotaController(
@RequestBody req: PurchaseRoomQuotaRequest
): ApiResponse<PurchaseRoomQuotaResponse> = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required")
if (req.container.isBlank()) throw SodaException(messageKey = "chat.room.quota.invalid_access")
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
?: throw SodaException(messageKey = "chat.error.room_not_found")
// 내 참여 여부 확인
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
?: throw SodaException(messageKey = "chat.room.quota.invalid_access")
// 캐릭터 참여자 확인(유효한 AI 캐릭터 방인지 체크 및 characterId 기본값 보조)
val characterParticipant = participantRepository
.findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER)
?: throw SodaException(messageKey = "chat.room.quota.not_ai_room")
@@ -74,7 +74,6 @@ class ChatRoomQuotaController(
val characterId = character.id
?: throw SodaException(messageKey = "chat.room.quota.character_required")
// 서비스에서 결제 포함하여 처리
val status = chatRoomQuotaService.purchase(
memberId = member.id!!,
chatRoomId = chatRoomId,
@@ -99,24 +98,20 @@ class ChatRoomQuotaController(
@PathVariable chatRoomId: Long
): ApiResponse<RoomQuotaStatusResponse> = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required")
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
?: throw SodaException(messageKey = "chat.error.room_not_found")
// 내 참여 여부 확인
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
?: throw SodaException(messageKey = "chat.room.quota.invalid_access")
// 캐릭터 확인
val characterParticipant = participantRepository
.findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER)
?: throw SodaException(messageKey = "chat.room.quota.not_ai_room")
val character = characterParticipant.character
?: throw SodaException(messageKey = "chat.room.quota.not_ai_room")
// 글로벌 Lazy refill
val globalStatus = chatQuotaService.getStatus(member.id!!)
// 룸 Lazy refill 상태
val roomStatus = chatRoomQuotaService.applyRefillOnEnterAndGetStatus(
memberId = member.id!!,
chatRoomId = chatRoomId,
@@ -136,4 +131,12 @@ class ChatRoomQuotaController(
)
)
}
private fun resolveIsAdultAccessible(member: Member?): Boolean {
if (member == null) {
return false
}
return memberContentPreferenceService.getStoredPreference(member).isAdult
}
}

View File

@@ -8,6 +8,7 @@ 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.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
@@ -20,7 +21,8 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/chat/room")
class ChatRoomController(
private val chatRoomService: ChatRoomService
private val chatRoomService: ChatRoomService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
/**
@@ -43,7 +45,7 @@ class ChatRoomController(
@RequestBody request: CreateChatRoomRequest
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required")
val response = chatRoomService.createOrGetChatRoom(member, request.characterId)
ApiResponse.ok(response)
@@ -59,7 +61,7 @@ class ChatRoomController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@RequestParam(defaultValue = "0") page: Int
) = run {
if (member == null || member.auth == null) {
if (member == null || !resolveIsAdultAccessible(member)) {
ApiResponse.ok(emptyList())
} else {
val response = chatRoomService.listMyChatRooms(member, page)
@@ -78,7 +80,7 @@ class ChatRoomController(
@PathVariable chatRoomId: Long
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required")
val isActive = chatRoomService.isMyRoomSessionActive(member, chatRoomId)
ApiResponse.ok(isActive)
@@ -96,7 +98,7 @@ class ChatRoomController(
@RequestParam(required = false) characterImageId: Long?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required")
val response = chatRoomService.enterChatRoom(member, chatRoomId, characterImageId)
ApiResponse.ok(response)
@@ -115,7 +117,7 @@ class ChatRoomController(
@PathVariable chatRoomId: Long
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required")
chatRoomService.leaveChatRoom(member, chatRoomId)
ApiResponse.ok(true)
@@ -135,7 +137,7 @@ class ChatRoomController(
@RequestParam(required = false) cursor: Long?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required")
val response = chatRoomService.getChatMessages(member, chatRoomId, cursor, limit)
ApiResponse.ok(response)
@@ -154,7 +156,7 @@ class ChatRoomController(
@RequestBody request: SendChatMessageRequest
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required")
if (request.message.isBlank()) {
ApiResponse.error()
@@ -177,7 +179,7 @@ class ChatRoomController(
@RequestBody request: ChatMessagePurchaseRequest
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required")
val result = chatRoomService.purchaseMessage(member, chatRoomId, messageId, request.container)
ApiResponse.ok(result)
@@ -196,9 +198,17 @@ class ChatRoomController(
@RequestBody request: ChatRoomResetRequest
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required")
val response = chatRoomService.resetChatRoom(member, chatRoomId, request.container)
ApiResponse.ok(response)
}
private fun resolveIsAdultAccessible(member: Member?): Boolean {
if (member == null) {
return false
}
return memberContentPreferenceService.getStoredPreference(member).isAdult
}
}

View File

@@ -35,6 +35,7 @@ data class AudioContent(
var languageCode: String?,
var playCount: Long = 0,
var price: Int = 0,
var settlementRatio: Int? = null,
var releaseDate: LocalDateTime? = null,
val limited: Int? = null,
var remaining: Int? = null,

View File

@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.event.Event
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import javax.persistence.Column
import javax.persistence.Entity
@@ -22,6 +23,9 @@ data class AudioContentBanner(
@Enumerated(value = EnumType.STRING)
var type: AudioContentBannerType,
@Column(nullable = false)
@Enumerated(value = EnumType.STRING)
var lang: Lang = Lang.KO,
@Column(nullable = false)
var isAdult: Boolean = false,
@Column(nullable = false)
var isActive: Boolean = true,

View File

@@ -4,19 +4,20 @@ import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner
import kr.co.vividnext.sodalive.content.main.tab.QAudioContentMainTab.audioContentMainTab
import kr.co.vividnext.sodalive.event.QEvent.event
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.data.jpa.repository.JpaRepository
interface AudioContentBannerRepository : JpaRepository<AudioContentBanner, Long>, AudioContentBannerQueryRepository
interface AudioContentBannerQueryRepository {
fun getAudioContentMainBannerList(tabId: Long, isAdult: Boolean): List<AudioContentBanner>
fun getAudioContentMainBannerList(tabId: Long, isAdult: Boolean, lang: Lang? = null): List<AudioContentBanner>
}
class AudioContentBannerQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : AudioContentBannerQueryRepository {
override fun getAudioContentMainBannerList(tabId: Long, isAdult: Boolean): List<AudioContentBanner> {
override fun getAudioContentMainBannerList(tabId: Long, isAdult: Boolean, lang: Lang?): List<AudioContentBanner> {
var where = audioContentBanner.isActive.isTrue
where = if (tabId == 1L) {
@@ -29,6 +30,10 @@ class AudioContentBannerQueryRepositoryImpl(
where = where.and(audioContentBanner.isAdult.isFalse)
}
if (lang != null) {
where = where.and(audioContentBanner.lang.eq(lang))
}
return queryFactory
.selectFrom(audioContentBanner)
.leftJoin(audioContentBanner.tab, audioContentMainTab)

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.content.main.banner
import kr.co.vividnext.sodalive.event.EventItem
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
@@ -13,8 +14,8 @@ class AudioContentBannerService(
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
fun getBannerList(tabId: Long, memberId: Long?, isAdult: Boolean): List<GetAudioContentBannerResponse> {
return repository.getAudioContentMainBannerList(tabId, isAdult)
fun getBannerList(tabId: Long, memberId: Long?, isAdult: Boolean, lang: Lang? = null): List<GetAudioContentBannerResponse> {
return repository.getAudioContentMainBannerList(tabId, isAdult, lang)
.filter {
if (it.type == AudioContentBannerType.CREATOR && it.creator != null && memberId != null) {
!isBlockedBetweenMembers(memberId = memberId, creatorId = it.creator!!.id!!)

View File

@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.beans.factory.annotation.Value
@@ -21,6 +22,7 @@ import org.springframework.web.bind.annotation.RestController
class SeriesMainController(
private val contentSeriesService: ContentSeriesService,
private val bannerService: ContentSeriesBannerService,
private val langContext: LangContext,
private val memberContentPreferenceService: MemberContentPreferenceService,
@Value("\${cloud.aws.cloud-front.host}")
@@ -33,7 +35,7 @@ class SeriesMainController(
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member)
val banners = bannerService.getActiveBanners(PageRequest.of(0, 10))
val banners = bannerService.getDisplayBanners(PageRequest.of(0, 10), langContext.lang)
.content
.map {
SeriesBannerResponse.from(it, imageHost)

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.content.series.main.banner
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.Lang
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
@@ -16,22 +17,28 @@ class ContentSeriesBannerService(
return bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable)
}
fun getDisplayBanners(pageable: Pageable, lang: Lang): Page<SeriesBanner> {
return bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(lang, pageable)
}
fun getBannerById(bannerId: Long): SeriesBanner {
return bannerRepository.findById(bannerId)
.orElseThrow { SodaException(messageKey = "series.banner.error.not_found") }
}
@Transactional
fun registerBanner(seriesId: Long, imagePath: String): SeriesBanner {
fun registerBanner(seriesId: Long, imagePath: String, lang: Lang? = null): SeriesBanner {
val series = seriesRepository.findByIdAndActiveTrue(seriesId)
?: throw SodaException(messageKey = "series.banner.error.series_not_found")
val finalSortOrder = (bannerRepository.findMaxSortOrder() ?: 0) + 1
val finalLang = lang ?: Lang.KO
val banner = SeriesBanner(
imagePath = imagePath,
series = series,
sortOrder = finalSortOrder
sortOrder = finalSortOrder,
lang = finalLang
)
return bannerRepository.save(banner)
}

View File

@@ -2,7 +2,11 @@ package kr.co.vividnext.sodalive.content.series.main.banner
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.i18n.Lang
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
@@ -25,6 +29,10 @@ class SeriesBanner(
// 정렬 순서 (낮을수록 먼저 표시)
var sortOrder: Int = 0,
@Column(nullable = false)
@Enumerated(EnumType.STRING)
var lang: Lang = Lang.KO,
// 활성화 여부 (소프트 삭제용)
var isActive: Boolean = true
) : BaseEntity()

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.content.series.main.banner
import kr.co.vividnext.sodalive.i18n.Lang
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
@@ -10,6 +11,8 @@ import org.springframework.stereotype.Repository
interface SeriesBannerRepository : JpaRepository<SeriesBanner, Long> {
fun findByIsActiveTrueOrderBySortOrderAsc(pageable: Pageable): Page<SeriesBanner>
fun findByIsActiveTrueAndLangOrderBySortOrderAsc(lang: Lang, pageable: Pageable): Page<SeriesBanner>
@Query("SELECT MAX(b.sortOrder) FROM SeriesBanner b WHERE b.isActive = true")
fun findMaxSortOrder(): Int?
}

View File

@@ -74,18 +74,28 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
memberId: Long
): Int {
val orderFormattedDate = getFormattedDate(order.createdAt)
val pointGroup = CaseBuilder()
.`when`(order.point.loe(0)).then(0)
.otherwise(1)
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
return queryFactory
.select(audioContent.id)
.from(order)
.innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio)
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
order.createdAt.goe(startDate)
.and(order.createdAt.loe(endDate))
.and(order.isActive.isTrue)
.and(order.creator.id.eq(memberId))
)
.groupBy(audioContent.id, order.type, orderFormattedDate, order.can)
.groupBy(audioContent.id, order.type, orderFormattedDate, order.can, pointGroup, contentSettlementRatio)
.orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc())
.fetch()
.size
@@ -102,6 +112,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
val pointGroup = CaseBuilder()
.`when`(order.point.loe(0)).then(0)
.otherwise(1)
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
return queryFactory
.select(
@@ -115,7 +126,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
order.id.count(),
order.can.sum(),
order.point.sum(),
creatorSettlementRatio.contentSettlementRatio
contentSettlementRatio
)
)
.from(order)
@@ -138,7 +149,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
orderFormattedDate,
order.can,
pointGroup,
creatorSettlementRatio.contentSettlementRatio
contentSettlementRatio
)
.offset(offset)
.limit(limit)
@@ -161,16 +172,26 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
}
fun getCumulativeSalesByContentTotalCount(memberId: Long): Int {
val pointGroup = CaseBuilder()
.`when`(order.point.loe(0)).then(0)
.otherwise(1)
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
return queryFactory
.select(audioContent.id)
.from(order)
.innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio)
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
audioContent.member.id.eq(memberId)
.and(order.isActive.isTrue)
)
.groupBy(member.id, audioContent.id, order.can)
.groupBy(member.id, audioContent.id, order.type, order.can, pointGroup, contentSettlementRatio)
.fetch()
.size
}
@@ -183,6 +204,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
val pointGroup = CaseBuilder()
.`when`(order.point.loe(0)).then(0)
.otherwise(1)
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
return queryFactory
.select(
@@ -195,7 +217,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
order.id.count(),
order.can.sum(),
order.point.sum(),
creatorSettlementRatio.contentSettlementRatio
contentSettlementRatio
)
)
.from(order)
@@ -216,7 +238,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
order.type,
order.can,
pointGroup,
creatorSettlementRatio.contentSettlementRatio
contentSettlementRatio
)
.offset(offset)
.limit(limit)

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.i18n
import com.fasterxml.jackson.annotation.JsonCreator
import java.util.Locale
enum class Lang(val code: String, val locale: Locale) {
@@ -8,6 +9,14 @@ enum class Lang(val code: String, val locale: Locale) {
JA("ja", Locale.JAPANESE);
companion object {
@JvmStatic
@JsonCreator(mode = JsonCreator.Mode.DELEGATING)
fun fromCode(value: String): Lang {
return values().find {
it.code.equals(value.trim(), ignoreCase = true) || it.name.equals(value.trim(), ignoreCase = true)
} ?: throw IllegalArgumentException("Unknown language code: $value")
}
fun fromAcceptLanguage(header: String?): Lang {
if (header.isNullOrBlank()) return KO
val two = header.trim().lowercase().take(2) // 앱은 2자리만 보내지만 안전하게 처리

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.live.recommend
import kr.co.vividnext.sodalive.i18n.Lang
import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Service
@@ -9,12 +10,13 @@ class LiveRecommendCacheService(
) {
@Cacheable(
cacheNames = ["cache_ttl_3_hours"],
key = "'getRecommendLive:' + (#memberId ?: 'guest') + ':' + #isAdult"
key = "'getRecommendLive:' + (#memberId ?: 'guest') + ':' + #isAdult + ':' + #lang.name()"
)
fun getRecommendLive(memberId: Long?, isAdult: Boolean): List<GetRecommendLiveResponse> {
fun getRecommendLive(memberId: Long?, isAdult: Boolean, lang: Lang): List<GetRecommendLiveResponse> {
return repository.getRecommendLive(
memberId = memberId,
isAdult = isAdult
isAdult = isAdult,
lang = lang
)
}
}

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.live.recommend
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.member.Member
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
@@ -11,12 +12,15 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/live/recommend")
class LiveRecommendController(private val service: LiveRecommendService) {
class LiveRecommendController(
private val service: LiveRecommendService,
private val langContext: LangContext
) {
@GetMapping
fun getRecommendLive(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(service.getRecommendLive(member))
ApiResponse.ok(service.getRecommendLive(member, langContext.lang))
}
@GetMapping("/channel")

View File

@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.live.recommend
import com.querydsl.core.types.Projections
import com.querydsl.core.types.dsl.Expressions
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.live.recommend.QRecommendLiveCreatorBanner.recommendLiveCreatorBanner
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
import kr.co.vividnext.sodalive.member.MemberRole
@@ -22,12 +23,14 @@ class LiveRecommendRepository(
) {
fun getRecommendLive(
memberId: Long?,
isAdult: Boolean
isAdult: Boolean,
lang: Lang
): List<GetRecommendLiveResponse> {
val dateNow = LocalDateTime.now()
var where = recommendLiveCreatorBanner.startDate.loe(dateNow)
.and(recommendLiveCreatorBanner.endDate.goe(dateNow))
.and(recommendLiveCreatorBanner.lang.eq(lang))
if (!isAdult) {
where = where.and(recommendLiveCreatorBanner.isAdult.isFalse)

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.live.recommend
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
@@ -14,7 +15,7 @@ class LiveRecommendService(
private val memberContentPreferenceService: MemberContentPreferenceService,
private val liveRecommendCacheService: LiveRecommendCacheService
) {
fun getRecommendLive(member: Member?): List<GetRecommendLiveResponse> {
fun getRecommendLive(member: Member?, lang: Lang): List<GetRecommendLiveResponse> {
val isAdult = if (member != null) {
memberContentPreferenceService.getStoredPreference(member).isAdult
} else {
@@ -23,7 +24,8 @@ class LiveRecommendService(
return liveRecommendCacheService.getRecommendLive(
memberId = member?.id,
isAdult = isAdult
isAdult = isAdult,
lang = lang
)
}

View File

@@ -1,10 +1,13 @@
package kr.co.vividnext.sodalive.live.recommend
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import java.time.LocalDateTime
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
@@ -18,6 +21,9 @@ data class RecommendLiveCreatorBanner(
@Column(nullable = false)
var isAdult: Boolean = false,
@Column(nullable = false)
@Enumerated(EnumType.STRING)
var lang: Lang = Lang.KO,
@Column(nullable = false)
var orders: Int = 1,
@Column(nullable = true)
var image: String? = null

View File

@@ -0,0 +1,102 @@
package kr.co.vividnext.sodalive.admin.calculate
import kr.co.vividnext.sodalive.content.order.OrderType
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
class ContentSettlementCalculationTest {
@Test
@DisplayName("콘텐츠 정산 응답은 콘텐츠별 정산 요율을 우선 적용한다")
fun shouldApplyExplicitSettlementRatioForCalculateContentResponse() {
val result = GetCalculateContentQueryData(
nickname = "creator",
title = "content",
registrationDate = "2026-04-07",
saleDate = "2026-04-07",
orderType = OrderType.KEEP,
orderPrice = 100,
numberOfPeople = 2,
totalCan = 100,
totalPoint = 0,
settlementRatio = 80
).toGetCalculateContentResponse()
assertEquals("소장", result.orderType)
assertEquals(10_000, result.totalKrw)
assertEquals(660, result.paymentFee)
assertEquals(7_472, result.settlementAmount)
assertEquals(247, result.tax)
assertEquals(7_225, result.depositAmount)
}
@Test
@DisplayName("콘텐츠 정산 응답은 정산 요율이 없으면 70퍼센트 기본값으로 계산한다")
fun shouldFallbackToDefaultSettlementRatioForCalculateContentResponse() {
val result = GetCalculateContentQueryData(
nickname = "creator",
title = "content",
registrationDate = "2026-04-07",
saleDate = "2026-04-07",
orderType = OrderType.RENTAL,
orderPrice = 70,
numberOfPeople = 1,
totalCan = 100,
totalPoint = 0,
settlementRatio = null
).toGetCalculateContentResponse()
assertEquals("대여", result.orderType)
assertEquals(10_000, result.totalKrw)
assertEquals(660, result.paymentFee)
assertEquals(6_538, result.settlementAmount)
assertEquals(216, result.tax)
assertEquals(6_322, result.depositAmount)
}
@Test
@DisplayName("누적 콘텐츠 정산 응답은 콘텐츠별 정산 요율을 우선 적용한다")
fun shouldApplyExplicitSettlementRatioForCumulativeSalesResponse() {
val result = GetCumulativeSalesByContentQueryData(
nickname = "creator",
title = "content",
registrationDate = "2026-04-07",
orderType = OrderType.KEEP,
orderPrice = 100,
numberOfPeople = 2,
totalCan = 100,
totalPoint = 0,
settlementRatio = 80
).toCumulativeSalesByContentItem()
assertEquals("소장", result.orderType)
assertEquals(10_000, result.totalKrw)
assertEquals(660, result.paymentFee)
assertEquals(7_472, result.settlementAmount)
assertEquals(247, result.tax)
assertEquals(7_225, result.depositAmount)
}
@Test
@DisplayName("누적 콘텐츠 정산 응답은 정산 요율이 없으면 70퍼센트 기본값으로 계산한다")
fun shouldFallbackToDefaultSettlementRatioForCumulativeSalesResponse() {
val result = GetCumulativeSalesByContentQueryData(
nickname = "creator",
title = "content",
registrationDate = "2026-04-07",
orderType = OrderType.RENTAL,
orderPrice = 70,
numberOfPeople = 1,
totalCan = 100,
totalPoint = 0,
settlementRatio = null
).toCumulativeSalesByContentItem()
assertEquals("대여", result.orderType)
assertEquals(10_000, result.totalKrw)
assertEquals(660, result.paymentFee)
assertEquals(6_538, result.settlementAmount)
assertEquals(216, result.tax)
assertEquals(6_322, result.depositAmount)
}
}

View File

@@ -0,0 +1,145 @@
package kr.co.vividnext.sodalive.admin.chat
import com.amazonaws.services.s3.AmazonS3Client
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.mock.web.MockMultipartFile
import java.net.URL
class AdminChatBannerControllerTest {
private val bannerService = Mockito.mock(ChatCharacterBannerService::class.java)
private val adminCharacterService = Mockito.mock(AdminChatCharacterService::class.java)
private val amazonS3Client = Mockito.mock(AmazonS3Client::class.java)
private val s3Uploader = S3Uploader(amazonS3Client)
private val controller = AdminChatBannerController(
bannerService = bannerService,
adminCharacterService = adminCharacterService,
s3Uploader = s3Uploader,
langContext = LangContext(),
messageSource = SodaMessageSource(),
s3Bucket = "test-bucket",
imageHost = "https://cdn.test"
)
@Test
fun shouldRegisterJapaneseBannerThroughAdminApi() {
val image = MockMultipartFile("image", "banner.png", "image/png", "image".toByteArray())
val registeredBanner = createBanner(id = 10L, lang = Lang.JA, imagePath = "")
val updatedBanner = createBanner(id = 10L, lang = Lang.JA, imagePath = "")
Mockito.`when`(amazonS3Client.getUrl(Mockito.eq("test-bucket"), Mockito.anyString()))
.thenAnswer { URL("https://cdn.test/${it.arguments[1]}") }
Mockito.`when`(
bannerService.registerBanner(
characterId = 1L,
imagePath = "",
lang = Lang.JA
)
).thenReturn(registeredBanner)
Mockito.doAnswer {
updatedBanner.apply {
imagePath = it.arguments[1] as String
}
}.`when`(bannerService).updateBanner(Mockito.eq(10L), Mockito.anyString(), Mockito.isNull())
val response = controller.registerBanner(
image = image,
requestString = "{\"characterId\":1,\"lang\":\"ja\"}"
)
assertTrue(response.success)
assertEquals(10L, response.data?.id)
assertTrue(response.data?.imagePath?.startsWith("https://cdn.test/characters/banners/10/") == true)
Mockito.verify(bannerService).registerBanner(1L, "", Lang.JA)
}
@Test
fun shouldDeserializeIso639LanguageCodeToLangEnum() {
val request = ObjectMapper().readValue(
"{\"characterId\":1,\"lang\":\"en\"}",
kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerRegisterRequest::class.java
)
assertEquals(Lang.EN, request.lang)
}
@Test
fun shouldRegisterKoreanBannerByDefaultWhenLangIsMissing() {
val image = MockMultipartFile("image", "banner.png", "image/png", "image".toByteArray())
val registeredBanner = createBanner(id = 11L, lang = Lang.KO, imagePath = "")
val updatedBanner = createBanner(id = 11L, lang = Lang.KO, imagePath = "")
Mockito.`when`(amazonS3Client.getUrl(Mockito.eq("test-bucket"), Mockito.anyString()))
.thenAnswer { URL("https://cdn.test/${it.arguments[1]}") }
Mockito.`when`(
bannerService.registerBanner(
characterId = 2L,
imagePath = "",
lang = null
)
).thenReturn(registeredBanner)
Mockito.doAnswer {
updatedBanner.apply {
imagePath = it.arguments[1] as String
}
}.`when`(bannerService).updateBanner(Mockito.eq(11L), Mockito.anyString(), Mockito.isNull())
val response = controller.registerBanner(
image = image,
requestString = "{\"characterId\":2}"
)
assertTrue(response.success)
assertEquals(11L, response.data?.id)
Mockito.verify(bannerService).registerBanner(2L, "", null)
}
@Test
fun shouldAppendBannerLanguageToCharacterNameInBannerList() {
val pageable = PageRequest.of(0, 20)
val japaneseBanner = createBanner(id = 12L, lang = Lang.JA, imagePath = "banner/jp.png")
Mockito.`when`(adminCharacterService.createDefaultPageRequest(0, 20)).thenReturn(pageable)
Mockito.`when`(bannerService.getActiveBanners(pageable))
.thenReturn(PageImpl(listOf(japaneseBanner), pageable, 1))
val response = controller.getBannerList(page = 0, size = 20)
assertTrue(response.success)
assertEquals("character-12 (일본어)", response.data?.content?.first()?.characterName)
}
private fun createBanner(id: Long, lang: Lang, imagePath: String): ChatCharacterBanner {
val character = ChatCharacter(
characterUUID = "character-$id",
name = "character-$id",
description = "description-$id",
systemPrompt = "system-prompt-$id"
)
character.id = id
return ChatCharacterBanner(
imagePath = imagePath,
chatCharacter = character,
sortOrder = 1,
lang = lang
).also {
it.id = id
}
}
}

View File

@@ -0,0 +1,166 @@
package kr.co.vividnext.sodalive.admin.content
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kr.co.vividnext.sodalive.admin.content.curation.AdminContentCurationRepository
import kr.co.vividnext.sodalive.admin.content.tab.AdminContentMainTabRepository
import kr.co.vividnext.sodalive.admin.content.theme.AdminContentThemeRepository
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.AudioContent
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import java.util.Optional
class AdminContentServiceTest {
private lateinit var repository: AdminContentRepository
private lateinit var themeRepository: AdminContentThemeRepository
private lateinit var audioContentCloudFront: AudioContentCloudFront
private lateinit var curationRepository: AdminContentCurationRepository
private lateinit var contentMainTabRepository: AdminContentMainTabRepository
private lateinit var s3Uploader: S3Uploader
private lateinit var service: AdminContentService
@BeforeEach
fun setup() {
repository = Mockito.mock(AdminContentRepository::class.java)
themeRepository = Mockito.mock(AdminContentThemeRepository::class.java)
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java)
curationRepository = Mockito.mock(AdminContentCurationRepository::class.java)
contentMainTabRepository = Mockito.mock(AdminContentMainTabRepository::class.java)
s3Uploader = Mockito.mock(S3Uploader::class.java)
service = AdminContentService(
repository = repository,
themeRepository = themeRepository,
audioContentCloudFront = audioContentCloudFront,
curationRepository = curationRepository,
contentMainTabRepository = contentMainTabRepository,
objectMapper = jacksonObjectMapper(),
s3Uploader = s3Uploader,
bucket = "test-bucket"
)
}
@Test
@DisplayName("관리자 콘텐츠 수정은 유효한 개별 정산 요율을 저장한다")
fun shouldUpdateSettlementRatioWhenValidValueIsProvided() {
val audioContent = createAudioContent(settlementRatio = 70)
Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(audioContent))
service.updateAudioContent(
coverImage = null,
requestString = """
{"id":1,"isDefaultCoverImage":false,"settlementRatio":80}
""".trimIndent()
)
assertEquals(80, audioContent.settlementRatio)
}
@Test
@DisplayName("관리자 콘텐츠 수정은 삭제 플래그로 개별 정산 요율을 삭제한다")
fun shouldDeleteSettlementRatioWhenDeleteFlagIsTrue() {
val audioContent = createAudioContent(settlementRatio = 70)
Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(audioContent))
service.updateAudioContent(
coverImage = null,
requestString = """
{"id":1,"isDefaultCoverImage":false,"isSettlementRatioDeleted":true}
""".trimIndent()
)
assertNull(audioContent.settlementRatio)
}
@Test
@DisplayName("관리자 콘텐츠 수정은 삭제 플래그와 정산 요율을 함께 보내면 예외를 던진다")
fun shouldThrowWhenDeleteFlagAndSettlementRatioAreProvidedTogether() {
val audioContent = createAudioContent(settlementRatio = 70)
Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(audioContent))
val exception = assertThrows(SodaException::class.java) {
service.updateAudioContent(
coverImage = null,
requestString = """
{"id":1,"isDefaultCoverImage":false,"settlementRatio":80,"isSettlementRatioDeleted":true}
""".trimIndent()
)
}
assertEquals("common.error.invalid_request", exception.messageKey)
assertEquals(70, audioContent.settlementRatio)
}
@Test
@DisplayName("관리자 콘텐츠 수정은 삭제 플래그와 null 정산 요율 키를 함께 보내도 예외를 던진다")
fun shouldThrowWhenDeleteFlagAndNullSettlementRatioKeyAreProvidedTogether() {
val audioContent = createAudioContent(settlementRatio = 70)
Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(audioContent))
val exception = assertThrows(SodaException::class.java) {
service.updateAudioContent(
coverImage = null,
requestString = """
{"id":1,"isDefaultCoverImage":false,"settlementRatio":null,"isSettlementRatioDeleted":true}
""".trimIndent()
)
}
assertEquals("common.error.invalid_request", exception.messageKey)
assertEquals(70, audioContent.settlementRatio)
}
@Test
@DisplayName("관리자 콘텐츠 수정은 범위를 벗어난 정산 요율이면 예외를 던진다")
fun shouldThrowWhenSettlementRatioIsOutOfRange() {
val audioContent = createAudioContent(settlementRatio = 70)
Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(audioContent))
val exception = assertThrows(SodaException::class.java) {
service.updateAudioContent(
coverImage = null,
requestString = """
{"id":1,"isDefaultCoverImage":false,"settlementRatio":101}
""".trimIndent()
)
}
assertEquals("common.error.invalid_request", exception.messageKey)
assertEquals(70, audioContent.settlementRatio)
}
@Test
@DisplayName("관리자 콘텐츠 수정은 삭제 플래그와 정산 요율이 없으면 기존 값을 유지한다")
fun shouldKeepSettlementRatioWhenNoSettlementRatioFieldsAreProvided() {
val audioContent = createAudioContent(settlementRatio = 70)
Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(audioContent))
service.updateAudioContent(
coverImage = null,
requestString = """
{"id":1,"isDefaultCoverImage":false}
""".trimIndent()
)
assertEquals(70, audioContent.settlementRatio)
}
private fun createAudioContent(settlementRatio: Int?): AudioContent {
return AudioContent(
title = "title",
detail = "detail",
languageCode = "ko",
price = 100,
settlementRatio = settlementRatio
).apply {
id = 1L
}
}
}

View File

@@ -0,0 +1,78 @@
package kr.co.vividnext.sodalive.admin.content.banner
import com.amazonaws.services.s3.AmazonS3Client
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
import kr.co.vividnext.sodalive.admin.content.tab.AdminContentMainTabRepository
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.event.EventRepository
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.MemberRepository
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.ArgumentCaptor
import org.mockito.Mockito
import org.springframework.mock.web.MockMultipartFile
import java.net.URL
class AdminContentBannerServiceTest {
private lateinit var amazonS3Client: AmazonS3Client
private lateinit var s3Uploader: S3Uploader
private lateinit var repository: AdminContentBannerRepository
private lateinit var memberRepository: MemberRepository
private lateinit var seriesRepository: AdminContentSeriesRepository
private lateinit var eventRepository: EventRepository
private lateinit var contentMainTabRepository: AdminContentMainTabRepository
private lateinit var service: AdminContentBannerService
@BeforeEach
fun setUp() {
amazonS3Client = Mockito.mock(AmazonS3Client::class.java)
s3Uploader = S3Uploader(amazonS3Client)
repository = Mockito.mock(AdminContentBannerRepository::class.java)
memberRepository = Mockito.mock(MemberRepository::class.java)
seriesRepository = Mockito.mock(AdminContentSeriesRepository::class.java)
eventRepository = Mockito.mock(EventRepository::class.java)
contentMainTabRepository = Mockito.mock(AdminContentMainTabRepository::class.java)
service = AdminContentBannerService(
s3Uploader = s3Uploader,
repository = repository,
memberRepository = memberRepository,
seriesRepository = seriesRepository,
eventRepository = eventRepository,
contentMainTabRepository = contentMainTabRepository,
objectMapper = jacksonObjectMapper(),
bucket = "test-bucket"
)
}
@Test
@DisplayName("배너 등록 요청의 lang 값을 저장한다")
fun shouldSaveRequestedLangWhenCreatingBanner() {
val image = MockMultipartFile("image", "banner.png", "image/png", "image".toByteArray())
val requestString = """
{"type":"LINK","lang":"ja","tabId":null,"eventId":null,"creatorId":null,"seriesId":null,"link":"https://example.com","isAdult":false}
""".trimIndent()
val savedBannerCaptor = ArgumentCaptor.forClass(AudioContentBanner::class.java)
Mockito.`when`(repository.save(Mockito.any(AudioContentBanner::class.java)))
.thenAnswer {
(it.arguments[0] as AudioContentBanner).also { banner ->
banner.id = 1L
}
}
Mockito.doAnswer { URL("https://cdn.test/${it.arguments[1]}") }
.`when`(amazonS3Client)
.getUrl(Mockito.eq("test-bucket"), Mockito.anyString())
service.createAudioContentMainBanner(image, requestString)
Mockito.verify(repository).save(savedBannerCaptor.capture())
assertEquals(AudioContentBannerType.LINK, savedBannerCaptor.value.type)
assertEquals(Lang.JA, savedBannerCaptor.value.lang)
}
}

View File

@@ -0,0 +1,108 @@
package kr.co.vividnext.sodalive.admin.content.series.banner
import com.amazonaws.services.s3.AmazonS3Client
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService
import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBanner
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.mock.web.MockMultipartFile
import java.net.URL
class AdminContentSeriesBannerControllerTest {
private val bannerService = Mockito.mock(ContentSeriesBannerService::class.java)
private val amazonS3Client = Mockito.mock(AmazonS3Client::class.java)
private val s3Uploader = S3Uploader(amazonS3Client)
private val controller = AdminContentSeriesBannerController(
bannerService = bannerService,
s3Uploader = s3Uploader,
langContext = LangContext(),
messageSource = SodaMessageSource(),
s3Bucket = "test-bucket",
imageHost = "https://cdn.test"
)
@Test
fun shouldRegisterJapaneseBannerThroughAdminApi() {
val image = MockMultipartFile("image", "banner.png", "image/png", "image".toByteArray())
val registeredBanner = createBanner(id = 10L, lang = Lang.JA, imagePath = "")
val updatedBanner = createBanner(id = 10L, lang = Lang.JA, imagePath = "")
Mockito.`when`(amazonS3Client.getUrl(Mockito.eq("test-bucket"), Mockito.anyString()))
.thenAnswer { URL("https://cdn.test/${it.arguments[1]}") }
Mockito.`when`(
bannerService.registerBanner(
seriesId = 1L,
imagePath = "",
lang = Lang.JA
)
).thenReturn(registeredBanner)
Mockito.doAnswer {
updatedBanner.apply {
imagePath = it.arguments[1] as String
}
}.`when`(bannerService).updateBanner(Mockito.eq(10L), Mockito.anyString(), Mockito.isNull())
val response = controller.registerBanner(
image = image,
requestString = "{\"seriesId\":1,\"lang\":\"ja\"}"
)
assertTrue(response.success)
assertEquals(10L, response.data?.id)
assertTrue(response.data?.imagePath?.startsWith("https://cdn.test/series_banner/10/") == true)
Mockito.verify(bannerService).registerBanner(1L, "", Lang.JA)
}
@Test
fun shouldDeserializeIso639LanguageCodeToLangEnum() {
val request = ObjectMapper().readValue(
"{\"seriesId\":1,\"lang\":\"en\"}",
kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerRegisterRequest::class.java
)
assertEquals(Lang.EN, request.lang)
}
@Test
fun shouldAppendBannerLanguageToSeriesTitleInBannerList() {
val pageable = PageRequest.of(0, 20)
val japaneseBanner = createBanner(id = 12L, lang = Lang.JA, imagePath = "banner/jp.png")
Mockito.`when`(bannerService.getActiveBanners(pageable))
.thenReturn(PageImpl(listOf(japaneseBanner), pageable, 1))
val response = controller.getBannerList(page = 0, size = 20)
assertTrue(response.success)
assertEquals("series-12 (일본어)", response.data?.content?.first()?.seriesTitle)
}
private fun createBanner(id: Long, lang: Lang, imagePath: String): SeriesBanner {
val series = Series(
title = "series-$id",
introduction = "introduction-$id",
languageCode = "ko"
)
series.id = id
return SeriesBanner(
imagePath = imagePath,
series = series,
sortOrder = 1,
lang = lang
).also {
it.id = id
}
}
}

View File

@@ -0,0 +1,126 @@
package kr.co.vividnext.sodalive.admin.live
import com.amazonaws.services.s3.AmazonS3Client
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.can.CanRepository
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBanner
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBannerRepository
import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository
import kr.co.vividnext.sodalive.live.room.cancel.LiveRoomCancelRepository
import kr.co.vividnext.sodalive.live.room.info.LiveRoomInfoRedisRepository
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.context.ApplicationEventPublisher
import org.springframework.mock.web.MockMultipartFile
import java.net.URL
class AdminLiveServiceTest {
private val recommendCreatorBannerRepository = Mockito.mock(RecommendLiveCreatorBannerRepository::class.java)
private val roomInfoRepository = Mockito.mock(LiveRoomInfoRedisRepository::class.java)
private val roomCancelRepository = Mockito.mock(LiveRoomCancelRepository::class.java)
private val repository = Mockito.mock(AdminLiveRoomQueryRepository::class.java)
private val memberRepository = Mockito.mock(MemberRepository::class.java)
private val amazonS3Client = Mockito.mock(AmazonS3Client::class.java)
private val s3Uploader = S3Uploader(amazonS3Client)
private val useCanCalculateRepository = Mockito.mock(UseCanCalculateRepository::class.java)
private val reservationRepository = Mockito.mock(LiveReservationRepository::class.java)
private val chargeRepository = Mockito.mock(ChargeRepository::class.java)
private val canRepository = Mockito.mock(CanRepository::class.java)
private val applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
private val messageSource = SodaMessageSource()
private val langContext = LangContext()
private val service = AdminLiveService(
recommendCreatorBannerRepository = recommendCreatorBannerRepository,
roomInfoRepository = roomInfoRepository,
roomCancelRepository = roomCancelRepository,
repository = repository,
memberRepository = memberRepository,
s3Uploader = s3Uploader,
useCanCalculateRepository = useCanCalculateRepository,
reservationRepository = reservationRepository,
chargeRepository = chargeRepository,
canRepository = canRepository,
applicationEventPublisher = applicationEventPublisher,
messageSource = messageSource,
langContext = langContext,
bucket = "test-bucket",
coverImageHost = "https://cdn.test"
)
@Test
@DisplayName("추천 크리에이터 등록은 소문자 lang 코드를 Lang enum으로 저장한다")
fun shouldSaveRecommendCreatorBannerWithIsoLanguageCode() {
val image = MockMultipartFile("image", "banner.png", "image/png", "image".toByteArray())
val creator = Member(
email = "creator@test.com",
password = "password",
nickname = "creator",
role = MemberRole.CREATOR
).also {
it.id = 7L
}
var savedBanner: RecommendLiveCreatorBanner? = null
Mockito.`when`(memberRepository.findCreatorByIdOrNull(7L)).thenReturn(creator)
Mockito.doAnswer {
val banner = it.arguments[0] as RecommendLiveCreatorBanner
banner.id = 55L
savedBanner = banner
banner
}.`when`(recommendCreatorBannerRepository).save(Mockito.any(RecommendLiveCreatorBanner::class.java))
Mockito.`when`(amazonS3Client.getUrl(Mockito.eq("test-bucket"), Mockito.anyString()))
.thenAnswer { URL("https://cdn.test/${it.arguments[1]}") }
service.createRecommendCreatorBanner(
image = image,
creatorId = 7L,
startDateString = "2099-01-01 10:00",
endDateString = "2099-01-02 10:00",
isAdult = false,
lang = "ja"
)
assertEquals(Lang.JA, savedBanner?.lang)
}
@Test
@DisplayName("추천 크리에이터 등록은 지원하지 않는 lang 값이면 예외를 던진다")
fun shouldThrowWhenRecommendCreatorBannerLangIsInvalid() {
val image = MockMultipartFile("image", "banner.png", "image/png", "image".toByteArray())
val creator = Member(
email = "creator@test.com",
password = "password",
nickname = "creator",
role = MemberRole.CREATOR
).also {
it.id = 7L
}
Mockito.`when`(memberRepository.findCreatorByIdOrNull(7L)).thenReturn(creator)
val exception = assertThrows(SodaException::class.java) {
service.createRecommendCreatorBanner(
image = image,
creatorId = 7L,
startDateString = "2099-01-01 10:00",
endDateString = "2099-01-02 10:00",
isAdult = false,
lang = "fr"
)
}
assertEquals("common.error.invalid_request", exception.messageKey)
}
}

View File

@@ -0,0 +1,314 @@
package kr.co.vividnext.sodalive.api.home
import kr.co.vividnext.sodalive.audition.AuditionService
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.content.AudioContentService
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.SortType
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
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.live.room.LiveRoomService
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
import kr.co.vividnext.sodalive.query.recommend.RecommendChannelQueryService
import kr.co.vividnext.sodalive.rank.ContentRankingSortType
import kr.co.vividnext.sodalive.rank.RankingRepository
import kr.co.vividnext.sodalive.rank.RankingService
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.data.domain.Pageable
import java.time.DayOfWeek
import java.time.LocalDateTime
import java.time.ZoneId
class HomeServiceTest {
private lateinit var liveRoomService: LiveRoomService
private lateinit var auditionService: AuditionService
private lateinit var seriesService: ContentSeriesService
private lateinit var contentService: AudioContentService
private lateinit var bannerService: AudioContentBannerService
private lateinit var contentThemeService: AudioContentThemeService
private lateinit var recommendChannelService: RecommendChannelQueryService
private lateinit var characterService: ChatCharacterService
private lateinit var rankingService: RankingService
private lateinit var rankingRepository: RankingRepository
private lateinit var explorerQueryRepository: ExplorerQueryRepository
private lateinit var service: HomeService
private val timezone = "Asia/Seoul"
@BeforeEach
fun setUp() {
liveRoomService = Mockito.mock(LiveRoomService::class.java)
auditionService = Mockito.mock(AuditionService::class.java)
seriesService = Mockito.mock(ContentSeriesService::class.java)
contentService = Mockito.mock(AudioContentService::class.java)
bannerService = Mockito.mock(AudioContentBannerService::class.java)
contentThemeService = Mockito.mock(AudioContentThemeService::class.java)
recommendChannelService = Mockito.mock(RecommendChannelQueryService::class.java)
characterService = Mockito.mock(ChatCharacterService::class.java)
rankingService = Mockito.mock(RankingService::class.java)
rankingRepository = Mockito.mock(RankingRepository::class.java)
explorerQueryRepository = Mockito.mock(ExplorerQueryRepository::class.java)
service = HomeService(
liveRoomService = liveRoomService,
auditionService = auditionService,
seriesService = seriesService,
contentService = contentService,
bannerService = bannerService,
contentThemeService = contentThemeService,
recommendChannelService = recommendChannelService,
characterService = characterService,
rankingService = rankingService,
rankingRepository = rankingRepository,
explorerQueryRepository = explorerQueryRepository,
langContext = LangContext().apply { setLang(Lang.JA) },
memberContentPreferenceService = Mockito.mock(
kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService::class.java
),
imageHost = "https://cdn.test"
)
val systemTime = LocalDateTime.now()
val zonedDateTime = systemTime
.atZone(ZoneId.systemDefault())
.withZoneSameInstant(ZoneId.of(timezone))
val expectedDayOfWeek = when (zonedDateTime.dayOfWeek) {
DayOfWeek.MONDAY -> SeriesPublishedDaysOfWeek.MON
DayOfWeek.TUESDAY -> SeriesPublishedDaysOfWeek.TUE
DayOfWeek.WEDNESDAY -> SeriesPublishedDaysOfWeek.WED
DayOfWeek.THURSDAY -> SeriesPublishedDaysOfWeek.THU
DayOfWeek.FRIDAY -> SeriesPublishedDaysOfWeek.FRI
DayOfWeek.SATURDAY -> SeriesPublishedDaysOfWeek.SAT
DayOfWeek.SUNDAY -> SeriesPublishedDaysOfWeek.SUN
null -> SeriesPublishedDaysOfWeek.RANDOM
}
val currentDateTime = LocalDateTime.now()
val rankingStartDate = currentDateTime
.withHour(15)
.withMinute(0)
.withSecond(0)
.minusWeeks(1)
.with(java.time.temporal.TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
val rankingEndDate = rankingStartDate.plusDays(6)
Mockito.doReturn(emptyList<Any>()).`when`(liveRoomService)
.getRoomList(null, LiveRoomStatus.NOW, Pageable.ofSize(10), null, timezone)
Mockito.`when`(rankingRepository.getCreatorRankings(null)).thenReturn(emptyList())
Mockito.`when`(
contentThemeService.getActiveThemeOfContent(
false,
false,
false,
ContentType.ALL,
listOf("다시듣기")
)
).thenReturn(emptyList())
Mockito.`when`(
contentThemeService.getActiveThemeOfContent(
false,
true,
false,
ContentType.ALL,
emptyList()
)
).thenReturn(emptyList())
Mockito.`when`(
contentService.getLatestContentByTheme(
null,
emptyList(),
ContentType.ALL,
0,
20,
SortType.NEWEST,
false,
false,
false,
false,
emptyList()
)
)
.thenReturn(emptyList())
Mockito.`when`(
contentService.getLatestContentByTheme(
null,
emptyList(),
ContentType.ALL,
0,
50,
SortType.NEWEST,
true,
false,
false,
false,
emptyList()
)
)
.thenReturn(emptyList())
Mockito.`when`(
contentService.getLatestContentByTheme(
null,
emptyList(),
ContentType.ALL,
50,
100,
SortType.NEWEST,
true,
false,
false,
false,
emptyList()
)
)
.thenReturn(emptyList())
Mockito.`when`(
contentService.getLatestContentByTheme(
null,
emptyList(),
ContentType.ALL,
150,
150,
SortType.NEWEST,
true,
false,
false,
false,
emptyList()
)
)
.thenReturn(emptyList())
Mockito.`when`(
contentService.getLatestContentByTheme(
null,
emptyList(),
ContentType.ALL,
0,
50,
SortType.NEWEST,
false,
false,
false,
true,
emptyList()
)
)
.thenReturn(emptyList())
Mockito.`when`(
contentService.getLatestContentByTheme(
null,
emptyList(),
ContentType.ALL,
50,
100,
SortType.NEWEST,
false,
false,
false,
true,
emptyList()
)
)
.thenReturn(emptyList())
Mockito.`when`(
contentService.getLatestContentByTheme(
null,
emptyList(),
ContentType.ALL,
150,
150,
SortType.NEWEST,
false,
false,
false,
true,
emptyList()
)
)
.thenReturn(emptyList())
Mockito.`when`(
contentService.getLatestContentByTheme(
null,
emptyList(),
ContentType.ALL,
0,
50,
SortType.NEWEST,
false,
false,
false,
false,
emptyList()
)
)
.thenReturn(emptyList())
Mockito.`when`(
contentService.getLatestContentByTheme(
null,
emptyList(),
ContentType.ALL,
50,
100,
SortType.NEWEST,
false,
false,
false,
false,
emptyList()
)
)
.thenReturn(emptyList())
Mockito.`when`(
contentService.getLatestContentByTheme(
null,
emptyList(),
ContentType.ALL,
150,
150,
SortType.NEWEST,
false,
false,
false,
false,
emptyList()
)
)
.thenReturn(emptyList())
Mockito.`when`(bannerService.getBannerList(1L, null, false, Lang.JA)).thenReturn(emptyList())
Mockito.`when`(
seriesService.getOriginalAudioDramaList(null, false, ContentType.ALL, 0, 20)
).thenReturn(emptyList())
Mockito.`when`(auditionService.getInProgressAuditionList(false)).thenReturn(emptyList())
Mockito.`when`(
seriesService.getDayOfWeekSeriesList(null, false, ContentType.ALL, expectedDayOfWeek, 0, 10)
).thenReturn(emptyList())
Mockito.`when`(characterService.getPopularCharacters("ja", 20)).thenReturn(emptyList())
Mockito.`when`(
rankingService.getContentRanking(
null,
false,
ContentType.ALL,
rankingStartDate.minusDays(1),
rankingEndDate,
0,
12,
ContentRankingSortType.REVENUE,
""
)
)
.thenReturn(emptyList())
Mockito.`when`(recommendChannelService.getRecommendChannel(null, false, ContentType.ALL)).thenReturn(emptyList())
}
@Test
@DisplayName("홈 fetchData는 현재 요청 언어를 배너 조회에 전달한다")
fun shouldPassCurrentLangToBannerServiceWhenFetchingHome() {
service.fetchData(timezone = timezone, member = null)
Mockito.verify(bannerService).getBannerList(1L, null, false, Lang.JA)
}
}

View File

@@ -0,0 +1,122 @@
package kr.co.vividnext.sodalive.can.coupon
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.can.charge.ChargeService
import kr.co.vividnext.sodalive.common.SodaException
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.MemberRepository
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.Assertions.assertThrows
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.springframework.context.ApplicationEventPublisher
import java.util.Optional
class CanCouponServiceTest {
private lateinit var issueService: CanCouponIssueService
private lateinit var chargeService: ChargeService
private lateinit var repository: CanCouponRepository
private lateinit var couponNumberRepository: CanCouponNumberRepository
private lateinit var memberRepository: MemberRepository
private lateinit var memberContentPreferenceService: MemberContentPreferenceService
private lateinit var objectMapper: ObjectMapper
private lateinit var applicationEventPublisher: ApplicationEventPublisher
private lateinit var messageSource: SodaMessageSource
private lateinit var langContext: LangContext
private lateinit var service: CanCouponService
@BeforeEach
fun setUp() {
issueService = mock(CanCouponIssueService::class.java)
chargeService = mock(ChargeService::class.java)
repository = mock(CanCouponRepository::class.java)
couponNumberRepository = mock(CanCouponNumberRepository::class.java)
memberRepository = mock(MemberRepository::class.java)
memberContentPreferenceService = mock(MemberContentPreferenceService::class.java)
objectMapper = mock(ObjectMapper::class.java)
applicationEventPublisher = mock(ApplicationEventPublisher::class.java)
messageSource = mock(SodaMessageSource::class.java)
langContext = LangContext()
service = CanCouponService(
issueService = issueService,
chargeService = chargeService,
repository = repository,
couponNumberRepository = couponNumberRepository,
memberRepository = memberRepository,
memberContentPreferenceService = memberContentPreferenceService,
objectMapper = objectMapper,
applicationEventPublisher = applicationEventPublisher,
messageSource = messageSource,
langContext = langContext
)
}
@Test
@DisplayName("비한국 사용자는 성인 노출 설정이 true이면 본인인증 없이 쿠폰을 사용할 수 있다")
fun shouldUseCouponWithoutAuthForNonKrAdultVisibleMember() {
val member = createMember(memberId = 1L)
val couponNumber = "COUPON1234"
`when`(memberRepository.findById(1L)).thenReturn(Optional.of(member))
`when`(memberContentPreferenceService.getStoredPreference(member)).thenReturn(
ViewerContentPreference(
countryCode = "US",
isAdultContentVisible = true,
contentType = kr.co.vividnext.sodalive.content.ContentType.ALL,
isAdult = true
)
)
`when`(chargeService.chargeByCoupon(couponNumber, member)).thenReturn("charged")
val result = service.useCanCoupon(couponNumber = couponNumber, memberId = 1L)
assertEquals("charged", result)
verify(issueService).validateAvailableUseCoupon(couponNumber, 1L)
verify(chargeService).chargeByCoupon(couponNumber, member)
}
@Test
@DisplayName("한국 사용자는 본인인증이 없으면 기존처럼 쿠폰 사용이 불가능하다")
fun shouldThrowWhenAdultPolicyIsFalse() {
val member = createMember(memberId = 2L)
`when`(memberRepository.findById(2L)).thenReturn(Optional.of(member))
`when`(memberContentPreferenceService.getStoredPreference(member)).thenReturn(
ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = true,
contentType = kr.co.vividnext.sodalive.content.ContentType.ALL,
isAdult = false
)
)
val exception = assertThrows(SodaException::class.java) {
service.useCanCoupon(couponNumber = "COUPON5678", memberId = 2L)
}
assertEquals("can.coupon.auth_required", exception.messageKey)
verify(issueService, never()).validateAvailableUseCoupon("COUPON5678", 2L)
verify(chargeService, never()).chargeByCoupon("COUPON5678", member)
}
private fun createMember(memberId: Long): Member {
return Member(
email = "member$memberId@test.com",
password = "password",
nickname = "member$memberId"
).apply {
id = memberId
}
}
}

View File

@@ -0,0 +1,118 @@
package kr.co.vividnext.sodalive.chat.character.controller
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentService
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService
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.AiCharacterTranslationRepository
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.member.contentpreference.MemberContentPreferenceService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
class ChatCharacterControllerTest {
private val service = Mockito.mock(ChatCharacterService::class.java)
private val bannerService = Mockito.mock(ChatCharacterBannerService::class.java)
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 aiCharacterTranslationRepository = Mockito.mock(AiCharacterTranslationRepository::class.java)
private val langContext = LangContext().apply { setLang(Lang.JA) }
private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
private val controller = ChatCharacterController(
service = service,
bannerService = bannerService,
chatRoomService = chatRoomService,
characterCommentService = characterCommentService,
curationQueryService = curationQueryService,
translationService = translationService,
aiCharacterTranslationRepository = aiCharacterTranslationRepository,
langContext = langContext,
memberContentPreferenceService = memberContentPreferenceService,
imageHost = "https://cdn.test"
)
@Test
fun shouldUseJapaneseBannerWhenLangContextIsJapanese() {
val pageable = PageRequest.of(0, 10)
val banner = createBanner(id = 1L, imagePath = "banner/jp.png", lang = Lang.JA)
Mockito.`when`(bannerService.getDisplayBanners(pageable, Lang.JA))
.thenReturn(PageImpl(listOf(banner), pageable, 1))
Mockito.`when`(service.getPopularCharacters(locale = "ja")).thenReturn(emptyList())
Mockito.`when`(service.getRecentCharactersPage(page = 0, size = 50))
.thenReturn(RecentCharactersResponse(totalCount = 0, content = emptyList()))
Mockito.`when`(service.getRecommendCharacters(emptyList(), 30)).thenReturn(emptyList())
Mockito.`when`(curationQueryService.getActiveCurationsWithCharacters()).thenReturn(emptyList())
val response = controller.getCharacterMain(member = null)
assertTrue(response.success)
assertEquals(1, response.data?.banners?.size)
assertEquals(1L, response.data?.banners?.first()?.characterId)
assertEquals("https://cdn.test/banner/jp.png", response.data?.banners?.first()?.imageUrl)
Mockito.verify(bannerService).getDisplayBanners(pageable, Lang.JA)
}
@Test
fun shouldUseEnglishRequestWithKoreanFallbackBanner() {
val controller = ChatCharacterController(
service = service,
bannerService = bannerService,
chatRoomService = chatRoomService,
characterCommentService = characterCommentService,
curationQueryService = curationQueryService,
translationService = translationService,
aiCharacterTranslationRepository = aiCharacterTranslationRepository,
langContext = LangContext().apply { setLang(Lang.EN) },
memberContentPreferenceService = memberContentPreferenceService,
imageHost = "https://cdn.test"
)
val pageable = PageRequest.of(0, 10)
val banner = createBanner(id = 2L, imagePath = "banner/ko.png", lang = Lang.KO)
Mockito.`when`(bannerService.getDisplayBanners(pageable, Lang.EN))
.thenReturn(PageImpl(listOf(banner), pageable, 1))
Mockito.`when`(service.getPopularCharacters(locale = "en")).thenReturn(emptyList())
Mockito.`when`(service.getRecentCharactersPage(page = 0, size = 50))
.thenReturn(RecentCharactersResponse(totalCount = 0, content = emptyList()))
Mockito.`when`(service.getRecommendCharacters(emptyList(), 30)).thenReturn(emptyList())
Mockito.`when`(curationQueryService.getActiveCurationsWithCharacters()).thenReturn(emptyList())
val response = controller.getCharacterMain(member = null)
assertTrue(response.success)
assertEquals("https://cdn.test/banner/ko.png", response.data?.banners?.first()?.imageUrl)
Mockito.verify(bannerService).getDisplayBanners(pageable, Lang.EN)
}
private fun createBanner(id: Long, imagePath: String, lang: Lang): ChatCharacterBanner {
val character = ChatCharacter(
characterUUID = "character-$id",
name = "character-$id",
description = "description-$id",
systemPrompt = "system-prompt-$id"
)
character.id = id
return ChatCharacterBanner(
imagePath = imagePath,
chatCharacter = character,
sortOrder = 1,
lang = lang
).also {
it.id = id
}
}
}

View File

@@ -0,0 +1,177 @@
package kr.co.vividnext.sodalive.chat.character.service
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterBannerRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.i18n.Lang
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
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import java.util.Optional
class ChatCharacterBannerServiceTest {
private lateinit var bannerRepository: ChatCharacterBannerRepository
private lateinit var characterRepository: ChatCharacterRepository
private lateinit var service: ChatCharacterBannerService
@BeforeEach
fun setUp() {
bannerRepository = Mockito.mock(ChatCharacterBannerRepository::class.java)
characterRepository = Mockito.mock(ChatCharacterRepository::class.java)
service = ChatCharacterBannerService(
bannerRepository = bannerRepository,
characterRepository = characterRepository
)
}
@Test
@DisplayName("일본어 배너 등록 요청은 JA 언어값으로 저장한다")
fun shouldRegisterJapaneseBanner() {
val character = createCharacter(id = 1L)
Mockito.`when`(characterRepository.findById(1L)).thenReturn(Optional.of(character))
Mockito.`when`(bannerRepository.findMaxSortOrder()).thenReturn(3)
Mockito.`when`(bannerRepository.save(Mockito.any(ChatCharacterBanner::class.java))).thenAnswer { it.arguments[0] }
val banner = service.registerBanner(
characterId = 1L,
imagePath = "banner/jp.png",
lang = Lang.JA
)
assertEquals(Lang.JA, banner.lang)
assertEquals(4, banner.sortOrder)
assertEquals("banner/jp.png", banner.imagePath)
}
@Test
@DisplayName("기본 배너 등록 요청은 언어값이 없으면 KO로 저장한다")
fun shouldRegisterDefaultBannerAsKoreanWhenLangIsNull() {
val character = createCharacter(id = 2L)
Mockito.`when`(characterRepository.findById(2L)).thenReturn(Optional.of(character))
Mockito.`when`(bannerRepository.findMaxSortOrder()).thenReturn(null)
Mockito.`when`(bannerRepository.save(Mockito.any(ChatCharacterBanner::class.java))).thenAnswer { it.arguments[0] }
val banner = service.registerBanner(
characterId = 2L,
imagePath = "banner/default.png",
lang = null
)
assertEquals(Lang.KO, banner.lang)
assertEquals(1, banner.sortOrder)
}
@Test
@DisplayName("영어 배너 등록 요청은 EN 언어값으로 저장한다")
fun shouldRegisterEnglishBanner() {
val character = createCharacter(id = 3L)
Mockito.`when`(characterRepository.findById(3L)).thenReturn(Optional.of(character))
Mockito.`when`(bannerRepository.findMaxSortOrder()).thenReturn(5)
Mockito.`when`(bannerRepository.save(Mockito.any(ChatCharacterBanner::class.java))).thenAnswer { it.arguments[0] }
val banner = service.registerBanner(
characterId = 3L,
imagePath = "banner/en.png",
lang = Lang.EN
)
assertEquals(Lang.EN, banner.lang)
assertEquals(6, banner.sortOrder)
}
@Test
@DisplayName("일본어 사용자는 일본어 배너만 조회한다")
fun shouldReturnJapaneseBannersForJapaneseUser() {
val pageable = PageRequest.of(0, 10)
val japaneseBanner = ChatCharacterBanner(
imagePath = "banner/jp.png",
chatCharacter = createCharacter(id = 4L),
sortOrder = 1,
lang = Lang.JA
)
val expectedPage = PageImpl(listOf(japaneseBanner), pageable, 1)
Mockito.`when`(bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.JA, pageable))
.thenReturn(expectedPage)
val actual = service.getDisplayBanners(pageable, Lang.JA)
assertEquals(expectedPage, actual)
Mockito.verify(bannerRepository).findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.JA, pageable)
Mockito.verify(bannerRepository, Mockito.never())
.findByIsActiveTrueAndLangOrderBySortOrderAsc(
Lang.KO,
pageable
)
}
@Test
@DisplayName("한국어 사용자는 한국어 배너를 조회한다")
fun shouldReturnKoreanBannersForKoreanUser() {
val pageable = PageRequest.of(0, 10)
val defaultBanner = ChatCharacterBanner(
imagePath = "banner/ko.png",
chatCharacter = createCharacter(id = 5L),
sortOrder = 1,
lang = Lang.KO
)
val expectedPage = PageImpl(listOf(defaultBanner), pageable, 1)
Mockito.`when`(bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.KO, pageable))
.thenReturn(expectedPage)
val actual = service.getDisplayBanners(pageable, Lang.KO)
assertEquals(expectedPage, actual)
Mockito.verify(bannerRepository).findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.KO, pageable)
Mockito.verify(bannerRepository, Mockito.never())
.findByIsActiveTrueAndLangOrderBySortOrderAsc(
Lang.JA,
pageable
)
}
@Test
@DisplayName("영어 배너가 없으면 한국어 배너로 fallback 한다")
fun shouldFallbackToKoreanWhenEnglishBannerDoesNotExist() {
val pageable = PageRequest.of(0, 10)
val englishPage = PageImpl<ChatCharacterBanner>(emptyList(), pageable, 0)
val koreanBanner = ChatCharacterBanner(
imagePath = "banner/ko.png",
chatCharacter = createCharacter(id = 6L),
sortOrder = 1,
lang = Lang.KO
)
val koreanPage = PageImpl(listOf(koreanBanner), pageable, 1)
Mockito.`when`(bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.EN, pageable))
.thenReturn(englishPage)
Mockito.`when`(bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.KO, pageable))
.thenReturn(koreanPage)
val actual = service.getDisplayBanners(pageable, Lang.EN)
assertEquals(koreanPage, actual)
Mockito.verify(bannerRepository).findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.EN, pageable)
Mockito.verify(bannerRepository).findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.KO, pageable)
}
private fun createCharacter(id: Long): ChatCharacter {
val character = ChatCharacter(
characterUUID = "character-$id",
name = "character-$id",
description = "description-$id",
systemPrompt = "system-prompt-$id"
)
character.id = id
return character
}
}

View File

@@ -0,0 +1,45 @@
package kr.co.vividnext.sodalive.content.main.banner
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.i18n.Lang
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
@DataJpaTest
@Import(QueryDslConfig::class)
class AudioContentBannerRepositoryTest @Autowired constructor(
private val repository: AudioContentBannerRepository
) {
@Test
@DisplayName("사용자 배너 조회는 요청 언어와 일치하는 배너만 반환한다")
fun shouldReturnOnlyRequestedLanguageBanners() {
repository.saveAndFlush(
AudioContentBanner(
thumbnailImage = "banner/ko.png",
type = AudioContentBannerType.LINK,
lang = Lang.KO
).apply {
link = "https://ko.example.com"
}
)
repository.saveAndFlush(
AudioContentBanner(
thumbnailImage = "banner/ja.png",
type = AudioContentBannerType.LINK,
lang = Lang.JA
).apply {
link = "https://ja.example.com"
}
)
val banners = repository.getAudioContentMainBannerList(tabId = 1L, isAdult = false, lang = Lang.JA)
assertEquals(1, banners.size)
assertEquals(Lang.JA, banners.first().lang)
assertEquals("https://ja.example.com", banners.first().link)
}
}

View File

@@ -0,0 +1,105 @@
package kr.co.vividnext.sodalive.content.series.main
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService
import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBanner
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
class SeriesMainControllerTest {
private val contentSeriesService = Mockito.mock(ContentSeriesService::class.java)
private val bannerService = Mockito.mock(ContentSeriesBannerService::class.java)
private val langContext = LangContext().apply { setLang(Lang.JA) }
private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
private val controller = SeriesMainController(
contentSeriesService = contentSeriesService,
bannerService = bannerService,
langContext = langContext,
memberContentPreferenceService = memberContentPreferenceService,
imageHost = "https://cdn.test"
)
@Test
fun shouldFetchOnlyRequestedLanguageBanners() {
val member = createMember(id = 1L)
val preference = ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = true,
contentType = ContentType.ALL,
isAdult = true
)
val pageable = PageRequest.of(0, 10)
val japaneseBanner = SeriesBanner(
imagePath = "banner/jp.png",
series = createSeries(id = 10L),
sortOrder = 1,
lang = Lang.JA
).also {
it.id = 100L
}
Mockito.`when`(memberContentPreferenceService.resolveForQuery(member)).thenReturn(preference)
Mockito.`when`(bannerService.getDisplayBanners(pageable, Lang.JA))
.thenReturn(PageImpl(listOf(japaneseBanner), pageable, 1))
Mockito.`when`(
contentSeriesService.getSeriesList(
null,
false,
true,
true,
true,
ContentType.ALL,
member,
0,
20
)
).thenReturn(GetSeriesListResponse(totalCount = 0, items = emptyList()))
Mockito.`when`(
contentSeriesService.getRecommendSeriesList(
true,
ContentType.ALL,
member
)
).thenReturn(emptyList())
val response = controller.fetchData(member)
assertTrue(response.success)
assertEquals(1, response.data?.banners?.size)
assertEquals("series-10", response.data?.banners?.first()?.seriesTitle)
Mockito.verify(bannerService).getDisplayBanners(pageable, Lang.JA)
Mockito.verify(bannerService, Mockito.never()).getActiveBanners(pageable)
}
private fun createMember(id: Long): Member {
return Member(
email = "member-$id@test.com",
password = "password",
nickname = "member-$id"
).also {
it.id = id
}
}
private fun createSeries(id: Long): Series {
return Series(
title = "series-$id",
introduction = "introduction-$id",
languageCode = "ja"
).also {
it.id = id
}
}
}

View File

@@ -0,0 +1,100 @@
package kr.co.vividnext.sodalive.content.series.main.banner
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.i18n.Lang
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
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
class ContentSeriesBannerServiceTest {
private lateinit var bannerRepository: SeriesBannerRepository
private lateinit var seriesRepository: AdminContentSeriesRepository
private lateinit var service: ContentSeriesBannerService
@BeforeEach
fun setUp() {
bannerRepository = Mockito.mock(SeriesBannerRepository::class.java)
seriesRepository = Mockito.mock(AdminContentSeriesRepository::class.java)
service = ContentSeriesBannerService(
bannerRepository = bannerRepository,
seriesRepository = seriesRepository
)
}
@Test
@DisplayName("일본어 배너 등록 요청은 JA 언어값으로 저장한다")
fun shouldRegisterJapaneseBanner() {
val series = createSeries(id = 1L)
Mockito.`when`(seriesRepository.findByIdAndActiveTrue(1L)).thenReturn(series)
Mockito.`when`(bannerRepository.findMaxSortOrder()).thenReturn(2)
Mockito.`when`(bannerRepository.save(Mockito.any(SeriesBanner::class.java))).thenAnswer { it.arguments[0] }
val banner = service.registerBanner(
seriesId = 1L,
imagePath = "banner/jp.png",
lang = Lang.JA
)
assertEquals(Lang.JA, banner.lang)
assertEquals(3, banner.sortOrder)
assertEquals("banner/jp.png", banner.imagePath)
}
@Test
@DisplayName("언어가 없는 배너 등록 요청은 KO로 저장한다")
fun shouldRegisterKoreanBannerWhenLangIsMissing() {
val series = createSeries(id = 2L)
Mockito.`when`(seriesRepository.findByIdAndActiveTrue(2L)).thenReturn(series)
Mockito.`when`(bannerRepository.findMaxSortOrder()).thenReturn(null)
Mockito.`when`(bannerRepository.save(Mockito.any(SeriesBanner::class.java))).thenAnswer { it.arguments[0] }
val banner = service.registerBanner(
seriesId = 2L,
imagePath = "banner/default.png",
lang = null
)
assertEquals(Lang.KO, banner.lang)
assertEquals(1, banner.sortOrder)
}
@Test
@DisplayName("일본어 사용자는 일본어 배너만 조회한다")
fun shouldReturnJapaneseBannersForJapaneseUser() {
val pageable = PageRequest.of(0, 10)
val japaneseBanner = SeriesBanner(
imagePath = "banner/jp.png",
series = createSeries(id = 3L),
sortOrder = 1,
lang = Lang.JA
)
val expectedPage = PageImpl(listOf(japaneseBanner), pageable, 1)
Mockito.`when`(bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.JA, pageable))
.thenReturn(expectedPage)
val actual = service.getDisplayBanners(pageable, Lang.JA)
assertEquals(expectedPage, actual)
Mockito.verify(bannerRepository).findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.JA, pageable)
Mockito.verify(bannerRepository, Mockito.never())
.findByIsActiveTrueOrderBySortOrderAsc(pageable)
}
private fun createSeries(id: Long): Series {
return Series(
title = "series-$id",
introduction = "introduction-$id",
languageCode = "ko"
).also {
it.id = id
}
}
}

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.live.recommend
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
@@ -10,6 +11,7 @@ import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
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.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
@@ -34,6 +36,7 @@ class LiveRecommendRepositoryTest @Autowired constructor(
}
@Test
@DisplayName("추천 크리에이터 조회는 차단 관계를 양방향으로 제외한다")
fun shouldExcludeBlockedCreatorsInBothDirections() {
val viewer = saveMember(nickname = "viewer", role = MemberRole.USER)
val creatorBlockedByViewer = saveMember(nickname = "creator-blocked-by-viewer", role = MemberRole.CREATOR)
@@ -50,13 +53,14 @@ class LiveRecommendRepositoryTest @Autowired constructor(
entityManager.flush()
entityManager.clear()
val result = liveRecommendRepository.getRecommendLive(memberId = viewer.id, isAdult = true)
val result = liveRecommendRepository.getRecommendLive(memberId = viewer.id, isAdult = true, lang = Lang.KO)
assertEquals(1, result.size)
assertEquals(creatorAllowed.id, result[0].creatorId)
}
@Test
@DisplayName("추천 크리에이터 조회는 비활성 차단 관계를 제외하지 않는다")
fun shouldKeepCreatorWhenBlockRelationIsInactive() {
val viewer = saveMember(nickname = "viewer-inactive", role = MemberRole.USER)
val creator = saveMember(nickname = "creator-inactive", role = MemberRole.CREATOR)
@@ -67,13 +71,14 @@ class LiveRecommendRepositoryTest @Autowired constructor(
entityManager.flush()
entityManager.clear()
val result = liveRecommendRepository.getRecommendLive(memberId = viewer.id, isAdult = true)
val result = liveRecommendRepository.getRecommendLive(memberId = viewer.id, isAdult = true, lang = Lang.KO)
assertEquals(1, result.size)
assertEquals(creator.id, result[0].creatorId)
}
@Test
@DisplayName("크리에이터 팔로잉 전체 조회는 알림 여부를 포함한다")
fun shouldReturnFollowingCreatorListWithNotifyFlag() {
val viewer = saveMember(nickname = "viewer-following", role = MemberRole.USER)
val creatorA = saveMember(nickname = "creator-following-a", role = MemberRole.CREATOR)
@@ -98,6 +103,25 @@ class LiveRecommendRepositoryTest @Autowired constructor(
assertEquals(false, isNotifyByCreatorId[creatorB.id])
}
@Test
@DisplayName("추천 크리에이터 조회는 요청 언어와 일치하는 배너만 반환한다")
fun shouldReturnOnlyRequestedLanguageBanners() {
val viewer = saveMember(nickname = "viewer-lang", role = MemberRole.USER)
val koreanCreator = saveMember(nickname = "creator-ko", role = MemberRole.CREATOR)
val japaneseCreator = saveMember(nickname = "creator-ja", role = MemberRole.CREATOR)
saveBanner(creator = koreanCreator, order = 1, lang = Lang.KO)
saveBanner(creator = japaneseCreator, order = 2, lang = Lang.JA)
entityManager.flush()
entityManager.clear()
val result = liveRecommendRepository.getRecommendLive(memberId = viewer.id, isAdult = true, lang = Lang.JA)
assertEquals(1, result.size)
assertEquals(japaneseCreator.id, result.first().creatorId)
}
private fun saveMember(nickname: String, role: MemberRole): Member {
return memberRepository.saveAndFlush(
Member(
@@ -110,11 +134,12 @@ class LiveRecommendRepositoryTest @Autowired constructor(
)
}
private fun saveBanner(creator: Member, order: Int) {
private fun saveBanner(creator: Member, order: Int, lang: Lang = Lang.KO) {
val banner = RecommendLiveCreatorBanner(
startDate = LocalDateTime.now().minusDays(1),
endDate = LocalDateTime.now().plusDays(1),
isAdult = false,
lang = lang,
orders = order,
image = "recommend/$order.png"
)

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.live.recommend
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
@@ -58,12 +59,12 @@ class LiveRecommendServiceTest {
isAdult = true
)
)
Mockito.`when`(liveRecommendCacheService.getRecommendLive(memberId = member.id, isAdult = true)).thenReturn(expected)
Mockito.`when`(liveRecommendCacheService.getRecommendLive(member.id, true, Lang.JA)).thenReturn(expected)
val result = service.getRecommendLive(member)
val result = service.getRecommendLive(member, Lang.JA)
assertEquals(expected, result)
Mockito.verify(liveRecommendCacheService).getRecommendLive(memberId = member.id, isAdult = true)
Mockito.verify(liveRecommendCacheService).getRecommendLive(member.id, true, Lang.JA)
Mockito.verifyNoInteractions(repository)
Mockito.verifyNoInteractions(blockMemberRepository)
}
@@ -71,12 +72,12 @@ class LiveRecommendServiceTest {
@Test
fun shouldDelegateToRepositoryAsGuestWhenMemberIsNull() {
val expected = listOf(GetRecommendLiveResponse(imageUrl = "https://cdn.test/recommend-guest.png", creatorId = 88L))
Mockito.`when`(liveRecommendCacheService.getRecommendLive(memberId = null, isAdult = false)).thenReturn(expected)
Mockito.`when`(liveRecommendCacheService.getRecommendLive(null, false, Lang.EN)).thenReturn(expected)
val result = service.getRecommendLive(null)
val result = service.getRecommendLive(null, Lang.EN)
assertEquals(expected, result)
Mockito.verify(liveRecommendCacheService).getRecommendLive(memberId = null, isAdult = false)
Mockito.verify(liveRecommendCacheService).getRecommendLive(null, false, Lang.EN)
Mockito.verifyNoInteractions(repository)
Mockito.verifyNoInteractions(blockMemberRepository)
Mockito.verifyNoInteractions(memberContentPreferenceService)

View File

@@ -112,6 +112,18 @@ if [ -n "$body" ] && printf '%s\n' "$body" | grep -Eq '^Refs:'; then
fi
fi
if [ -n "$body" ]; then
if printf '%s\n' "$body" | grep -Fq 'Ultraworked with [Sisyphus]'; then
echo "[FAIL] Sisyphus attribution footer must not be included"
exit_code=1
fi
if printf '%s\n' "$body" | grep -Ei '^Co-authored-by:[[:space:]]*Sisyphus[[:space:]]*<clio-agent@sisyphuslabs\.ai>$' >/dev/null; then
echo "[FAIL] Automatic Sisyphus co-author footer must not be included"
exit_code=1
fi
fi
if [ $exit_code -eq 0 ]; then
echo "[PASS] Commit message follows AGENTS.md rules"
else