1
.gitignore
vendored
1
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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` 폴더 아래에 계획 문서를 먼저 생성하고, 해당 문서를 기준으로 구현을 진행한다.
|
||||||
|
|||||||
22
docs/20260402_AI캐릭터본인인증국가별분기적용.md
Normal file
22
docs/20260402_AI캐릭터본인인증국가별분기적용.md
Normal 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` 검색 → 결과 없음
|
||||||
41
docs/20260402_audio_content_banner_lang_ddl.sql
Normal file
41
docs/20260402_audio_content_banner_lang_ddl.sql
Normal 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;
|
||||||
41
docs/20260402_chat_character_banner_lang_ddl.sql
Normal file
41
docs/20260402_chat_character_banner_lang_ddl.sql
Normal 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;
|
||||||
41
docs/20260402_live_recommend_creator_banner_lang_ddl.sql
Normal file
41
docs/20260402_live_recommend_creator_banner_lang_ddl.sql
Normal 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;
|
||||||
10
docs/20260402_관리자채팅배너목록언어표기추가.md
Normal file
10
docs/20260402_관리자채팅배너목록언어표기추가.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
- [x] 배너 목록 조회 응답 생성 경로와 언어 정보 위치를 확인한다.
|
||||||
|
- [x] 배너 목록 응답의 연결 캐릭터 이름에 배너 등록 언어를 `(언어)` 형식으로 추가한다.
|
||||||
|
- [x] 변경 파일 기준으로 검증을 수행하고 결과를 기록한다.
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
|
||||||
|
### 1차 구현
|
||||||
|
- 무엇을: 관리자 배너 목록 조회 응답에서 연결 캐릭터 이름 뒤에 배너 등록 언어를 `(언어)` 형식으로 붙이도록 수정했다.
|
||||||
|
- 왜: 같은 이름과 같은 이미지의 배너라도 등록 언어가 다르면 관리자 페이지에서 즉시 구분할 수 있어야 하기 때문이다.
|
||||||
|
- 어떻게: `./gradlew test --tests "kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest"` 실행으로 컨트롤러 테스트를 검증했고, 새 테스트에서 목록 조회 응답 이름이 `character-12 (일본어)`로 반환되는 것을 확인했다. 결과는 `BUILD SUCCESSFUL`이다.
|
||||||
11
docs/20260402_라이브추천크리에이터언어적용.md
Normal file
11
docs/20260402_라이브추천크리에이터언어적용.md
Normal 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`이다.
|
||||||
10
docs/20260402_시리즈배너언어별조회적용.md
Normal file
10
docs/20260402_시리즈배너언어별조회적용.md
Normal 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`이다.
|
||||||
11
docs/20260402_오디오콘텐츠배너언어적용.md
Normal file
11
docs/20260402_오디오콘텐츠배너언어적용.md
Normal 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`이다.
|
||||||
25
docs/20260402_일본어채팅캐릭터배너추가.md
Normal file
25
docs/20260402_일본어채팅캐릭터배너추가.md
Normal 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`는 수행하지 못했다.
|
||||||
10
docs/20260402_쿠폰사용본인인증예외추가.md
Normal file
10
docs/20260402_쿠폰사용본인인증예외추가.md
Normal 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` 실행 성공.
|
||||||
15
docs/20260403_메시지전송username추가.md
Normal file
15
docs/20260403_메시지전송username추가.md
Normal 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`을 확인했다.
|
||||||
10
docs/20260406_omxgitignore.md
Normal file
10
docs/20260406_omxgitignore.md
Normal 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/`가 더 이상 추적 대상이 아니고 문서만 남는지 확인했다.
|
||||||
19
docs/20260407_audio_content_settlement_ratio_ddl.sql
Normal file
19
docs/20260407_audio_content_settlement_ratio_ddl.sql
Normal 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;
|
||||||
22
docs/20260407_커밋footer자동추가차단.md
Normal file
22
docs/20260407_커밋footer자동추가차단.md
Normal 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을 확인했다.
|
||||||
85
docs/20260407_콘텐츠별정산요율추가.md
Normal file
85
docs/20260407_콘텐츠별정산요율추가.md
Normal 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 회귀 확인
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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를 사용하여 이미지 업로드
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 -> "일본어"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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?,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 -> "일본어"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
"등록되었습니다."
|
"등록되었습니다."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 배너 수정
|
* 배너 수정
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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!!)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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자리만 보내지만 안전하게 처리
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user