diff --git a/.gitignore b/.gitignore index 67439e8c..addc23cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ HELP.md .gradle .envrc +.omx/ build/ !**/src/main/**/build/ !**/src/test/**/build/ diff --git a/.opencode/commands/commit.md b/.opencode/commands/commit.md index 0528089b..06cedcac 100644 --- a/.opencode/commands/commit.md +++ b/.opencode/commands/commit.md @@ -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 ` 라인이 본문에 추가되지 않도록 확인한다. +5. 마지막에 실행 명령과 pre-check/post-check PASS/FAIL 핵심 결과를 간단히 보고한다. 추가 사용자 의도: $ARGUMENTS diff --git a/.opencode/skills/commit-policy/SKILL.md b/.opencode/skills/commit-policy/SKILL.md index b13180e7..505a5fdd 100644 --- a/.opencode/skills/commit-policy/SKILL.md +++ b/.opencode/skills/commit-policy/SKILL.md @@ -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 ` 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 ""` + - `./work/scripts/check-commit-message-rules.sh --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 ` and commit with the same file via `git commit -F ` 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. diff --git a/AGENTS.md b/AGENTS.md index a679c05e..01e1a3c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` 자동 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` 폴더 아래에 계획 문서를 먼저 생성하고, 해당 문서를 기준으로 구현을 진행한다. diff --git a/docs/20260402_AI캐릭터본인인증국가별분기적용.md b/docs/20260402_AI캐릭터본인인증국가별분기적용.md new file mode 100644 index 00000000..137c88c4 --- /dev/null +++ b/docs/20260402_AI캐릭터본인인증국가별분기적용.md @@ -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` 검색 → 결과 없음 diff --git a/docs/20260402_audio_content_banner_lang_ddl.sql b/docs/20260402_audio_content_banner_lang_ddl.sql new file mode 100644 index 00000000..6e73b150 --- /dev/null +++ b/docs/20260402_audio_content_banner_lang_ddl.sql @@ -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; diff --git a/docs/20260402_chat_character_banner_lang_ddl.sql b/docs/20260402_chat_character_banner_lang_ddl.sql new file mode 100644 index 00000000..1c5ffebc --- /dev/null +++ b/docs/20260402_chat_character_banner_lang_ddl.sql @@ -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; diff --git a/docs/20260402_live_recommend_creator_banner_lang_ddl.sql b/docs/20260402_live_recommend_creator_banner_lang_ddl.sql new file mode 100644 index 00000000..32f72030 --- /dev/null +++ b/docs/20260402_live_recommend_creator_banner_lang_ddl.sql @@ -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; diff --git a/docs/20260402_관리자채팅배너목록언어표기추가.md b/docs/20260402_관리자채팅배너목록언어표기추가.md new file mode 100644 index 00000000..0f3c1af2 --- /dev/null +++ b/docs/20260402_관리자채팅배너목록언어표기추가.md @@ -0,0 +1,10 @@ +- [x] 배너 목록 조회 응답 생성 경로와 언어 정보 위치를 확인한다. +- [x] 배너 목록 응답의 연결 캐릭터 이름에 배너 등록 언어를 `(언어)` 형식으로 추가한다. +- [x] 변경 파일 기준으로 검증을 수행하고 결과를 기록한다. + +## 검증 기록 + +### 1차 구현 +- 무엇을: 관리자 배너 목록 조회 응답에서 연결 캐릭터 이름 뒤에 배너 등록 언어를 `(언어)` 형식으로 붙이도록 수정했다. +- 왜: 같은 이름과 같은 이미지의 배너라도 등록 언어가 다르면 관리자 페이지에서 즉시 구분할 수 있어야 하기 때문이다. +- 어떻게: `./gradlew test --tests "kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest"` 실행으로 컨트롤러 테스트를 검증했고, 새 테스트에서 목록 조회 응답 이름이 `character-12 (일본어)`로 반환되는 것을 확인했다. 결과는 `BUILD SUCCESSFUL`이다. diff --git a/docs/20260402_라이브추천크리에이터언어적용.md b/docs/20260402_라이브추천크리에이터언어적용.md new file mode 100644 index 00000000..02b9b749 --- /dev/null +++ b/docs/20260402_라이브추천크리에이터언어적용.md @@ -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`이다. diff --git a/docs/20260402_시리즈배너언어별조회적용.md b/docs/20260402_시리즈배너언어별조회적용.md new file mode 100644 index 00000000..fe218b3c --- /dev/null +++ b/docs/20260402_시리즈배너언어별조회적용.md @@ -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`이다. diff --git a/docs/20260402_오디오콘텐츠배너언어적용.md b/docs/20260402_오디오콘텐츠배너언어적용.md new file mode 100644 index 00000000..81b459bf --- /dev/null +++ b/docs/20260402_오디오콘텐츠배너언어적용.md @@ -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`이다. diff --git a/docs/20260402_일본어채팅캐릭터배너추가.md b/docs/20260402_일본어채팅캐릭터배너추가.md new file mode 100644 index 00000000..d1e6011e --- /dev/null +++ b/docs/20260402_일본어채팅캐릭터배너추가.md @@ -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`는 수행하지 못했다. diff --git a/docs/20260402_쿠폰사용본인인증예외추가.md b/docs/20260402_쿠폰사용본인인증예외추가.md new file mode 100644 index 00000000..a3c81038 --- /dev/null +++ b/docs/20260402_쿠폰사용본인인증예외추가.md @@ -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` 실행 성공. diff --git a/docs/20260403_메시지전송username추가.md b/docs/20260403_메시지전송username추가.md new file mode 100644 index 00000000..d1787c67 --- /dev/null +++ b/docs/20260403_메시지전송username추가.md @@ -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`을 확인했다. diff --git a/docs/20260406_omxgitignore.md b/docs/20260406_omxgitignore.md new file mode 100644 index 00000000..1f2a8846 --- /dev/null +++ b/docs/20260406_omxgitignore.md @@ -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/`가 더 이상 추적 대상이 아니고 문서만 남는지 확인했다. diff --git a/docs/20260407_audio_content_settlement_ratio_ddl.sql b/docs/20260407_audio_content_settlement_ratio_ddl.sql new file mode 100644 index 00000000..b3e79ed2 --- /dev/null +++ b/docs/20260407_audio_content_settlement_ratio_ddl.sql @@ -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; diff --git a/docs/20260407_커밋footer자동추가차단.md b/docs/20260407_커밋footer자동추가차단.md new file mode 100644 index 00000000..ee336ce9 --- /dev/null +++ b/docs/20260407_커밋footer자동추가차단.md @@ -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 ` 자동 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을 확인했다. diff --git a/docs/20260407_콘텐츠별정산요율추가.md b/docs/20260407_콘텐츠별정산요율추가.md new file mode 100644 index 00000000..278bb95c --- /dev/null +++ b/docs/20260407_콘텐츠별정산요율추가.md @@ -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 회귀 확인 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt index 4d8e7de3..3f203e70 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt @@ -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) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt index 086ab3ed..cc2e4b58 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt @@ -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를 사용하여 이미지 업로드 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerRequest.kt index 930c8e13..4d1877fd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerRequest.kt @@ -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 ) /** diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerResponse.kt index 25ebcc7e..a114e9a9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerResponse.kt @@ -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 -> "일본어" + } + } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentRepository.kt index af36cb06..283d1745 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentRepository.kt @@ -110,6 +110,7 @@ class AdminAudioContentQueryRepositoryImpl( audioContentTheme.theme, audioContentTheme.id, audioContent.price, + audioContent.settlementRatio, audioContent.limited, audioContent.remaining, audioContent.isAdult, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentService.kt index dbdbd277..b903c420 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentService.kt @@ -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 { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/GetAdminContentListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/GetAdminContentListResponse.kt index 1fe1ba7e..8052b84d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/GetAdminContentListResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/GetAdminContentListResponse.kt @@ -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, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/UpdateAdminContentRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/UpdateAdminContentRequest.kt index 9b0a6e7c..1a51947c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/UpdateAdminContentRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/UpdateAdminContentRequest.kt @@ -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? diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerService.kt index a6ecbf47..bcaeadcf 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerService.kt @@ -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 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/CreateContentBannerRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/CreateContentBannerRequest.kt index e564206a..a5a81fbf 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/CreateContentBannerRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/CreateContentBannerRequest.kt @@ -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?, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt index 6f8c3d4b..7ec2edd5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt @@ -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) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/dto/SeriesBannerDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/dto/SeriesBannerDtos.kt index d054ca91..a8537476 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/dto/SeriesBannerDtos.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/dto/SeriesBannerDtos.kt @@ -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 -> "일본어" + } + } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveController.kt index b4e659e9..98b581db 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveController.kt @@ -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), "등록되었습니다." ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt index b9cdeb15..39cdf2a0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt @@ -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) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index cf7b925f..e93ed984 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt @@ -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( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/live/LiveApiService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/live/LiveApiService.kt index a2dcca68..7c1e64a7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/live/LiveApiService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/live/LiveApiService.kt @@ -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) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponService.kt index 54f17365..b457778d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponService.kt @@ -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) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBanner.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBanner.kt index 055f3a17..c2a85bb5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBanner.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBanner.kt @@ -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() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index f2d21ffe..cdaf7995 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -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( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt index 2de90207..4a911059 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt @@ -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 + fun findByIsActiveTrueAndLangOrderBySortOrderAsc(lang: Lang, pageable: Pageable): Page + // 활성화된 배너 중 최대 정렬 순서 값 조회 @Query("SELECT MAX(b.sortOrder) FROM ChatCharacterBanner b WHERE b.isActive = true") fun findMaxSortOrder(): Int? diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt index 6321c28d..40858642 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt @@ -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 { + 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") + } + /** * 배너 수정 * diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt index ce5232b2..73fab1f6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt @@ -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 + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt index 01bc064d..e981f46b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt @@ -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 = 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 = 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 + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt index c1d8f543..e886de05 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt @@ -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 = 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 = 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 + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt index d2b80fbb..0220c39b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt @@ -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 + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt index a7cf23a1..eaa37f68 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt @@ -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, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBanner.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBanner.kt index c79f17ef..6c2dce3c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBanner.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBanner.kt @@ -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, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepository.kt index 0fd76c68..3f125d97 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepository.kt @@ -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, AudioContentBannerQueryRepository interface AudioContentBannerQueryRepository { - fun getAudioContentMainBannerList(tabId: Long, isAdult: Boolean): List + fun getAudioContentMainBannerList(tabId: Long, isAdult: Boolean, lang: Lang? = null): List } class AudioContentBannerQueryRepositoryImpl( private val queryFactory: JPAQueryFactory ) : AudioContentBannerQueryRepository { - override fun getAudioContentMainBannerList(tabId: Long, isAdult: Boolean): List { + override fun getAudioContentMainBannerList(tabId: Long, isAdult: Boolean, lang: Lang?): List { 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) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerService.kt index 4aaaffa7..c8904ca5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerService.kt @@ -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 { - return repository.getAudioContentMainBannerList(tabId, isAdult) + fun getBannerList(tabId: Long, memberId: Long?, isAdult: Boolean, lang: Lang? = null): List { + return repository.getAudioContentMainBannerList(tabId, isAdult, lang) .filter { if (it.type == AudioContentBannerType.CREATOR && it.creator != null && memberId != null) { !isBlockedBetweenMembers(memberId = memberId, creatorId = it.creator!!.id!!) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainController.kt index 41c6fde9..0d21dc79 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainController.kt @@ -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) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerService.kt index 8ea977b8..fbce2efa 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerService.kt @@ -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 { + 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) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBanner.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBanner.kt index 2b4cc810..d460cee3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBanner.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBanner.kt @@ -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() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBannerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBannerRepository.kt index c2c2052f..b730bab9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBannerRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBannerRepository.kt @@ -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 { fun findByIsActiveTrueOrderBySortOrderAsc(pageable: Pageable): Page + fun findByIsActiveTrueAndLangOrderBySortOrderAsc(lang: Lang, pageable: Pageable): Page + @Query("SELECT MAX(b.sortOrder) FROM SeriesBanner b WHERE b.isActive = true") fun findMaxSortOrder(): Int? } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt index eba2d596..0c782da1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt @@ -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) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/Lang.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/Lang.kt index d40abe32..a1ceea97 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/Lang.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/Lang.kt @@ -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자리만 보내지만 안전하게 처리 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendCacheService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendCacheService.kt index 811302fd..0e5dd25a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendCacheService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendCacheService.kt @@ -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 { + fun getRecommendLive(memberId: Long?, isAdult: Boolean, lang: Lang): List { return repository.getRecommendLive( memberId = memberId, - isAdult = isAdult + isAdult = isAdult, + lang = lang ) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendController.kt index d1b7280a..bd29b813 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendController.kt @@ -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") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepository.kt index 50901f77..cece4a56 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepository.kt @@ -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 { 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) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt index 108d1249..0a2a36fe 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt @@ -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 { + fun getRecommendLive(member: Member?, lang: Lang): List { 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 ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/RecommendLiveCreatorBanner.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/RecommendLiveCreatorBanner.kt index b56d0309..64bf3f16 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/RecommendLiveCreatorBanner.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/RecommendLiveCreatorBanner.kt @@ -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 diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/ContentSettlementCalculationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/ContentSettlementCalculationTest.kt new file mode 100644 index 00000000..75f095e7 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/ContentSettlementCalculationTest.kt @@ -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) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerControllerTest.kt new file mode 100644 index 00000000..19a37eea --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerControllerTest.kt @@ -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 + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentServiceTest.kt new file mode 100644 index 00000000..027bd62b --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentServiceTest.kt @@ -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 + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerServiceTest.kt new file mode 100644 index 00000000..6ce901d5 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerServiceTest.kt @@ -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) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerControllerTest.kt new file mode 100644 index 00000000..dc46b303 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerControllerTest.kt @@ -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 + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveServiceTest.kt new file mode 100644 index 00000000..b8ed2976 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveServiceTest.kt @@ -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) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/api/home/HomeServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/api/home/HomeServiceTest.kt new file mode 100644 index 00000000..245e857f --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/api/home/HomeServiceTest.kt @@ -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()).`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) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponServiceTest.kt new file mode 100644 index 00000000..651ec3fb --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponServiceTest.kt @@ -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 + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterControllerTest.kt new file mode 100644 index 00000000..bc949f1e --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterControllerTest.kt @@ -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 + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerServiceTest.kt new file mode 100644 index 00000000..8475c57f --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerServiceTest.kt @@ -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(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 + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepositoryTest.kt new file mode 100644 index 00000000..ddf5792b --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepositoryTest.kt @@ -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) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainControllerTest.kt new file mode 100644 index 00000000..a560232b --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainControllerTest.kt @@ -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 + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerServiceTest.kt new file mode 100644 index 00000000..ce77f408 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerServiceTest.kt @@ -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 + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepositoryTest.kt index f0a75628..a1de892a 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepositoryTest.kt @@ -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" ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendServiceTest.kt index 2190e34a..db60bf77 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendServiceTest.kt @@ -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) diff --git a/work/scripts/check-commit-message-rules.sh b/work/scripts/check-commit-message-rules.sh index f95abbe3..7be904db 100755 --- a/work/scripts/check-commit-message-rules.sh +++ b/work/scripts/check-commit-message-rules.sh @@ -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:]]*$' >/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