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

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

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -113,17 +113,20 @@
- `type`은 소문자(`feat`, `fix`, `chore`, `docs`, `refactor`, `test` 등)를 사용한다. - `type`은 소문자(`feat`, `fix`, `chore`, `docs`, `refactor`, `test` 등)를 사용한다.
- 제목(description)은 한글로 작성하고, 명령형/간결한 현재형으로 작성한다. - 제목(description)은 한글로 작성하고, 명령형/간결한 현재형으로 작성한다.
- 이슈 참조 footer는 `Refs: #123` 또는 `Refs: #123, #456` 형식을 사용한다. - 이슈 참조 footer는 `Refs: #123` 또는 `Refs: #123, #456` 형식을 사용한다.
- 커밋 본문에는 `Ultraworked with [Sisyphus]...``Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>` 자동 footer를 포함하지 않는다.
### 커밋 메시지 검증 절차 ### 커밋 메시지 검증 절차
- `git commit` 실행 직전에 `work/scripts/check-commit-message-rules.sh`를 실행해 규칙 준수 여부를 확인한다. - `git commit` 실행 직전에 `work/scripts/check-commit-message-rules.sh`를 실행해 규칙 준수 여부를 확인한다.
- `git commit` 실행 직후에도 `work/scripts/check-commit-message-rules.sh`를 다시 실행해 최종 메시지를 재검증한다. - `git commit` 실행 직후에도 `work/scripts/check-commit-message-rules.sh`를 다시 실행해 최종 메시지를 재검증한다.
- 스크립트 결과가 `[FAIL]`이면 커밋 메시지를 규칙에 맞게 수정한 뒤 다시 검증한다. - 스크립트 결과가 `[FAIL]`이면 커밋 메시지를 규칙에 맞게 수정한 뒤 다시 검증한다.
- 커밋 실행 시 검증한 메시지를 그대로 사용하고, 도구 기본 footer가 자동 추가되지 않도록 최종 커밋 본문을 확인한다.
## 작업 절차 체크리스트 ## 작업 절차 체크리스트
- 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다. - 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다.
- 변경 중: 공개 API 스키마를 임의 변경하지 말고, 작은 단위로 안전하게 수정한다. - 변경 중: 공개 API 스키마를 임의 변경하지 말고, 작은 단위로 안전하게 수정한다.
- 변경 후: 최소 단일 테스트 또는 `./gradlew test`를 실행하고, 필요 시 `./gradlew ktlintCheck`를 수행한다. - 변경 후: 최소 단일 테스트 또는 `./gradlew test`를 실행하고, 필요 시 `./gradlew ktlintCheck`를 수행한다.
- 커밋 전/후: `commit-policy` 스킬을 먼저 로드하고, `git commit` 직전과 직후에 `work/scripts/check-commit-message-rules.sh`를 실행해 커밋 메시지 규칙 준수 여부를 확인한다. - 커밋 전/후: `commit-policy` 스킬을 먼저 로드하고, `git commit` 직전과 직후에 `work/scripts/check-commit-message-rules.sh`를 실행해 커밋 메시지 규칙 준수 여부를 확인한다.
- 커밋 전/후 확인 시 Sisyphus attribution footer가 없는지 함께 검증한다.
## 작업 계획 문서 규칙 (docs) ## 작업 계획 문서 규칙 (docs)
- 모든 작업 시작 전에 `docs` 폴더 아래에 계획 문서를 먼저 생성하고, 해당 문서를 기준으로 구현을 진행한다. - 모든 작업 시작 전에 `docs` 폴더 아래에 계획 문서를 먼저 생성하고, 해당 문서를 기준으로 구현을 진행한다.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -62,7 +62,13 @@ class AdminChatBannerController(
val banners = bannerService.getActiveBanners(pageable) val banners = bannerService.getActiveBanners(pageable)
val response = ChatCharacterBannerListPageResponse( val response = ChatCharacterBannerListPageResponse(
totalCount = banners.totalElements, 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) ApiResponse.ok(response)
@@ -127,7 +133,8 @@ class AdminChatBannerController(
// 1. 먼저 빈 이미지 경로로 배너 등록 (정렬 순서 포함) // 1. 먼저 빈 이미지 경로로 배너 등록 (정렬 순서 포함)
val banner = bannerService.registerBanner( val banner = bannerService.registerBanner(
characterId = request.characterId, characterId = request.characterId,
imagePath = "" imagePath = "",
lang = request.lang
) )
// 2. 배너 ID를 사용하여 이미지 업로드 // 2. 배너 ID를 사용하여 이미지 업로드

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory 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.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBanner import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBanner
@@ -122,7 +123,8 @@ class AdminLiveService(
creatorId: Long, creatorId: Long,
startDateString: String, startDateString: String,
endDateString: String, endDateString: String,
isAdult: Boolean isAdult: Boolean,
lang: String
): Long { ): Long {
if (creatorId < 1) throw SodaException(messageKey = "admin.live.creator_required") 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 < nowDate) throw SodaException(messageKey = "admin.live.end_after_now")
if (endDate <= startDate) throw SodaException(messageKey = "admin.live.start_before_end") 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( val recommendCreatorBanner = RecommendLiveCreatorBanner(
startDate = startDate, startDate = startDate,
endDate = endDate, endDate = endDate,
isAdult = isAdult isAdult = isAdult,
lang = bannerLang
) )
recommendCreatorBanner.creator = creator recommendCreatorBanner.creator = creator
recommendCreatorBannerRepository.save(recommendCreatorBanner) recommendCreatorBannerRepository.save(recommendCreatorBanner)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.i18n package kr.co.vividnext.sodalive.i18n
import com.fasterxml.jackson.annotation.JsonCreator
import java.util.Locale import java.util.Locale
enum class Lang(val code: String, val locale: 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); JA("ja", Locale.JAPANESE);
companion object { 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 { fun fromAcceptLanguage(header: String?): Lang {
if (header.isNullOrBlank()) return KO if (header.isNullOrBlank()) return KO
val two = header.trim().lowercase().take(2) // 앱은 2자리만 보내지만 안전하게 처리 val two = header.trim().lowercase().take(2) // 앱은 2자리만 보내지만 안전하게 처리

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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