Compare commits
19 Commits
39025fc3f3
...
abc3e8e9aa
| Author | SHA1 | Date | |
|---|---|---|---|
| abc3e8e9aa | |||
| 6fa7044220 | |||
| 7be8a8c917 | |||
| 530e38c1ad | |||
| f2c2473a47 | |||
| b85c61bd0b | |||
| 9305dc600d | |||
| 0afab91d72 | |||
| 0c5234c09a | |||
| 082d8457eb | |||
| a0f0d82b63 | |||
| 5c132c984d | |||
| f6a07faef2 | |||
| 5cf1f7d909 | |||
| 268ed751c3 | |||
| ff9053d54d | |||
| 74414937cf | |||
| 72e6efe3e6 | |||
| 685209d47d |
269
docs/20260611_AI캐릭터_크리에이터기능_최소연결/alter-existing-tables.sql
Normal file
269
docs/20260611_AI캐릭터_크리에이터기능_최소연결/alter-existing-tables.sql
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
-- AI 캐릭터 크리에이터 기능 최소 연결 운영 DB 반영 SQL
|
||||||
|
-- MySQL 기준. 운영 반영 전 백업과 트랜잭션/락 영향을 점검한다.
|
||||||
|
|
||||||
|
-- 1. member.member_kind 추가
|
||||||
|
SET @member_kind_column_exists := (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'member'
|
||||||
|
AND column_name = 'member_kind'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @add_member_kind_sql := IF(
|
||||||
|
@member_kind_column_exists = 0,
|
||||||
|
'ALTER TABLE member ADD COLUMN member_kind VARCHAR(30) NOT NULL DEFAULT ''HUMAN'' COMMENT ''Member 주체 종류: HUMAN, AI_CHARACTER'' AFTER role',
|
||||||
|
'SELECT ''member.member_kind already exists'' AS message'
|
||||||
|
);
|
||||||
|
|
||||||
|
PREPARE add_member_kind_stmt FROM @add_member_kind_sql;
|
||||||
|
EXECUTE add_member_kind_stmt;
|
||||||
|
DEALLOCATE PREPARE add_member_kind_stmt;
|
||||||
|
|
||||||
|
-- 1번 결과 확인: varchar(30), NOT NULL, DEFAULT 'HUMAN'
|
||||||
|
SELECT column_name, column_type, is_nullable, column_default, column_comment
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'member'
|
||||||
|
AND column_name = 'member_kind';
|
||||||
|
|
||||||
|
-- 2. chat_character.creator_member_id nullable 컬럼 추가
|
||||||
|
SET @creator_member_column_exists := (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'chat_character'
|
||||||
|
AND column_name = 'creator_member_id'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @add_creator_member_sql := IF(
|
||||||
|
@creator_member_column_exists = 0,
|
||||||
|
'ALTER TABLE chat_character ADD COLUMN creator_member_id BIGINT NULL COMMENT ''크리에이터 기능 주체 Member ID'' AFTER character_type',
|
||||||
|
'SELECT ''chat_character.creator_member_id already exists'' AS message'
|
||||||
|
);
|
||||||
|
|
||||||
|
PREPARE add_creator_member_stmt FROM @add_creator_member_sql;
|
||||||
|
EXECUTE add_creator_member_stmt;
|
||||||
|
DEALLOCATE PREPARE add_creator_member_stmt;
|
||||||
|
|
||||||
|
-- 2번 결과 확인: bigint, NULL 허용
|
||||||
|
SELECT column_name, column_type, is_nullable, column_comment
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'chat_character'
|
||||||
|
AND column_name = 'creator_member_id';
|
||||||
|
|
||||||
|
-- 3. 기존 chat_character별 AI 캐릭터용 Member 생성 및 매핑
|
||||||
|
DROP TEMPORARY TABLE IF EXISTS tmp_chat_character_creator_member;
|
||||||
|
CREATE TEMPORARY TABLE tmp_chat_character_creator_member (
|
||||||
|
chat_character_id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
migration_email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
creator_member_id BIGINT NULL
|
||||||
|
) COMMENT 'chat_character와 backfill member.id 임시 매핑';
|
||||||
|
|
||||||
|
INSERT INTO tmp_chat_character_creator_member (chat_character_id, migration_email)
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
CONCAT('__ai_character_creator_', c.id, '@migration.local')
|
||||||
|
FROM chat_character c
|
||||||
|
WHERE c.creator_member_id IS NULL;
|
||||||
|
|
||||||
|
-- member.email은 nullable이므로 backfill 중에만 임시 식별자로 사용하고, 매핑 후 NULL로 되돌린다.
|
||||||
|
INSERT INTO member (
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
nickname,
|
||||||
|
profile_image,
|
||||||
|
provider,
|
||||||
|
gender,
|
||||||
|
role,
|
||||||
|
member_kind,
|
||||||
|
is_visible_donation_rank,
|
||||||
|
donation_ranking_period,
|
||||||
|
is_active,
|
||||||
|
container,
|
||||||
|
introduce,
|
||||||
|
instagram_url,
|
||||||
|
fancimm_url,
|
||||||
|
x_url,
|
||||||
|
youtube_url,
|
||||||
|
website_url,
|
||||||
|
blog_url,
|
||||||
|
pg_charge_can,
|
||||||
|
pg_reward_can,
|
||||||
|
google_charge_can,
|
||||||
|
google_reward_can,
|
||||||
|
apple_charge_can,
|
||||||
|
apple_reward_can,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
m.migration_email,
|
||||||
|
'',
|
||||||
|
c.name,
|
||||||
|
c.image_path,
|
||||||
|
'EMAIL',
|
||||||
|
'NONE',
|
||||||
|
'CREATOR',
|
||||||
|
'AI_CHARACTER',
|
||||||
|
TRUE,
|
||||||
|
'CUMULATIVE',
|
||||||
|
c.is_active,
|
||||||
|
'web',
|
||||||
|
COALESCE(c.description, ''),
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
FROM tmp_chat_character_creator_member m
|
||||||
|
INNER JOIN chat_character c
|
||||||
|
ON c.id = m.chat_character_id
|
||||||
|
LEFT JOIN member existing_member
|
||||||
|
ON existing_member.email = m.migration_email
|
||||||
|
WHERE existing_member.id IS NULL;
|
||||||
|
|
||||||
|
UPDATE tmp_chat_character_creator_member m
|
||||||
|
INNER JOIN member mb
|
||||||
|
ON mb.email = m.migration_email
|
||||||
|
SET m.creator_member_id = mb.id
|
||||||
|
WHERE m.creator_member_id IS NULL
|
||||||
|
AND m.chat_character_id IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE chat_character c
|
||||||
|
INNER JOIN tmp_chat_character_creator_member m
|
||||||
|
ON m.chat_character_id = c.id
|
||||||
|
SET c.creator_member_id = m.creator_member_id
|
||||||
|
WHERE c.creator_member_id IS NULL
|
||||||
|
AND m.creator_member_id IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE member mb
|
||||||
|
INNER JOIN tmp_chat_character_creator_member m
|
||||||
|
ON m.creator_member_id = mb.id
|
||||||
|
SET mb.email = NULL
|
||||||
|
WHERE mb.email = m.migration_email;
|
||||||
|
|
||||||
|
-- 4. unique index 추가
|
||||||
|
SET @creator_member_unique_exists := (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.statistics
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'chat_character'
|
||||||
|
AND index_name = 'uk_chat_character_creator_member'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @add_creator_member_unique_sql := IF(
|
||||||
|
@creator_member_unique_exists = 0,
|
||||||
|
'ALTER TABLE chat_character ADD UNIQUE INDEX uk_chat_character_creator_member (creator_member_id)',
|
||||||
|
'SELECT ''uk_chat_character_creator_member already exists'' AS message'
|
||||||
|
);
|
||||||
|
|
||||||
|
PREPARE add_creator_member_unique_stmt FROM @add_creator_member_unique_sql;
|
||||||
|
EXECUTE add_creator_member_unique_stmt;
|
||||||
|
DEALLOCATE PREPARE add_creator_member_unique_stmt;
|
||||||
|
|
||||||
|
-- 5. foreign key 추가
|
||||||
|
SET @creator_member_fk_exists := (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.table_constraints
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'chat_character'
|
||||||
|
AND constraint_name = 'fk_chat_character_creator_member'
|
||||||
|
AND constraint_type = 'FOREIGN KEY'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @add_creator_member_fk_sql := IF(
|
||||||
|
@creator_member_fk_exists = 0,
|
||||||
|
'ALTER TABLE chat_character ADD CONSTRAINT fk_chat_character_creator_member FOREIGN KEY (creator_member_id) REFERENCES member (id)',
|
||||||
|
'SELECT ''fk_chat_character_creator_member already exists'' AS message'
|
||||||
|
);
|
||||||
|
|
||||||
|
PREPARE add_creator_member_fk_stmt FROM @add_creator_member_fk_sql;
|
||||||
|
EXECUTE add_creator_member_fk_stmt;
|
||||||
|
DEALLOCATE PREPARE add_creator_member_fk_stmt;
|
||||||
|
|
||||||
|
-- 6. 운영 반영 전 필수 검증. 두 결과 모두 0이어야 한다.
|
||||||
|
SELECT COUNT(*) AS invalid_ai_character_member_count
|
||||||
|
FROM member
|
||||||
|
WHERE member_kind = 'AI_CHARACTER'
|
||||||
|
AND role <> 'CREATOR';
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS missing_creator_member_count
|
||||||
|
FROM chat_character
|
||||||
|
WHERE creator_member_id IS NULL;
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS remaining_migration_email_count
|
||||||
|
FROM member
|
||||||
|
WHERE email LIKE '__ai_character_creator_%@migration.local';
|
||||||
|
|
||||||
|
-- 7. 검증 완료 후 creator_member_id NOT NULL 전환
|
||||||
|
SET @missing_creator_member_count := (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM chat_character
|
||||||
|
WHERE creator_member_id IS NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @creator_member_nullable := (
|
||||||
|
SELECT is_nullable
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'chat_character'
|
||||||
|
AND column_name = 'creator_member_id'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @modify_creator_member_not_null_sql := IF(
|
||||||
|
@missing_creator_member_count = 0 AND @creator_member_nullable = 'YES',
|
||||||
|
'ALTER TABLE chat_character MODIFY COLUMN creator_member_id BIGINT NOT NULL COMMENT ''크리에이터 기능 주체 Member ID''',
|
||||||
|
'SELECT ''chat_character.creator_member_id not modified; verify missing_creator_member_count is 0 and column is nullable'' AS message'
|
||||||
|
);
|
||||||
|
|
||||||
|
PREPARE modify_creator_member_not_null_stmt FROM @modify_creator_member_not_null_sql;
|
||||||
|
EXECUTE modify_creator_member_not_null_stmt;
|
||||||
|
DEALLOCATE PREPARE modify_creator_member_not_null_stmt;
|
||||||
|
|
||||||
|
DROP TEMPORARY TABLE IF EXISTS tmp_chat_character_creator_member;
|
||||||
|
|
||||||
|
-- Rollback 참고용. 운영 반영 후 문제가 있으면 백업 복구를 우선 검토한다.
|
||||||
|
-- 아래 SQL은 이 마이그레이션으로 연결된 AI_CHARACTER Member와 제약/컬럼을 되돌리는 전체 롤백 예시다.
|
||||||
|
-- 신규 기능을 이미 운영에서 사용한 뒤에는 후속 데이터 의존성이 생길 수 있으므로 실행 전 영향 범위를 재확인한다.
|
||||||
|
-- 1) FK 제거
|
||||||
|
-- ALTER TABLE chat_character DROP FOREIGN KEY fk_chat_character_creator_member;
|
||||||
|
-- 2) unique index 제거
|
||||||
|
-- ALTER TABLE chat_character DROP INDEX uk_chat_character_creator_member;
|
||||||
|
-- 3) creator_member_id를 NULL 허용으로 복구
|
||||||
|
-- ALTER TABLE chat_character MODIFY COLUMN creator_member_id BIGINT NULL COMMENT '크리에이터 기능 주체 Member ID';
|
||||||
|
-- 4) backfill로 연결된 AI 캐릭터용 Member 삭제 준비
|
||||||
|
-- DROP TEMPORARY TABLE IF EXISTS tmp_rollback_ai_character_member;
|
||||||
|
-- CREATE TEMPORARY TABLE tmp_rollback_ai_character_member (
|
||||||
|
-- member_id BIGINT NOT NULL PRIMARY KEY
|
||||||
|
-- ) COMMENT 'AI 캐릭터 크리에이터 backfill 롤백 대상 Member';
|
||||||
|
-- INSERT INTO tmp_rollback_ai_character_member (member_id)
|
||||||
|
-- SELECT DISTINCT mb.id
|
||||||
|
-- FROM member mb
|
||||||
|
-- INNER JOIN chat_character c
|
||||||
|
-- ON c.creator_member_id = mb.id
|
||||||
|
-- WHERE mb.member_kind = 'AI_CHARACTER'
|
||||||
|
-- AND mb.role = 'CREATOR';
|
||||||
|
-- 5) chat_character 연결 해제 후 Member 삭제
|
||||||
|
-- UPDATE chat_character c
|
||||||
|
-- INNER JOIN tmp_rollback_ai_character_member r
|
||||||
|
-- ON r.member_id = c.creator_member_id
|
||||||
|
-- SET c.creator_member_id = NULL;
|
||||||
|
-- DELETE mb
|
||||||
|
-- FROM member mb
|
||||||
|
-- INNER JOIN tmp_rollback_ai_character_member r
|
||||||
|
-- ON r.member_id = mb.id;
|
||||||
|
-- DROP TEMPORARY TABLE IF EXISTS tmp_rollback_ai_character_member;
|
||||||
|
-- 6) 컬럼 제거가 필요한 전체 스키마 롤백인 경우에만 실행
|
||||||
|
-- ALTER TABLE chat_character DROP COLUMN creator_member_id;
|
||||||
|
-- ALTER TABLE member DROP COLUMN member_kind;
|
||||||
317
docs/20260611_AI캐릭터_크리에이터기능_최소연결/plan-task.md
Normal file
317
docs/20260611_AI캐릭터_크리에이터기능_최소연결/plan-task.md
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
# AI 캐릭터 크리에이터 기능 최소 연결 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 모든 `ChatCharacter`를 `Member(role = CREATOR, memberKind = AI_CHARACTER)`와 1:1로 연결해 로그인/DM을 제외한 기존 크리에이터 기능을 최소 변경으로 재사용한다.
|
||||||
|
|
||||||
|
**Architecture:** 기존 크리에이터 기능의 소유자는 계속 `Member`로 유지한다. `ChatCharacter`는 `creatorMember`를 단방향 `OneToOne`으로 가지며, AI 캐릭터용 Member의 표시 정보는 `ChatCharacter` 값에서 스냅샷으로 동기화한다. 사람/AI 주체 구분은 `Member.memberKind`로 처리하고, 로그인/DM만 명시적으로 차단한다.
|
||||||
|
|
||||||
|
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring Security, JPA/Hibernate, QueryDSL, MySQL, Gradle Wrapper, JUnit5, Mockito.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- 포함: `MemberKind` 추가, `ChatCharacter.creatorMember` 1:1 관계 추가, MySQL DDL/backfill SQL, AI 캐릭터용 Member 생성/표시 정보 동기화, 로그인 차단, DM 차단.
|
||||||
|
- 제외: `creator_identity` 도입, `ChatCharacter` 독립 소유자화, 검색 카테고리 개편, `Member:ChatCharacter = 1:N`, AI 캐릭터용 콘텐츠/라이브/커뮤니티 대리 생성 API 설계.
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt`
|
||||||
|
- `MemberKind` enum과 `memberKind` 필드 추가.
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt`
|
||||||
|
- `creatorMember: Member` 단방향 `OneToOne` 추가.
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt`
|
||||||
|
- `creatorMember` 조회/검증 메서드 추가.
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterCreatorMemberService.kt`
|
||||||
|
- AI 캐릭터용 Member 생성 및 표시 정보 동기화 책임.
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt`
|
||||||
|
- 캐릭터 생성/수정 시 `ChatCharacterCreatorMemberService` 호출.
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt`
|
||||||
|
- 일반 로그인에서 `memberKind = AI_CHARACTER` 차단.
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberService.kt`
|
||||||
|
- 크리에이터 관리자 로그인에서 `memberKind = AI_CHARACTER` 차단.
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`
|
||||||
|
- DM 생성/메시지 발송 대상이 `memberKind = AI_CHARACTER`이면 차단.
|
||||||
|
- Create: `docs/20260611_AI캐릭터_크리에이터기능_최소연결/alter-existing-tables.sql`
|
||||||
|
- 운영 DB 반영용 MySQL DDL/backfill/검증 SQL.
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterCreatorMemberServiceTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberServiceTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 1: MemberKind 및 DB 마이그레이션 기반 추가
|
||||||
|
|
||||||
|
- [x] **Task 1.1: `MemberKind` 필드 추가**
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt`
|
||||||
|
- Test: 기존 컴파일 회귀
|
||||||
|
- RED: `Member.memberKind`를 참조하는 최소 컴파일 테스트 또는 이후 task 테스트를 먼저 작성하면 현재 컴파일이 실패해야 한다.
|
||||||
|
- GREEN: `Member` 생성자에 기본값 `MemberKind.HUMAN`을 가진 non-null 필드를 추가한다.
|
||||||
|
- 구현 기준:
|
||||||
|
```kotlin
|
||||||
|
@Enumerated(value = EnumType.STRING)
|
||||||
|
var memberKind: MemberKind = MemberKind.HUMAN
|
||||||
|
```
|
||||||
|
```kotlin
|
||||||
|
enum class MemberKind {
|
||||||
|
HUMAN, AI_CHARACTER
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- REFACTOR: `MemberRole`과 `MemberKind` 의미가 섞이지 않도록 주석은 최소화하고, 정책 판단은 각 서비스 task에서 명시한다.
|
||||||
|
- Verify:
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
||||||
|
- Expected: 기존 테스트 컴파일 및 통과.
|
||||||
|
|
||||||
|
- [x] **Task 1.2: 운영 DB DDL/backfill SQL 작성**
|
||||||
|
- Create: `docs/20260611_AI캐릭터_크리에이터기능_최소연결/alter-existing-tables.sql`
|
||||||
|
- TDD 예외 사유: 운영 DDL 문서 작성은 단위 테스트 대상이 아니다.
|
||||||
|
- 대체 검증 방법: SQL 문법과 PRD 요구사항을 수동 점검하고 검증 쿼리를 포함한다.
|
||||||
|
- SQL 작성 기준:
|
||||||
|
- `member.member_kind varchar(30) not null default 'HUMAN' comment 'Member 주체 종류: HUMAN, AI_CHARACTER'`
|
||||||
|
- `chat_character.creator_member_id bigint null comment '크리에이터 기능 주체 Member ID'`
|
||||||
|
- `uk_chat_character_creator_member` unique index
|
||||||
|
- `fk_chat_character_creator_member` foreign key
|
||||||
|
- 기존 모든 `chat_character`별 AI 캐릭터용 Member 생성
|
||||||
|
- 생성된 Member를 `chat_character.creator_member_id`에 연결
|
||||||
|
- 검증 후 `chat_character.creator_member_id not null` 전환
|
||||||
|
- SQL backfill은 최종적으로 `email = null`, `password = ''`, `role = 'CREATOR'`, `member_kind = 'AI_CHARACTER'` 상태가 되도록 생성한다.
|
||||||
|
- `member.email`은 nullable이므로 저장 프로시저 대신 backfill 중 임시 식별자로 사용할 수 있다. 단, `chat_character.creator_member_id` 매핑 후 임시 email 값은 반드시 `NULL`로 되돌린다.
|
||||||
|
- 운영 반영 후 문제에 대비해 FK/index/연결 데이터/컬럼 제거 순서의 롤백 방법을 SQL 문서에 함께 기록한다.
|
||||||
|
- Verify:
|
||||||
|
- SQL 내 검증 쿼리 포함:
|
||||||
|
```sql
|
||||||
|
select count(*) as invalid_ai_character_member_count
|
||||||
|
from member
|
||||||
|
where member_kind = 'AI_CHARACTER' and role <> 'CREATOR';
|
||||||
|
|
||||||
|
select count(*) as missing_creator_member_count
|
||||||
|
from chat_character
|
||||||
|
where creator_member_id is null;
|
||||||
|
```
|
||||||
|
- Expected: 운영 반영 전 두 검증 쿼리 결과가 모두 0이어야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: ChatCharacter와 AI 캐릭터용 Member 연결
|
||||||
|
|
||||||
|
- [x] **Task 2.1: `ChatCharacter.creatorMember` 관계 추가**
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterCreatorMemberServiceTest.kt`
|
||||||
|
- RED: 테스트에서 `ChatCharacter.creatorMember`에 접근하거나 `findByCreatorMemberId`를 호출하게 작성해 컴파일 실패를 확인한다.
|
||||||
|
- GREEN: `ChatCharacter`에 단방향 `OneToOne`을 추가한다.
|
||||||
|
```kotlin
|
||||||
|
@OneToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "creator_member_id", nullable = false, unique = true)
|
||||||
|
var creatorMember: Member? = null
|
||||||
|
```
|
||||||
|
- Repository 메서드 기준:
|
||||||
|
```kotlin
|
||||||
|
fun findByCreatorMemberId(creatorMemberId: Long): ChatCharacter?
|
||||||
|
fun existsByCreatorMemberId(creatorMemberId: Long): Boolean
|
||||||
|
```
|
||||||
|
- REFACTOR: `ChatCharacter`에서 `Member` import만 추가하고, 기존 캐릭터 필드/관계는 변경하지 않는다.
|
||||||
|
- Verify:
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberServiceTest`
|
||||||
|
- Expected: 관계 접근 컴파일 및 테스트 통과.
|
||||||
|
|
||||||
|
- [x] **Task 2.2: AI 캐릭터용 Member 생성/표시 정보 동기화 서비스 추가**
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterCreatorMemberService.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterCreatorMemberServiceTest.kt`
|
||||||
|
- RED: 아래 테스트를 먼저 작성한다.
|
||||||
|
- `shouldCreateAiCharacterMemberAndCopyDisplayFields`
|
||||||
|
- `shouldSyncAiCharacterMemberDisplayFields`
|
||||||
|
- `shouldNotOverwriteHumanCreatorDisplayFields`
|
||||||
|
- 테스트 기대:
|
||||||
|
- 생성 시 `role = CREATOR`, `memberKind = AI_CHARACTER`, `email = null`, `password = ""`
|
||||||
|
- `Member.nickname = ChatCharacter.name`
|
||||||
|
- `Member.profileImage = ChatCharacter.imagePath`
|
||||||
|
- `Member.introduce = ChatCharacter.description`
|
||||||
|
- 연결된 `creatorMember.memberKind = HUMAN`이면 표시 정보 덮어쓰기 없음.
|
||||||
|
- GREEN: 서비스 API를 아래 기준으로 구현한다.
|
||||||
|
```kotlin
|
||||||
|
fun ensureAiCharacterCreatorMember(chatCharacter: ChatCharacter): Member
|
||||||
|
fun syncAiCharacterCreatorMemberDisplayFields(chatCharacter: ChatCharacter)
|
||||||
|
```
|
||||||
|
- 구현 정책:
|
||||||
|
- `chatCharacter.creatorMember == null`이면 AI 캐릭터용 Member를 생성하고 연결한다.
|
||||||
|
- `chatCharacter.creatorMember.memberKind == AI_CHARACTER`이면 표시 정보를 동기화한다.
|
||||||
|
- `chatCharacter.creatorMember.memberKind == HUMAN`이면 사람 크리에이터 프로필을 덮어쓰지 않는다.
|
||||||
|
- REFACTOR: 동기화 로직은 `ChatCharacterService`에 직접 흩뿌리지 않고 이 서비스로 모은다.
|
||||||
|
- Verify:
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberServiceTest`
|
||||||
|
- Expected: PASS.
|
||||||
|
|
||||||
|
- [x] **Task 2.3: 캐릭터 생성/수정 흐름에 AI 캐릭터용 Member 연결**
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterCreatorMemberServiceTest.kt`
|
||||||
|
- RED: 캐릭터 생성 후 `creatorMember`가 생성되고, 이미지 저장 후 `Member.profileImage`가 갱신되는 테스트를 작성한다.
|
||||||
|
- GREEN:
|
||||||
|
- `createChatCharacter` 또는 `createChatCharacterWithDetails` 저장 후 `ensureAiCharacterCreatorMember`를 호출한다.
|
||||||
|
- 관리자 등록 컨트롤러에서 이미지 저장 후 `chatCharacter.imagePath`를 설정하고 저장한 뒤 `syncAiCharacterCreatorMemberDisplayFields`를 호출한다.
|
||||||
|
- `updateChatCharacterWithDetails`에서 이름/설명/이미지 변경 후 `syncAiCharacterCreatorMemberDisplayFields`를 호출한다.
|
||||||
|
- REFACTOR: 외부 API 호출, S3 업로드, 원작 연결, 언어 감지 이벤트 흐름은 기존 순서를 유지한다.
|
||||||
|
- Verify:
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberServiceTest`
|
||||||
|
- Expected: PASS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: 로그인 및 DM 차단
|
||||||
|
|
||||||
|
- [x] **Task 3.1: 일반 로그인에서 AI 캐릭터용 Member 차단**
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt`
|
||||||
|
- RED: `memberKind = AI_CHARACTER`인 Member가 일반 로그인 요청 시 인증 매니저 호출 전에 예외가 발생하는 테스트를 작성한다.
|
||||||
|
- GREEN: `MemberService.login(...)`의 Member 조회/활성 검증 직후 아래 정책을 추가한다.
|
||||||
|
```kotlin
|
||||||
|
if (member.memberKind == MemberKind.AI_CHARACTER) {
|
||||||
|
throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- REFACTOR: 기존 `provider`, `isCreator`, `isAdmin` 검증 순서는 불필요하게 바꾸지 않는다.
|
||||||
|
- Verify:
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.member.MemberServiceTest`
|
||||||
|
- Expected: AI 캐릭터 로그인 차단 테스트 PASS.
|
||||||
|
|
||||||
|
- [x] **Task 3.2: 크리에이터 관리자 로그인에서 AI 캐릭터용 Member 차단**
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberService.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberServiceTest.kt`
|
||||||
|
- RED: `memberKind = AI_CHARACTER`, `role = CREATOR`인 Member가 크리에이터 관리자 로그인 요청 시 `common.error.bad_credentials` 예외가 발생하는 테스트를 작성한다.
|
||||||
|
- GREEN: `CreatorAdminMemberService.login(email, password)`에서 role 검증 전 또는 직후 AI 캐릭터용 Member를 차단한다.
|
||||||
|
```kotlin
|
||||||
|
if (member.memberKind == MemberKind.AI_CHARACTER) {
|
||||||
|
throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- REFACTOR: `AGENT` 로그인 허용 정책은 변경하지 않는다.
|
||||||
|
- Verify:
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberServiceTest`
|
||||||
|
- Expected: PASS.
|
||||||
|
|
||||||
|
- [x] **Task 3.3: 유저-크리에이터 DM에서 AI 캐릭터용 Member 차단**
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt`
|
||||||
|
- RED: 아래 테스트를 추가한다.
|
||||||
|
- `shouldRejectCreateRoomWhenCreatorIsAiCharacterMember`
|
||||||
|
- `shouldRejectSendTextMessageWhenOpponentIsAiCharacterMember`
|
||||||
|
- `memberRepository.findById(creatorId)`는 `role = CREATOR`, `memberKind = AI_CHARACTER`인 Member를 반환한다.
|
||||||
|
- `service.createOrGetRoom(user, creatorId)`는 예외를 던진다.
|
||||||
|
- `roomRepository.save`와 `participantRepository.save`는 호출되지 않는다.
|
||||||
|
- 기존 방에 AI 캐릭터용 Member가 참여한 상태에서 `sendTextMessage`는 예외를 던진다.
|
||||||
|
- `messageRepository.save`와 푸시 발송 경로는 호출되지 않는다.
|
||||||
|
- GREEN: `validateRecipient` 또는 `createOrGetRoom`에서 recipient가 AI 캐릭터용 Member이면 차단한다.
|
||||||
|
```kotlin
|
||||||
|
if (recipient.memberKind == MemberKind.AI_CHARACTER) {
|
||||||
|
throw SodaException(messageKey = "message.error.recipient_not_found")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- REFACTOR: 기존 비활성/본인/차단 검증 메시지와 우선순위를 불필요하게 바꾸지 않는다.
|
||||||
|
- Verify:
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
||||||
|
- Expected: 기존 DM 테스트와 신규 생성/발송 차단 테스트 PASS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: 회귀 검증 및 문서 정리
|
||||||
|
|
||||||
|
- [x] **Task 4.1: 핵심 단위 테스트 실행**
|
||||||
|
- Files: 변경 없음
|
||||||
|
- TDD 예외 사유: 구현 완료 후 회귀 검증 task다.
|
||||||
|
- 대체 검증 방법: 관련 단일 테스트를 모두 실행한다.
|
||||||
|
- Run:
|
||||||
|
```bash
|
||||||
|
./gradlew test \
|
||||||
|
--tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberServiceTest \
|
||||||
|
--tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberServiceIntegrationTest \
|
||||||
|
--tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest \
|
||||||
|
--tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest \
|
||||||
|
--tests kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberServiceTest \
|
||||||
|
--tests kr.co.vividnext.sodalive.member.MemberServiceTest
|
||||||
|
```
|
||||||
|
- Expected: PASS.
|
||||||
|
|
||||||
|
- [x] **Task 4.2: 정적 검증 및 전체 회귀**
|
||||||
|
- Files: 변경 없음
|
||||||
|
- TDD 예외 사유: 전체 회귀 검증 task다.
|
||||||
|
- 대체 검증 방법: Gradle 테스트와 ktlint를 실행한다.
|
||||||
|
- Run:
|
||||||
|
```bash
|
||||||
|
./gradlew ktlintCheck
|
||||||
|
./gradlew test
|
||||||
|
```
|
||||||
|
- Expected: 두 명령 모두 PASS.
|
||||||
|
|
||||||
|
- [x] **Task 4.3: 검증 기록 누적**
|
||||||
|
- Modify: `docs/20260611_AI캐릭터_크리에이터기능_최소연결/plan-task.md`
|
||||||
|
- TDD 예외 사유: 문서 기록 task다.
|
||||||
|
- 대체 검증 방법: 실행한 명령, 목적, 결과를 아래 검증 기록 섹션에 누적한다.
|
||||||
|
- 기록 형식:
|
||||||
|
```markdown
|
||||||
|
- `./gradlew test --tests ...`
|
||||||
|
- 목적: [무엇을 검증했는지]
|
||||||
|
- 결과: PASS 또는 실패 내용
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Log
|
||||||
|
- `./gradlew tasks --all`
|
||||||
|
- 목적: 문서 변경 후 Gradle 명령 유효성 확인.
|
||||||
|
- 결과: 최초 실행은 sandbox가 `/Users/.../gradle-8.1.1-bin.zip.lck` 파일에 접근하지 못해 실패. 권한 승인 후 재실행하여 `BUILD SUCCESSFUL in 13s`.
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
||||||
|
- 목적: `Member.memberKind` 추가 후 Phase 1 계획에 명시된 기존 DM 테스트 컴파일 및 회귀 확인.
|
||||||
|
- 결과: `BUILD SUCCESSFUL in 2m 3s`.
|
||||||
|
- `./gradlew tasks --all`
|
||||||
|
- 목적: Phase 1 문서 및 운영 DB 반영용 SQL 추가 후 Gradle 명령 유효성 재확인.
|
||||||
|
- 결과: `BUILD SUCCESSFUL in 8s`.
|
||||||
|
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberServiceTest`
|
||||||
|
- 목적: Phase 2 RED 테스트가 신규 서비스/관계/repository/wiring 부재로 실패하는지 확인.
|
||||||
|
- 결과: `compileTestKotlin`에서 `ChatCharacterCreatorMemberService`, `creatorMember`, `findByCreatorMemberId`, `existsByCreatorMemberId`, `creatorMemberService` 생성자 파라미터 unresolved reference로 실패.
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberServiceTest`
|
||||||
|
- 목적: `ChatCharacter.creatorMember` 관계, repository 메서드, AI 캐릭터용 Member 생성/동기화, 캐릭터 생성/수정 wiring 검증.
|
||||||
|
- 결과: `BUILD SUCCESSFUL in 11s`.
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest`
|
||||||
|
- 목적: 관리자 캐릭터 컨트롤러 생성자 변경 후 기존 성별 매핑 회귀 테스트 컴파일 및 통과 확인.
|
||||||
|
- 결과: `BUILD SUCCESSFUL in 3s`.
|
||||||
|
- `./gradlew ktlintCheck`
|
||||||
|
- 목적: Phase 2 Kotlin production/test 변경의 ktlint 규칙 준수 확인.
|
||||||
|
- 결과: `BUILD SUCCESSFUL in 14s`.
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.member.MemberServiceTest --tests kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
||||||
|
- 목적: Phase 3 RED 검증. AI 캐릭터용 Member의 일반 로그인, 크리에이터 관리자 로그인, 유저-크리에이터 DM 방 생성이 기존 코드에서 차단되지 않음을 확인.
|
||||||
|
- 결과: `CreatorAdminMemberServiceTest`, `MemberServiceTest`, `UserCreatorChatServiceTest`의 신규 차단 테스트 3건 실패.
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.member.MemberServiceTest --tests kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
||||||
|
- 목적: Phase 3 GREEN 검증. 로그인/DM 차단 정책과 기존 DM 회귀 테스트 통과 확인.
|
||||||
|
- 결과: `BUILD SUCCESSFUL in 10s`.
|
||||||
|
- `./gradlew ktlintCheck`
|
||||||
|
- 목적: Phase 3/4 Kotlin production/test 및 문서 변경 전 정적 규칙 준수 확인.
|
||||||
|
- 결과: `BUILD SUCCESSFUL in 20s`.
|
||||||
|
- `./gradlew test`
|
||||||
|
- 목적: Phase 4 전체 회귀 테스트 확인.
|
||||||
|
- 결과: `BUILD SUCCESSFUL in 1m 14s`.
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberServiceTest --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberServiceIntegrationTest --tests kr.co.vividnext.sodalive.member.MemberServiceTest --tests kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
||||||
|
- 목적: 계획 진행 중 추가한 테스트의 mock 사용 적합성 재검토 후, 저장소/JPA 관계/로그인/DM 정책 검증을 Spring 컨텍스트 + H2 repository 기반 테스트로 전환했는지 확인.
|
||||||
|
- 결과: `BUILD SUCCESSFUL in 34s`.
|
||||||
|
- `./gradlew ktlintCheck`
|
||||||
|
- 목적: 계획 관련 테스트 리팩터링 후 ktlint 규칙 준수 확인.
|
||||||
|
- 결과: `BUILD SUCCESSFUL in 10s`.
|
||||||
|
- `./gradlew test`
|
||||||
|
- 목적: 계획 관련 테스트 리팩터링 후 전체 회귀 테스트 확인.
|
||||||
|
- 결과: `BUILD SUCCESSFUL in 1m 20s`.
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`
|
||||||
|
- 목적: 리뷰 보완. AI 캐릭터용 Member가 참여한 기존 DM 방에서 `sendTextMessage`도 `message.error.recipient_not_found`로 차단되고 메시지가 저장되지 않는지 확인.
|
||||||
|
- 결과: `BUILD SUCCESSFUL in 31s`.
|
||||||
|
- `./gradlew ktlintCheck`
|
||||||
|
- 목적: 리뷰 보완 후 ktlint 규칙 준수 확인.
|
||||||
|
- 결과: `BUILD SUCCESSFUL in 8s`.
|
||||||
|
- `./gradlew test`
|
||||||
|
- 목적: 리뷰 보완 후 전체 회귀 테스트 확인.
|
||||||
|
- 결과: `BUILD SUCCESSFUL in 1m 15s`.
|
||||||
|
- `rg -n "CREATE PROCEDURE|CURSOR|CALL backfill_chat_character_creator_member|LAST_INSERT_ID|Rollback|임시 식별자" docs/20260611_AI캐릭터_크리에이터기능_최소연결`
|
||||||
|
- 목적: 운영 DB 반영 SQL을 저장 프로시저 없는 단순 SQL로 변경했고, 임시 email 식별자 기준과 롤백 절차가 문서에 남았는지 확인.
|
||||||
|
- 결과: 저장 프로시저/커서/CALL/LAST_INSERT_ID 패턴은 미검출. `alter-existing-tables.sql`에 임시 식별자 정리와 Rollback 절차가 존재함을 확인.
|
||||||
|
- `./gradlew tasks --all`
|
||||||
|
- 목적: 문서 변경 후 Gradle 명령 유효성 확인.
|
||||||
|
- 결과: `BUILD SUCCESSFUL in 942ms`.
|
||||||
208
docs/20260611_AI캐릭터_크리에이터기능_최소연결/prd.md
Normal file
208
docs/20260611_AI캐릭터_크리에이터기능_최소연결/prd.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# PRD: AI 캐릭터 크리에이터 기능 최소 연결
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
`ChatCharacter`가 기존 크리에이터 기능을 최소 변경으로 사용할 수 있도록, 모든 `ChatCharacter`를 `Member(role = CREATOR)`와 1:1로 연결하고 실제 사람 Member와 AI 캐릭터용 Member를 `memberKind`로 구분한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problem
|
||||||
|
- 현재 `ChatCharacter`는 AI 대화 주체로만 동작하고, 라이브/콘텐츠/커뮤니티/채널 후원/정산/알림 등 크리에이터 기능의 주체가 될 수 없다.
|
||||||
|
- 기존 크리에이터 기능은 대부분 `Member(role = CREATOR)`와 `member.id` 기반 `creatorId`를 전제로 구현되어 있다.
|
||||||
|
- `ChatCharacter`를 독립 소유자로 직접 도입하면 콘텐츠, 라이브, 후원, 정산, 랭킹, 알림, 차단, 팔로우 등 넓은 범위의 소유자 모델 변경이 필요하다.
|
||||||
|
- 이번 변경은 기존 `Member` 기반 크리에이터 기능을 유지하면서, AI 캐릭터가 크리에이터 기능을 사용할 수 있는 최소 연결 구조가 필요하다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
- `Member`에 `MemberKind`를 추가해 실제 사람 Member와 AI 캐릭터용 Member를 구분한다.
|
||||||
|
- 모든 기존 `ChatCharacter`에 대응되는 `Member(role = CREATOR, memberKind = AI_CHARACTER)`를 생성하고 1:1로 연결할 수 있는 마이그레이션 SQL을 준비한다.
|
||||||
|
- 신규/기존 `ChatCharacter`는 크리에이터 기능 주체인 `creatorMember`를 가진다.
|
||||||
|
- `memberKind = AI_CHARACTER`인 Member는 로그인할 수 없도록 차단한다.
|
||||||
|
- `memberKind = AI_CHARACTER`인 Member는 유저-크리에이터 DM 생성 대상이 될 수 없도록 차단한다.
|
||||||
|
- `memberKind = AI_CHARACTER`인 Member는 로그인과 DM을 제외하고 `Member(role = CREATOR)`가 사용할 수 있는 기존 크리에이터 기능을 사용할 수 있어야 한다.
|
||||||
|
- AI 캐릭터용 Member의 표시 정보는 연결된 `ChatCharacter`의 이름, 프로필 이미지, 소개를 스냅샷으로 복사해 기존 Member 기반 화면과 쿼리를 재사용한다.
|
||||||
|
- 기존 사람 크리에이터의 콘텐츠/라이브/커뮤니티/채널 후원/정산/알림 동작은 유지한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
- 이번 범위에서 `ChatCharacter`를 `Member`와 동급의 별도 소유자 타입으로 만들지 않는다.
|
||||||
|
- 이번 범위에서 `creator_identity` 같은 공통 크리에이터 소유자 테이블을 도입하지 않는다.
|
||||||
|
- 이번 범위에서 공개 API의 기존 `creatorId = member.id` 의미를 변경하지 않는다.
|
||||||
|
- 이번 범위에서 크리에이터 검색 결과 카테고리 개편은 구현하지 않는다.
|
||||||
|
- 이번 범위에서 `Member:ChatCharacter = 1:N` 관계를 허용하지 않는다.
|
||||||
|
- 이번 범위에서 AI 캐릭터용 Member의 직접 로그인, 직접 크리에이터 관리자 접속, 직접 DM 기능은 허용하지 않는다.
|
||||||
|
- 이번 범위에서 AI 캐릭터용 콘텐츠/라이브/커뮤니티 대리 생성 API를 새로 설계하지 않는다.
|
||||||
|
- 이번 범위에서 기존 정산 산식, 정산 비율, 랭킹 점수 산식은 변경하지 않는다.
|
||||||
|
- 이번 범위에서 AI 캐릭터용 Member를 정산 관리자 화면에서 사람 크리에이터와 별도 목록 또는 별도 필터로 분리하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Target Users
|
||||||
|
- 일반 사용자: AI 캐릭터와 AI 대화를 하고, AI 캐릭터 채널에 후원하거나 AI 캐릭터 콘텐츠를 소비하는 회원
|
||||||
|
- 사람 크리에이터: 필요 시 자신의 `Member`에 연결된 `ChatCharacter`를 통해 AI 대화 기능을 제공하는 크리에이터
|
||||||
|
- 운영/정산 담당자: AI 캐릭터용 Member를 기존 크리에이터 정산 흐름에서 식별하고 처리해야 하는 담당자
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. User Stories
|
||||||
|
- 사용자는 모든 활성 `ChatCharacter`와 AI 대화를 시작하고 싶다.
|
||||||
|
- 사용자는 AI 캐릭터 채널에도 기존 크리에이터 채널처럼 채널 후원을 하고 싶다.
|
||||||
|
- 사용자는 AI 캐릭터가 업로드한 콘텐츠를 기존 콘텐츠와 같은 방식으로 보고 싶다.
|
||||||
|
- 시스템은 AI 캐릭터용 Member가 실제 사람 계정처럼 로그인하거나 DM 대상이 되는 것을 막고 싶다.
|
||||||
|
- 운영자는 사람 크리에이터와 AI 캐릭터용 크리에이터 Member를 데이터에서 명확히 구분하고 싶다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Core Features
|
||||||
|
|
||||||
|
### Feature A. `MemberKind` 도입
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- `Member`에 `memberKind` 필드를 추가한다.
|
||||||
|
- `MemberKind` 값은 최소 다음 2개를 가진다.
|
||||||
|
- `HUMAN`: 실제 사람 Member
|
||||||
|
- `AI_CHARACTER`: AI 캐릭터 기능을 위해 생성된 내부 크리에이터 Member
|
||||||
|
- `memberKind`는 `NOT NULL`이며 기본값은 `HUMAN`이다.
|
||||||
|
- 기존 모든 Member 데이터는 DDL 기본값에 의해 `memberKind = HUMAN`이 된다.
|
||||||
|
- 일반 회원가입, 관리자, 에이전트, 콘텐츠 관리자 등 실제 사람 계정은 `memberKind = HUMAN`을 사용한다.
|
||||||
|
- `memberKind = AI_CHARACTER`인 Member도 `role = CREATOR`를 가진다.
|
||||||
|
- 크리에이터 기능 가능 여부는 기존처럼 기본적으로 `role = CREATOR`를 기준으로 유지한다.
|
||||||
|
- 사람 크리에이터 전용 기능 가능 여부는 `role = CREATOR`와 `memberKind = HUMAN`을 함께 기준으로 판단한다.
|
||||||
|
- `memberKind = AI_CHARACTER`인 Member는 로그인과 DM을 제외한 팔로우, 채널 후원, 콘텐츠, 커뮤니티, 라이브, 정산, 알림 등 기존 CREATOR 기능의 대상이 될 수 있다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- `memberKind = HUMAN`만으로 사람 크리에이터 여부를 판단하면 안 되며, 반드시 `role = CREATOR` 조건을 함께 확인해야 한다.
|
||||||
|
- `memberKind = AI_CHARACTER`인 Member는 반드시 `role = CREATOR`여야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Feature B. `ChatCharacter`와 `Member` 1:1 연결
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- `ChatCharacter`가 관계의 주인이며 `creatorMember`를 가진다.
|
||||||
|
- 관계는 초기에는 1:1로 제한한다.
|
||||||
|
- DB에는 `chat_character.creator_member_id`를 추가한다.
|
||||||
|
- `chat_character.creator_member_id`는 `member.id`를 참조한다.
|
||||||
|
- `chat_character.creator_member_id`에는 unique 제약을 둔다.
|
||||||
|
- `ChatCharacter.creatorMember.role`은 반드시 `CREATOR`여야 한다.
|
||||||
|
- 기존 모든 `ChatCharacter`는 마이그레이션 후 `creatorMember`가 있어야 한다.
|
||||||
|
- 기존 `ChatCharacter` 중 실제 사람 크리에이터와 연결해야 하는 데이터는 이번 마이그레이션 대상에 없다고 본다.
|
||||||
|
- 기존 모든 `ChatCharacter`는 새 `Member(role = CREATOR, memberKind = AI_CHARACTER)`를 생성해 연결한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 이미 연결된 `ChatCharacter`에 중복 `creatorMember`가 배정되면 안 된다.
|
||||||
|
- 하나의 `Member`에 여러 `ChatCharacter`가 연결되면 안 된다.
|
||||||
|
- 비활성 `ChatCharacter`도 기존 데이터 정합성을 위해 `creatorMember` 연결 대상에 포함한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Feature C. 기존 `ChatCharacter`용 Member 생성 마이그레이션
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 운영 DB 반영용 MySQL 기준 DDL과 backfill SQL을 작성한다.
|
||||||
|
- 마이그레이션 SQL은 기존 `ChatCharacter`별로 AI 캐릭터용 `Member`를 생성할 수 있어야 한다.
|
||||||
|
- 생성되는 AI 캐릭터용 Member는 다음 정책을 따른다.
|
||||||
|
- `role = CREATOR`
|
||||||
|
- `memberKind = AI_CHARACTER`
|
||||||
|
- `email = null`
|
||||||
|
- `password = ""`
|
||||||
|
- `nickname`은 기본적으로 `ChatCharacter.name` 기준
|
||||||
|
- `profileImage`는 기본적으로 `ChatCharacter.imagePath` 기준
|
||||||
|
- `introduce`는 기본적으로 `ChatCharacter.description` 기준
|
||||||
|
- AI 캐릭터용 Member의 `nickname`, `profileImage`, `introduce`는 기존 콘텐츠/라이브/커뮤니티/후원/정산/알림/팔로우/AGENT 소속 화면에서 별도 `ChatCharacter` JOIN 없이 표시하기 위한 스냅샷이다.
|
||||||
|
- backfill 후 `chat_character.creator_member_id`가 없는 row가 0건인지 검증하는 SQL을 포함한다.
|
||||||
|
- 검증 완료 후 `chat_character.creator_member_id`를 `NOT NULL`로 전환할 수 있어야 한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- `ChatCharacter.name`이 중복되더라도 Member 생성이 가능해야 한다.
|
||||||
|
- AI 캐릭터용 Member의 로그인 차단은 `email/password` 값이 아니라 `memberKind = AI_CHARACTER` 정책으로 보장해야 한다.
|
||||||
|
- 기존 `ChatCharacter`의 사람 크리에이터 수동 매핑은 이번 범위에서 제공하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Feature D. AI 캐릭터 표시 정보 동기화
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- AI 캐릭터용 Member의 표시 정보는 연결된 `ChatCharacter` 값을 기준으로 유지한다.
|
||||||
|
- `ChatCharacter.name`은 `Member.nickname`에 동기화한다.
|
||||||
|
- `ChatCharacter.imagePath`는 `Member.profileImage`에 동기화한다.
|
||||||
|
- `ChatCharacter.description`은 `Member.introduce`에 동기화한다.
|
||||||
|
- `ChatCharacter` 생성 시 AI 캐릭터용 Member를 함께 생성하는 경우 같은 transaction 안에서 표시 정보를 복사한다.
|
||||||
|
- `ChatCharacter` 수정 시 연결된 AI 캐릭터용 Member의 표시 정보도 같은 transaction 안에서 갱신한다.
|
||||||
|
- `memberKind = AI_CHARACTER`인 Member의 표시 정보는 직접 수정 API가 아니라 `ChatCharacter` 생성/수정 흐름을 기준으로 관리한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 동기화 대상 `creatorMember`가 없으면 저장을 실패시켜 데이터 불일치를 막아야 한다.
|
||||||
|
- 연결된 `creatorMember.memberKind != AI_CHARACTER`인 경우, 사람 크리에이터의 프로필을 덮어쓰지 않도록 동기화 대상에서 제외하거나 별도 정책을 명확히 적용해야 한다.
|
||||||
|
- 기존 Member 기반 화면은 AI 캐릭터 표시 정보를 조회할 때 별도 `ChatCharacter` JOIN을 추가하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Feature E. AI 캐릭터용 Member 로그인 차단
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- `memberKind = AI_CHARACTER`인 Member는 모든 일반 로그인 흐름에서 인증 성공 상태가 되면 안 된다.
|
||||||
|
- 크리에이터 관리자 로그인 흐름에서도 `memberKind = AI_CHARACTER`인 Member는 로그인할 수 없어야 한다.
|
||||||
|
- 소셜 로그인 또는 토큰 재발급 흐름에서 AI 캐릭터용 Member가 세션/토큰을 얻을 수 있는 경로가 있으면 차단한다.
|
||||||
|
- 차단 시 기존 인증 실패 응답 패턴을 우선 재사용한다.
|
||||||
|
- AI 캐릭터용 Member는 로그인에 사용하지 않으므로 `email`은 `null`을 허용하고, `password`는 기존 소셜 회원 생성 패턴과 같이 빈 문자열을 사용할 수 있다.
|
||||||
|
- 로그인 차단은 `email/password` 값이 아니라 `memberKind = AI_CHARACTER` 정책으로 보장한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 기존 토큰을 이미 가진 AI 캐릭터용 Member가 있을 수 없도록 마이그레이션 시점과 배포 순서를 점검한다.
|
||||||
|
- 후속 범위에서 관리자/콘텐츠 관리자가 AI 캐릭터용 콘텐츠를 등록하더라도, AI 캐릭터용 Member 자체가 로그인하는 것은 허용하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Feature F. AI 캐릭터용 Member DM 차단
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 유저-크리에이터 DM 생성 대상이 `memberKind = AI_CHARACTER`이면 DM 방을 생성하지 않는다.
|
||||||
|
- 기존 사람 크리에이터는 `ChatCharacter` 연결 여부와 무관하게 DM이 가능해야 한다.
|
||||||
|
- DM 차단 기준은 `ChatCharacter` 연결 여부가 아니라 `Member.memberKind`이다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- `memberKind = HUMAN`인 사람 크리에이터가 `ChatCharacter`를 가진 경우에도 DM은 가능해야 한다.
|
||||||
|
- `memberKind = AI_CHARACTER`인 Member가 `role = CREATOR`이더라도 DM은 불가능해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Technical Constraints
|
||||||
|
- Kotlin, Spring Boot 2.7.14, Java 17, Gradle Wrapper 구조를 유지한다.
|
||||||
|
- 기존 공개 API의 `creatorId` 의미는 이번 범위에서 변경하지 않는다.
|
||||||
|
- 기존 콘텐츠/라이브/커뮤니티/후원/정산 테이블의 소유자 컬럼은 `Member` 기준을 유지한다.
|
||||||
|
- `ChatCharacter`와 `Member` 관계는 초기에는 `ChatCharacter` 단방향 `OneToOne`으로 구현한다.
|
||||||
|
- 운영 DB 반영용 DDL은 MySQL 기준으로 작성한다.
|
||||||
|
- DDL 컬럼에는 가능한 경우 `COMMENT`를 추가한다.
|
||||||
|
- `memberKind` 기반 정책 판단은 중복 분기를 줄이기 위해 정책 함수 또는 명확한 서비스 검증으로 모은다.
|
||||||
|
- 검색 결과 카테고리 개편은 이번 구현에서 제외하되, 향후 `memberKind`를 활용할 수 있도록 데이터 모델만 준비한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Data Migration Requirements
|
||||||
|
- Phase 1 DDL
|
||||||
|
- `member.member_kind`를 `NOT NULL DEFAULT 'HUMAN'`으로 추가
|
||||||
|
- `chat_character.creator_member_id` nullable 추가
|
||||||
|
- `chat_character.creator_member_id` FK 및 unique index 추가
|
||||||
|
- Phase 2 backfill
|
||||||
|
- 기존 모든 `ChatCharacter`별로 AI 캐릭터용 `Member(role = CREATOR, memberKind = AI_CHARACTER)`를 생성한다.
|
||||||
|
- 생성된 Member를 `chat_character.creator_member_id`에 연결한다.
|
||||||
|
- Phase 3 검증 및 제약 강화
|
||||||
|
- `member_kind = 'AI_CHARACTER' and role <> 'CREATOR'` row가 0건인지 확인한다.
|
||||||
|
- `chat_character.creator_member_id is null` row가 0건인지 확인한다.
|
||||||
|
- `chat_character.creator_member_id`를 `NOT NULL`로 변경한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Metrics
|
||||||
|
- 기존 `ChatCharacter` 중 `creator_member_id` 누락 0건
|
||||||
|
- `memberKind = AI_CHARACTER`이면서 `role != CREATOR`인 Member 0건
|
||||||
|
- `memberKind = AI_CHARACTER` Member 로그인 성공 0건
|
||||||
|
- `memberKind = AI_CHARACTER` Member 대상 DM 생성 성공 0건
|
||||||
|
- 기존 사람 크리에이터의 DM, 콘텐츠 등록, 채널 후원 흐름 회귀 실패 0건
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Open Questions
|
||||||
|
- AI 캐릭터용 콘텐츠/라이브/커뮤니티 등록 운영 흐름은 이번 범위에서 구현하지 않으므로, 후속 범위에서 `chatCharacterId` 기반 대리 생성 API 정책을 별도로 정해야 한다.
|
||||||
517
docs/20260612_크리에이터_채널_홈_API/plan-task.md
Normal file
517
docs/20260612_크리에이터_채널_홈_API/plan-task.md
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
# 크리에이터 채널 홈 API Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
||||||
|
|
||||||
|
**Goal:** 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/home`으로 크리에이터 채널 홈 탭 데이터를 한 번에 조회할 수 있게 한다.
|
||||||
|
|
||||||
|
**Architecture:** 신규 크리에이터 채널 홈 API는 메인 페이지 홈 API와 분리해 `kr.co.vividnext.sodalive.v2.creator.channel` 하위에 둔다. Controller는 인증/HTTP 계약만 담당하고, application service는 섹션 조립과 정책 적용을 담당하며, persistence adapter는 기존 `explorer`, `content`, `live`, `series`, `chat_character` 도메인 데이터를 조회 전용 record로 반환한다. 추천 페이지에서 쓰던 `RecommendedActivityType`은 `CreatorActivityType`으로 이름을 변경해 공용 패키지로 이동하고, 추천 페이지와 크리에이터 채널 홈이 함께 사용한다.
|
||||||
|
|
||||||
|
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring Data JPA, QueryDSL 또는 native SQL, JUnit 5, MockMvc, Gradle Wrapper
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 구현 전 확정 사항
|
||||||
|
|
||||||
|
- API endpoint: `GET /api/v2/creator-channels/{creatorId}/home`
|
||||||
|
- API 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
|
||||||
|
- 신규 기능 패키지: `kr.co.vividnext.sodalive.v2.creator.channel`
|
||||||
|
- 공용 활동 타입 enum: 기존 `RecommendedActivityType`을 `CreatorActivityType`으로 이름 변경하고 `kr.co.vividnext.sodalive.v2.common.domain` 하위로 이동한다.
|
||||||
|
- 스케줄 타입: `LIVE`, `AUDIO`만 사용한다. 오디오 콘텐츠가 `다시보기` 카테고리여도 `AUDIO`로 내려준다.
|
||||||
|
- 스케줄 정렬/개수: 현재 시각 이후 예약 중 오늘 날짜와 가장 근접한 3개, 예약 시각 오름차순, 같은 예약 시각이면 라이브 먼저 표시한다.
|
||||||
|
- 스케줄 성인 노출 정책: repository query에서 조회자의 성인 노출 정책을 먼저 반영하고, service 최종 조합에서도 내부 스케줄 후보의 `isAdult`로 한 번 더 보정한다. 공개 스케줄 응답에는 `isAdult`를 노출하지 않는다.
|
||||||
|
- 신규 오디오 콘텐츠와 오디오 목록은 중복 노출하지 않는다. `latestAudioContent`로 내려간 가장 최신 콘텐츠를 오디오 목록에서 제외한다.
|
||||||
|
- 채널 후원 홈 섹션은 기존 채널 후원 목록과 동일하게 이번 달 기준 최신순 8개를 내려준다.
|
||||||
|
- 오리지널 시리즈 여부는 `Series.isOriginal == true`로 판단한다.
|
||||||
|
- 화보와 상단 탭별 전체보기 API는 이번 범위에서 제외한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 파일 구조 계획
|
||||||
|
|
||||||
|
### 공용 enum 및 추천 페이지 영향 범위
|
||||||
|
- Move/Rename: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedActivityType.kt` → `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CreatorActivityType.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||||
|
|
||||||
|
### 신규 creator.channel API/application/domain/port
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeController.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHome.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicy.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
||||||
|
|
||||||
|
### 테스트
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CreatorActivityTypeTest.kt`
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicyTest.kt`
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
||||||
|
|
||||||
|
### 문서 산출물
|
||||||
|
- Modify: `docs/20260612_크리에이터_채널_홈_API/plan-task.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Response data class 초안
|
||||||
|
|
||||||
|
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt`에 아래 응답 DTO를 기준으로 작성한다. 필드명은 공개 API 계약이므로 구현 중 변경이 필요하면 먼저 이 문서를 갱신한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||||
|
|
||||||
|
data class CreatorChannelHomeResponse(
|
||||||
|
val creator: CreatorChannelCreatorResponse,
|
||||||
|
val currentLive: CreatorChannelLiveResponse?,
|
||||||
|
val latestAudioContent: CreatorChannelAudioContentResponse?,
|
||||||
|
val channelDonations: List<CreatorChannelDonationResponse>,
|
||||||
|
val notices: List<CreatorChannelCommunityPostResponse>,
|
||||||
|
val schedules: List<CreatorChannelScheduleResponse>,
|
||||||
|
val audioContents: List<CreatorChannelAudioContentResponse>,
|
||||||
|
val series: List<CreatorChannelSeriesResponse>,
|
||||||
|
val communities: List<CreatorChannelCommunityPostResponse>,
|
||||||
|
val fanTalk: CreatorChannelFanTalkSummaryResponse,
|
||||||
|
val introduce: String,
|
||||||
|
val activity: CreatorChannelActivityResponse,
|
||||||
|
val sns: CreatorChannelSnsResponse
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelCreatorResponse(
|
||||||
|
val creatorId: Long,
|
||||||
|
val nickname: String,
|
||||||
|
val profileImageUrl: String,
|
||||||
|
val followerCount: Int,
|
||||||
|
@JsonProperty("isAiChatAvailable")
|
||||||
|
val isAiChatAvailable: Boolean,
|
||||||
|
@JsonProperty("isDmAvailable")
|
||||||
|
val isDmAvailable: Boolean,
|
||||||
|
@JsonProperty("isFollow")
|
||||||
|
val isFollow: Boolean,
|
||||||
|
@JsonProperty("isNotify")
|
||||||
|
val isNotify: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelLiveResponse(
|
||||||
|
val liveId: Long,
|
||||||
|
val title: String,
|
||||||
|
val coverImageUrl: String?,
|
||||||
|
val beginDateTimeUtc: String,
|
||||||
|
val price: Int,
|
||||||
|
@JsonProperty("isAdult")
|
||||||
|
val isAdult: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelAudioContentResponse(
|
||||||
|
val audioContentId: Long,
|
||||||
|
val title: String,
|
||||||
|
val duration: String?,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val price: Int,
|
||||||
|
@JsonProperty("isAdult")
|
||||||
|
val isAdult: Boolean,
|
||||||
|
@JsonProperty("isPointAvailable")
|
||||||
|
val isPointAvailable: Boolean,
|
||||||
|
@JsonProperty("isFirstContent")
|
||||||
|
val isFirstContent: Boolean,
|
||||||
|
val seriesName: String?,
|
||||||
|
@JsonProperty("isOriginalSeries")
|
||||||
|
val isOriginalSeries: Boolean?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelDonationResponse(
|
||||||
|
val donationId: Long,
|
||||||
|
val memberId: Long,
|
||||||
|
val nickname: String,
|
||||||
|
val profileImageUrl: String,
|
||||||
|
val can: Int,
|
||||||
|
@JsonProperty("isSecret")
|
||||||
|
val isSecret: Boolean,
|
||||||
|
val message: String,
|
||||||
|
val createdAtUtc: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelScheduleResponse(
|
||||||
|
val scheduledAtUtc: String,
|
||||||
|
val title: String,
|
||||||
|
val type: CreatorActivityType,
|
||||||
|
val targetId: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelSeriesResponse(
|
||||||
|
val seriesId: Long,
|
||||||
|
val title: String,
|
||||||
|
val coverImageUrl: String,
|
||||||
|
val publishedDaysOfWeek: String,
|
||||||
|
@JsonProperty("isComplete")
|
||||||
|
val isComplete: Boolean,
|
||||||
|
val numberOfContent: Int,
|
||||||
|
@JsonProperty("isNew")
|
||||||
|
val isNew: Boolean,
|
||||||
|
@JsonProperty("isPopular")
|
||||||
|
val isPopular: Boolean,
|
||||||
|
@JsonProperty("isOriginal")
|
||||||
|
val isOriginal: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelCommunityPostResponse(
|
||||||
|
val postId: Long,
|
||||||
|
val creatorId: Long,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val creatorProfileUrl: String,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val audioUrl: String?,
|
||||||
|
val content: String,
|
||||||
|
val price: Int,
|
||||||
|
val dateUtc: String,
|
||||||
|
val existOrdered: Boolean,
|
||||||
|
val likeCount: Int,
|
||||||
|
val commentCount: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalkSummaryResponse(
|
||||||
|
val totalCount: Int,
|
||||||
|
val latestFanTalk: CreatorChannelFanTalkResponse?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalkResponse(
|
||||||
|
val fanTalkId: Long,
|
||||||
|
val memberId: Long,
|
||||||
|
val nickname: String,
|
||||||
|
val profileImageUrl: String,
|
||||||
|
val content: String,
|
||||||
|
val languageCode: String?,
|
||||||
|
val createdAtUtc: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelActivityResponse(
|
||||||
|
val debutDateUtc: String?,
|
||||||
|
val dDay: String,
|
||||||
|
val liveCount: Long,
|
||||||
|
val liveDurationHours: Long,
|
||||||
|
val liveContributorCount: Long,
|
||||||
|
val audioContentCount: Long,
|
||||||
|
val seriesCount: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelSnsResponse(
|
||||||
|
val instagramUrl: String,
|
||||||
|
val fancimmUrl: String,
|
||||||
|
val xUrl: String,
|
||||||
|
val youtubeUrl: String,
|
||||||
|
val kakaoOpenChatUrl: String
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
> 스케줄 성인 여부는 service 최종 보정에 필요한 내부 domain/record 필드로만 유지하고, 위 공개 응답 DTO에는 포함하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 1: 공용 활동 타입 정리
|
||||||
|
|
||||||
|
- [x] **Task 1.1: `RecommendedActivityType`을 공용 `CreatorActivityType`으로 이동**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CreatorActivityType.kt`
|
||||||
|
- Delete: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedActivityType.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CreatorActivityTypeTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||||
|
- RED: `CreatorActivityTypeTest`를 먼저 추가해 `LIVE`, `AUDIO`, `COMMUNITY`, `LIVE_REPLAY`의 `code`가 enum name과 같은지 검증한다. 추천 서비스/리포지토리 테스트 import를 `CreatorActivityType`으로 바꿔 기존 파일이 컴파일 실패하는 것을 확인한다.
|
||||||
|
- 실패 확인:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityTypeTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
|
||||||
|
- GREEN: enum을 공용 패키지로 이동하고 추천 페이지 코드의 import/type을 모두 `CreatorActivityType`으로 변경한다.
|
||||||
|
- 통과 확인:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityTypeTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
||||||
|
- REFACTOR: 더 이상 `RecommendedActivityType` 문자열이 남지 않도록 `rg -n "RecommendedActivityType" src/main/kotlin src/test/kotlin`로 확인한다.
|
||||||
|
- 기대 결과: 추천 페이지 기존 동작은 유지되고, 크리에이터 채널 홈 스케줄도 같은 enum을 사용할 수 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: 응답 모델과 순수 정책
|
||||||
|
|
||||||
|
- [x] **Task 2.1: 크리에이터 채널 홈 domain/response 모델 작성**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHome.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
|
||||||
|
- RED: service 테스트에서 `CreatorChannelHome`이 PRD 섹션 전체를 담는지 컴파일 기준으로 먼저 고정한다. 필드는 `creator`, `currentLive`, `latestAudioContent`, `channelDonations`, `notices`, `schedules`, `audioContents`, `series`, `communities`, `fanTalk`, `introduce`, `activity`, `sns`를 포함한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
|
||||||
|
- GREEN: domain 모델과 response DTO를 추가하고, response는 domain model을 받아 API 노출 필드만 변환하는 `from(home: CreatorChannelHome)` factory를 둔다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
|
||||||
|
- REFACTOR: API DTO는 JPA entity나 QueryDSL projection에 직접 의존하지 않도록 유지한다.
|
||||||
|
- 기대 결과: 이후 persistence/application/controller가 공유할 응답 표면이 고정된다.
|
||||||
|
|
||||||
|
- [x] **Task 2.2: 홈 섹션 정렬/필터 순수 정책 작성**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicy.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicyTest.kt`
|
||||||
|
- RED: 다음 정책 테스트를 작성한다.
|
||||||
|
- 스케줄은 예약 시각 오름차순 최대 3개만 남긴다.
|
||||||
|
- 스케줄은 현재 시각 이후 예약만 남긴다.
|
||||||
|
- 같은 예약 시각이면 `CreatorActivityType.LIVE`가 `AUDIO`보다 먼저 온다.
|
||||||
|
- 조회자의 성인 노출 정책이 false이면 성인 스케줄을 제외한다.
|
||||||
|
- 오디오 목록에서는 `latestAudioContentId`와 같은 콘텐츠를 제외한다.
|
||||||
|
- 오디오 콘텐츠의 첫 공개 콘텐츠 여부는 공개 시각 오름차순, 동일 시각이면 id 오름차순으로 판정한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest`
|
||||||
|
- GREEN: `limitSchedules(schedules, now, canViewAdultContent)`, `excludeLatestAudioContent`, `markFirstAudioContent` 같은 순수 함수를 구현한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest`
|
||||||
|
- REFACTOR: DB 정렬과 application 보정이 중복되더라도 최종 응답 전 정책 함수가 한 번 더 보장하도록 service에서 재사용할 수 있게 둔다.
|
||||||
|
- 기대 결과: 날짜/중복/첫 콘텐츠 정책이 DB fixture 없이 빠르게 검증된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: 조회 port와 persistence adapter
|
||||||
|
|
||||||
|
- [ ] **Task 3.1: 조회 port와 record 타입 정의**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
||||||
|
- RED: repository 테스트에서 port 메서드 이름을 먼저 사용해 컴파일 실패를 만든다. 최소 port 메서드는 `findCreator`, `existsBlockedBetween`, `findCurrentLive`, `findLatestAudioContent`, `findChannelDonations`, `findCommunityPosts`, `findSchedules`, `findAudioContents`, `findSeries`, `findFanTalkSummary`, `findActivity`, `findSns`로 둔다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
||||||
|
- GREEN: port record와 `DefaultCreatorChannelHomeQueryRepository` 골격을 추가한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
||||||
|
- REFACTOR: record 타입은 JPA entity를 노출하지 않는 data class로 둔다.
|
||||||
|
- 기대 결과: application service가 의존할 조회 인터페이스가 고정된다.
|
||||||
|
|
||||||
|
- [ ] **Task 3.2: 크리에이터 기본 정보/차단/팔로우/AI 채팅/DM 조회 구현**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
||||||
|
- RED: 다음 repository 통합 테스트를 작성한다.
|
||||||
|
- 활성 팔로워 수만 `followerCount`에 포함한다.
|
||||||
|
- `ChatCharacter.creatorMember.id == creatorId`이고 활성 캐릭터가 있으면 `isAiChatAvailable=true`다.
|
||||||
|
- `Member.memberKind == AI_CHARACTER`이면 `isDmAvailable=false`다.
|
||||||
|
- 인증 회원의 `CreatorFollowing.isFollow`, `isNotify`가 응답에 반영된다.
|
||||||
|
- 양방향 차단 관계가 있으면 `existsBlockedBetween(viewerId, creatorId)=true`다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
||||||
|
- GREEN: `Member`, `CreatorFollowing`, `BlockMember`, `ChatCharacter` 기반 조회를 구현한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
||||||
|
- REFACTOR: 프로필 이미지 URL 조합은 application/DTO에서 cloudFrontHost로 처리할지 repository에서 처리할지 한 곳으로 고정한다. 기존 v2 홈 DTO 관례처럼 path record와 URL 변환 함수를 분리하는 방식을 우선한다.
|
||||||
|
- 기대 결과: 기본 정보와 접근 차단 판단이 기존 정책과 맞는다.
|
||||||
|
|
||||||
|
- [ ] **Task 3.3: 현재 라이브와 예약 스케줄 조회 구현**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
||||||
|
- RED: 다음 repository 통합 테스트를 작성한다.
|
||||||
|
- 현재 라이브는 `channelName`이 있고 활성 상태이며 크리에이터가 진행 중인 라이브만 반환한다.
|
||||||
|
- 예약 라이브는 `beginDateTime > now`, 활성 상태인 row만 스케줄 후보로 반환한다.
|
||||||
|
- 예약 오디오는 `releaseDate > now`인 콘텐츠만 스케줄 후보로 반환한다.
|
||||||
|
- 다시듣기 테마 예약 오디오도 스케줄 타입은 `AUDIO`다.
|
||||||
|
- 같은 예약 시각이면 라이브가 오디오보다 먼저 온다.
|
||||||
|
- 성인 라이브/오디오는 조회자의 성인 노출 정책이 false이면 제외된다.
|
||||||
|
- service 최종 보정을 위해 스케줄 후보 record에는 `isAdult`가 포함된다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
||||||
|
- GREEN: `LiveRoom`, `AudioContent`, `AudioContentTheme` 조회를 구현하고 `CreatorActivityType.LIVE`/`AUDIO`와 `isAdult`를 record에 담는다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
||||||
|
- REFACTOR: 최종 3개 제한은 repository query와 `CreatorChannelHomeQueryPolicy.limitSchedules` 양쪽 중복 방어를 허용하되, service에서 최종 보정한다.
|
||||||
|
- 기대 결과: 스케줄 섹션이 PRD의 타입/정렬/개수 정책을 만족한다.
|
||||||
|
|
||||||
|
- [ ] **Task 3.4: 최신 오디오와 오디오 목록 조회 구현**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
||||||
|
- RED: 다음 repository 통합 테스트를 작성한다.
|
||||||
|
- `latestAudioContent`는 예약 공개 전 콘텐츠를 제외하고 공개 시각 최신순 1개를 반환한다.
|
||||||
|
- 오디오 목록은 `latestAudioContent`를 제외하고 최대 9개를 최신순으로 반환한다.
|
||||||
|
- `isPointAvailable`, duration, cover image, price가 record에 포함된다.
|
||||||
|
- 공개 순서상 첫 콘텐츠만 `isFirstContent=true`다.
|
||||||
|
- 시리즈 콘텐츠이면 시리즈 이름과 `Series.isOriginal`이 포함된다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
||||||
|
- GREEN: `AudioContent`, `SeriesContent`, `Series` 기반 조회를 구현한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
||||||
|
- REFACTOR: 예약 공개 여부 조건은 `releaseDate == null || releaseDate <= now`처럼 기존 콘텐츠 목록 정책과 어긋나지 않도록 작성한다.
|
||||||
|
- 기대 결과: 신규 오디오 영역과 오디오 목록이 중복 없이 구성된다.
|
||||||
|
|
||||||
|
- [ ] **Task 3.5: 채널 후원, 공지, 커뮤니티, 팬 Talk 조회 구현**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
||||||
|
- RED: 다음 repository 통합 테스트를 작성한다.
|
||||||
|
- 채널 후원은 KST 기준 이번 달 범위의 최신순 8개만 반환한다.
|
||||||
|
- 공지는 `CreatorCommunity.isFixed == true`, 최대 3개, 고정 시각 최신순으로 반환한다.
|
||||||
|
- 커뮤니티는 `isFixed == false`, 최대 3개, 작성 시각 최신순으로 반환한다.
|
||||||
|
- 공지와 커뮤니티의 홈 응답 게시글 요약 필드는 기존 커뮤니티 전체보기 응답과 같은 의미로 계산한다.
|
||||||
|
- 팬 Talk는 `CreatorCheers.parent == null`, `isActive == true`인 최신 1개와 전체 개수를 반환한다.
|
||||||
|
- 차단 관계가 있는 팬 Talk 작성자는 기존 팬 Talk 목록 정책과 동일하게 제외한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
||||||
|
- GREEN: `ChannelDonationMessage`, `CreatorCommunity`, `CreatorCheers` 기반 조회를 구현한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
||||||
|
- REFACTOR: 커뮤니티 유료 이미지/오디오 구매 여부(`existOrdered`)는 인증 회원 기준으로 기존 community query 의미와 동일하게 계산한다.
|
||||||
|
- 기대 결과: 홈 후원/공지/커뮤니티/팬 Talk 섹션이 기존 전체보기 의미와 맞게 내려간다.
|
||||||
|
|
||||||
|
- [ ] **Task 3.6: 시리즈, 소개, 활동, SNS 조회 구현**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
||||||
|
- RED: 다음 repository 통합 테스트를 작성한다.
|
||||||
|
- 시리즈는 최대 8개, 시리즈에 속한 공개 콘텐츠 최신 공개 시각 내림차순으로 반환한다.
|
||||||
|
- 시리즈 응답 record에는 id, 제목, 커버 이미지, 연재 요일, 완결 여부, 콘텐츠 개수, 신규/인기 표시 정보가 포함된다.
|
||||||
|
- 소개는 `Member.introduce`를 반환한다.
|
||||||
|
- 데뷔일은 첫 라이브 시작 시각과 첫 공개 오디오 공개 시각 중 빠른 값이다.
|
||||||
|
- 업로드 오디오 콘텐츠 개수는 예약 업로드를 제외한다.
|
||||||
|
- 라이브 진행 횟수/누적 시간/누적 참여자는 기존 `ExplorerQueryRepository` 의미와 맞는다.
|
||||||
|
- SNS는 `instagramUrl`, `fancimmUrl`, `xUrl`, `youtubeUrl`, `websiteUrl`을 기존 상세 API 의미로 반환한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
||||||
|
- GREEN: `Series`, `SeriesContent`, `Member`, `LiveRoom`, `LiveRoomVisit`, `AudioContent` 기반 조회를 구현한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
||||||
|
- REFACTOR: 기존 `ExplorerService.getCreatorDetail`과 의미가 같은 계산은 테스트명에 근거를 남기고, 구버전 service를 직접 호출하지 않는다.
|
||||||
|
- 기대 결과: 활동/SNS/시리즈가 구버전 상세 의미와 신규 홈 요구를 함께 만족한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: application service 조립
|
||||||
|
|
||||||
|
- [ ] **Task 4.1: `CreatorChannelHomeQueryService` 정상 응답 조립 구현**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicy.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
|
||||||
|
- RED: fake port를 사용해 모든 섹션 record를 넣고, service가 `CreatorChannelHome`으로 전체 섹션을 조립하는 테스트를 작성한다. `latestAudioContent`와 오디오 목록 중복 제거, 스케줄 최대 3개 제한, 같은 시각 라이브 우선 정렬, 성인 스케줄 최종 제외도 service 테스트에서 검증한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
|
||||||
|
- GREEN: service에서 creator 검증, 성인 노출 정책 입력, port 호출, policy 적용, URL 변환에 필요한 host 전달을 구현한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
|
||||||
|
- REFACTOR: service는 트랜잭션 경계 `@Transactional(readOnly = true)`를 갖고, persistence adapter의 세부 query에 의존하지 않도록 port만 사용한다.
|
||||||
|
- 기대 결과: controller가 단일 service 호출만으로 홈 응답을 받을 수 있다.
|
||||||
|
|
||||||
|
- [ ] **Task 4.2: 예외/접근 정책 구현**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
|
||||||
|
- RED: 다음 service 테스트를 작성한다.
|
||||||
|
- creatorId에 해당하는 회원이 없으면 `SodaException(messageKey = "member.validation.user_not_found")`를 던진다.
|
||||||
|
- 대상 회원 role이 `CREATOR`가 아니면 `member.validation.creator_not_found`를 던진다.
|
||||||
|
- 조회자와 크리에이터 사이에 차단 관계가 있으면 구버전 채널 접근 정책과 동일한 접근 차단 예외를 던진다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
|
||||||
|
- GREEN: port의 creator/blocked 조회 결과에 따라 `SodaException`을 던진다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
|
||||||
|
- REFACTOR: 차단 예외 메시지 조합에 `SodaMessageSource`가 필요하면 기존 `ExplorerService.getCreatorDetail` 패턴을 따른다.
|
||||||
|
- 기대 결과: 신규 API 접근 정책이 구버전 채널 정책과 맞는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: web API와 응답 계약
|
||||||
|
|
||||||
|
- [ ] **Task 5.1: Controller 인증 정책과 endpoint 구현**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeController.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
||||||
|
- RED: MockMvc 테스트를 작성한다.
|
||||||
|
- `GET /api/v2/creator-channels/{creatorId}/home` 비회원 요청은 실패한다.
|
||||||
|
- 인증 회원 요청은 service를 호출해 `ApiResponse.ok(...)` 형식으로 성공 응답을 반환한다.
|
||||||
|
- path variable `creatorId`가 service에 전달된다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
||||||
|
- GREEN: `@RestController`, `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/home")` controller를 구현하고 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member")` 패턴을 사용한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
||||||
|
- REFACTOR: 인증 null 가드는 기존 v2 controller와 동일하게 `SodaException(messageKey = "common.error.bad_credentials")`를 사용한다.
|
||||||
|
- 기대 결과: 공개 API endpoint와 인증 정책이 고정된다.
|
||||||
|
|
||||||
|
- [ ] **Task 5.2: 응답 JSON 필드 계약 고정**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
||||||
|
- RED: MockMvc `jsonPath`로 다음 최상위 필드를 검증한다.
|
||||||
|
- `creator`
|
||||||
|
- `currentLive`
|
||||||
|
- `latestAudioContent`
|
||||||
|
- `channelDonations`
|
||||||
|
- `notices`
|
||||||
|
- `schedules`
|
||||||
|
- `audioContents`
|
||||||
|
- `series`
|
||||||
|
- `communities`
|
||||||
|
- `fanTalk`
|
||||||
|
- `introduce`
|
||||||
|
- `activity`
|
||||||
|
- `sns`
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
||||||
|
- GREEN: response DTO factory에서 domain model을 JSON 계약에 맞게 변환한다. Boolean 필드는 `isAiChatAvailable`, `isDmAvailable`, `isPointAvailable`, `isFirstContent`, `isOriginalSeries`처럼 앱 계약이 읽기 쉬운 이름을 사용한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
||||||
|
- REFACTOR: nullable 섹션은 단건이면 `null`, 목록이면 빈 배열로 일관되게 내려준다.
|
||||||
|
- 기대 결과: 클라이언트가 사용할 JSON 스키마가 테스트로 고정된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 6: 통합 회귀와 문서 갱신
|
||||||
|
|
||||||
|
- [ ] **Task 6.1: 크리에이터 채널 홈 통합 시나리오 검증**
|
||||||
|
- Files:
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
||||||
|
- RED: 현실적인 fixture로 한 크리에이터에 라이브, 예약 라이브, 예약 오디오, 최신 오디오, 오디오 목록, 시리즈, 공지, 커뮤니티, 후원, 팬 Talk, SNS, 활동 데이터를 넣고 홈 응답 핵심 필드가 모두 내려오는 통합 테스트를 작성한다.
|
||||||
|
- 실패 확인:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
||||||
|
- GREEN: 누락된 mapping이나 query 조건을 최소 수정한다.
|
||||||
|
- 통과 확인:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
||||||
|
- REFACTOR: 테스트 fixture helper가 과도하게 길어지면 같은 테스트 파일 내부 private helper로만 분리하고 운영 코드에는 테스트 편의를 위한 API를 추가하지 않는다.
|
||||||
|
- 기대 결과: PRD의 홈 전체 섹션이 한 요청에서 조립되는지 확인된다.
|
||||||
|
|
||||||
|
- [ ] **Task 6.2: 추천 페이지 enum rename 회귀 확인**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||||
|
- RED: 해당 없음. `TDD 예외 사유`: Task 1.1에서 이미 RED/GREEN으로 enum rename을 처리했고, 이 task는 영향 범위 회귀 실행이다.
|
||||||
|
- 대체 검증 방법:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
||||||
|
- GREEN: 실패가 있으면 import/type mismatch 또는 enum value mapping만 최소 수정한다.
|
||||||
|
- REFACTOR: `rg -n "RecommendedActivityType" src/main/kotlin src/test/kotlin` 결과가 없어야 한다.
|
||||||
|
- 기대 결과: 추천 페이지 최근 활동 타입 분류가 기존과 동일하게 유지된다.
|
||||||
|
|
||||||
|
- [ ] **Task 6.3: 전체 검증 및 계획 문서 검증 기록 누적**
|
||||||
|
- Files:
|
||||||
|
- Modify: `docs/20260612_크리에이터_채널_홈_API/plan-task.md`
|
||||||
|
- RED: 테스트 작성 예외. `TDD 예외 사유`: 검증 기록 문서화 task다.
|
||||||
|
- 대체 검증 방법:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityTypeTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
||||||
|
- `./gradlew ktlintCheck`
|
||||||
|
- GREEN: 모든 명령 결과를 아래 `검증 기록`에 누적한다.
|
||||||
|
- REFACTOR: 실패한 검증이 있으면 해당 phase/task로 돌아가 plan-task 체크박스를 완료 처리하지 않는다.
|
||||||
|
- 기대 결과: 구현 완료 시 어떤 검증으로 완료 판단했는지 문서에 남는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 중 주의사항
|
||||||
|
|
||||||
|
- 기존 `ExplorerService.getCreatorDetail`의 활동/SNS 의미를 유지하되, 신규 API에서 구버전 service를 직접 호출하지 않는다.
|
||||||
|
- 메인 페이지 홈 패키지(`kr.co.vividnext.sodalive.v2.api.home`)와 크리에이터 채널 홈 패키지를 섞지 않는다.
|
||||||
|
- 공개 시간은 UTC ISO-8601 문자열로 내려주고, 앱 표시 포맷은 서버에서 조합하지 않는다.
|
||||||
|
- 목록 섹션은 데이터가 없으면 빈 배열, 단건 섹션은 없으면 `null`로 내려준다.
|
||||||
|
- 신규 API 공개 스키마 변경은 이 문서의 task 범위 안에서만 수행한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
|
||||||
|
- 2026-06-12: plan-task 문서 생성 전 `docs/agent-guides/코드스타일.md`, `docs/agent-guides/테스트스타일.md`, `docs/agent-guides/실행명령어.md`, 기존 `docs/20260608_크리에이터_랭킹/plan-task.md`, `docs/20260612_크리에이터_채널_홈_API/prd.md`를 확인했다.
|
||||||
|
- 2026-06-12: Phase 1 RED 확인 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityTypeTest` 실행 시 `Unresolved reference: CreatorActivityType` 컴파일 오류를 확인했다.
|
||||||
|
- 2026-06-12: Phase 1 GREEN/회귀 확인 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityTypeTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` 통과.
|
||||||
|
- 2026-06-12: Phase 1 정리 확인 - `rg -n "RecommendedActivityType" src/main/kotlin src/test/kotlin` 결과 없음, `./gradlew ktlintCheck` 통과.
|
||||||
|
- 2026-06-12: Phase 2 RED 확인 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest` 실행 시 `Unresolved reference: CreatorChannelHome`, `Unresolved reference: CreatorChannelHomeResponse`, `Unresolved reference: CreatorChannelHomeQueryPolicy` 컴파일 오류를 확인했다.
|
||||||
|
- 2026-06-12: Phase 2 GREEN 확인 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest` 통과.
|
||||||
|
- 2026-06-12: Phase 2 정리 확인 - `./gradlew ktlintCheck` 통과.
|
||||||
|
- 2026-06-12: Phase 2 리뷰 보정 RED 확인 - 오디오 콘텐츠 `isAdult`와 스케줄 현재시각 필터 테스트 추가 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest` 실행 시 `Unresolved reference: isAdult`, `Too many arguments for limitSchedules` 컴파일 오류를 확인했다.
|
||||||
|
- 2026-06-12: Phase 2 리뷰 보정 GREEN 확인 - `CreatorChannelAudioContent`/`CreatorChannelAudioContentResponse`에 `isAdult`를 추가하고 `CreatorChannelHomeQueryPolicy.limitSchedules(schedules, now)`가 `scheduledAt > now`만 남기도록 수정한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest` 통과.
|
||||||
|
- 2026-06-12: 스케줄 성인 노출 정책 보강 - PRD와 plan-task에 repository query 1차 필터 + service 최종 보정 방식을 명시하고, 내부 `CreatorChannelSchedule.isAdult`와 `CreatorChannelHomeQueryPolicy.limitSchedules(schedules, now, canViewAdultContent)`를 반영했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest`, `./gradlew ktlintCheck` 통과.
|
||||||
314
docs/20260612_크리에이터_채널_홈_API/prd.md
Normal file
314
docs/20260612_크리에이터_채널_홈_API/prd.md
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
# PRD: 크리에이터 채널 홈 API
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
크리에이터 채널 신규 페이지의 홈 탭에 필요한 크리에이터 정보, 라이브/오디오/후원/공지/스케줄/시리즈/커뮤니티/팬 Talk/소개/활동/SNS 데이터를 한 번에 조회하는 v2 API를 제공한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problem
|
||||||
|
- 신규 크리에이터 채널 화면은 기존 `ExplorerService.getCreatorProfile`, `ExplorerService.getCreatorDetail`, 커뮤니티, 후원, 오디오, 라이브, 시리즈 도메인 데이터가 섞여 있어 홈 탭용 API 계약을 먼저 확정해야 한다.
|
||||||
|
- Figma 홈 화면에는 여러 섹션이 한 스크롤에 배치되어 있으므로 클라이언트가 섹션별 기존 API를 여러 번 호출하면 초기 진입 속도와 계약 관리가 불리하다.
|
||||||
|
- 공지와 커뮤니티는 같은 커뮤니티 게시글 데이터를 사용하지만 `isFixed` 여부에 따라 홈에서 다른 섹션으로 분리되어야 한다.
|
||||||
|
- 예약 라이브와 예약 업로드 오디오 콘텐츠를 하나의 스케줄 섹션으로 합쳐 노출해야 하므로 타입과 이동 대상 id를 명확히 내려줘야 한다.
|
||||||
|
- 활동 지수와 SNS는 기존 구버전 크리에이터 채널 상세(`ExplorerService.getCreatorDetail`)와 의미가 어긋나지 않아야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
- 크리에이터 채널 홈 탭 첫 화면을 구성하는 단일 조회 API를 제공한다.
|
||||||
|
- API는 인증 회원만 조회할 수 있도록 제공한다.
|
||||||
|
- API는 인증 회원 기준 성인 콘텐츠 노출 정책, 차단 관계, 구매 여부 등 기존 도메인 정책을 가능한 한 유지한다.
|
||||||
|
- 응답 시간은 앱 표시 포맷에 의존하지 않도록 UTC 기준 문자열로 내려준다.
|
||||||
|
- 공지, 커뮤니티 게시글은 홈 노출에 필요한 게시글 요약 필드를 제공한다.
|
||||||
|
- 채널 후원은 최신순 8개를 내려준다.
|
||||||
|
- 오디오 콘텐츠는 최근 업로드 기준 최대 9개를 내려주고, 예약 업로드 전 콘텐츠는 일반 오디오 목록에는 포함하지 않는다.
|
||||||
|
- 시리즈는 최대 8개를 내려주고, 해당 시리즈에 속한 콘텐츠의 최신 공개일 기준으로 정렬한다.
|
||||||
|
- 팬 Talk는 가장 최근에 남긴 팬 Talk 1개와 전체 팬 Talk 개수를 함께 내려준다.
|
||||||
|
- 활동 지수와 SNS는 `ExplorerService.getCreatorDetail`의 계산/필드 의미를 기준으로 확장한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
- 이번 범위는 크리에이터 채널 `홈` API만 포함한다.
|
||||||
|
- Figma 상단 탭의 `라이브`, `오디오`, `시리즈`, `커뮤니티`, `팬Talk`, `후원` 탭별 전체보기/페이징 API는 다음 범위에서 추가한다.
|
||||||
|
- `화보` 섹션과 화보 활동 지표는 이번 범위에서 제외한다.
|
||||||
|
- 기존 구버전 크리에이터 채널 API의 공개 스키마는 변경하지 않는다.
|
||||||
|
- 커뮤니티 글 작성, 팬 Talk 작성, 채널 후원 등록, 팔로우/알림/DM/AI 채팅 실행 API는 포함하지 않는다.
|
||||||
|
- 관리자 화면 신규 개발은 포함하지 않는다.
|
||||||
|
- 앱 표시용 다국어 문구, 날짜 포맷, 숫자 단위 축약 표시는 서버에서 처리하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Target Users
|
||||||
|
- 회원: 크리에이터 채널 홈에서 최신 활동과 대표 콘텐츠를 탐색하는 사용자
|
||||||
|
- 앱 클라이언트: 홈 탭 진입 시 한 API 응답으로 섹션을 구성하려는 클라이언트
|
||||||
|
- 크리에이터: 자신의 채널 홈에 공지, 콘텐츠, 후원, 활동 정보가 적절히 노출되기를 원하는 사용자
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. User Stories
|
||||||
|
- 사용자는 크리에이터 채널에 진입하면 닉네임, 팔로워 수, AI 채팅 가능 여부, DM 가능 여부를 바로 확인하고 싶다.
|
||||||
|
- 사용자는 크리에이터가 현재 진행 중인 라이브가 있으면 홈에서 바로 보고 싶다.
|
||||||
|
- 사용자는 크리에이터가 최근 올린 오디오 콘텐츠와 시리즈를 홈에서 빠르게 탐색하고 싶다.
|
||||||
|
- 사용자는 고정된 커뮤니티 글은 공지로, 일반 커뮤니티 글은 커뮤니티 섹션으로 구분해 보고 싶다.
|
||||||
|
- 사용자는 예정된 라이브와 예약 업로드 오디오 콘텐츠를 시간순으로 보고 싶다.
|
||||||
|
- 사용자는 최근 채널 후원, 팬 Talk, 소개, 활동 지표, SNS 링크를 한 화면에서 확인하고 싶다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Core Features
|
||||||
|
|
||||||
|
### Feature A. 크리에이터 채널 홈 조회 API
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 신규 API는 메인 페이지 홈 API와 분리된 크리에이터 채널 전용 v2 API로 작성한다.
|
||||||
|
- 신규 코드 위치는 `kr.co.vividnext.sodalive.v2.creator.channel` 하위 경계를 기본 후보로 한다.
|
||||||
|
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/home`을 기본안으로 한다.
|
||||||
|
- 인증 회원만 조회할 수 있어야 한다.
|
||||||
|
- 비회원이 조회하면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
|
||||||
|
- 조회 대상 회원이 존재하지 않으면 기존 정책과 동일하게 `member.validation.user_not_found` 계열 오류를 반환한다.
|
||||||
|
- 조회 대상 회원이 크리에이터가 아니면 기존 정책과 동일하게 `member.validation.creator_not_found` 계열 오류를 반환한다.
|
||||||
|
- 조회자와 크리에이터 사이에 차단 관계가 있으면 구버전 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
|
||||||
|
- 섹션별 데이터가 부족하면 가능한 만큼만 내려주고 전체 API는 성공 처리한다.
|
||||||
|
- 섹션 데이터가 없으면 빈 배열 또는 `null`로 내려주되, 응답 스키마는 유지한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다.
|
||||||
|
|
||||||
|
### Feature B. 크리에이터 기본 정보
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 크리에이터 기본 정보에는 다음 값을 포함한다.
|
||||||
|
- `creatorId`
|
||||||
|
- `nickname`
|
||||||
|
- `profileImageUrl`
|
||||||
|
- `followerCount`
|
||||||
|
- `isAiChatAvailable`
|
||||||
|
- `isDmAvailable`
|
||||||
|
- `isFollow`
|
||||||
|
- `isNotify`
|
||||||
|
- `followerCount`는 활성 팔로우 수 기준으로 계산한다.
|
||||||
|
- `isAiChatAvailable`은 해당 `Member`와 연결된 활성 `ChatCharacter`가 있는지로 판단한다. 구현 후보는 `ChatCharacterRepository.existsByCreatorMemberId(creatorId)`를 기준으로 한다.
|
||||||
|
- `isDmAvailable`은 `member.memberKind != MemberKind.AI_CHARACTER`이면 `true`, `AI_CHARACTER`이면 `false`로 판단한다.
|
||||||
|
- `isFollow`, `isNotify`는 인증 회원의 기존 `CreatorFollowing` 상태를 기준으로 내려준다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 프로필 이미지가 없으면 기존 기본 프로필 이미지 URL을 내려준다.
|
||||||
|
- AI 캐릭터 크리에이터는 AI 채팅 가능 여부가 `true`일 수 있지만 DM 가능 여부는 `false`일 수 있다.
|
||||||
|
|
||||||
|
### Feature C. 현재 진행 중인 라이브
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 크리에이터가 현재 진행 중인 라이브를 내려준다.
|
||||||
|
- 현재 진행 중인 라이브는 기존 라이브 도메인의 `LiveRoomStatus.NOW` 의미와 동일하게 판단한다.
|
||||||
|
- 응답에는 라이브 ID, 제목, 커버 이미지, 시작 시각 UTC, 유료 여부 또는 가격, 성인 여부, 예약 여부가 아닌 현재 라이브 여부를 포함한다.
|
||||||
|
- 조회자의 성인 콘텐츠 노출 정책과 차단 정책을 반영한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 현재 진행 중인 라이브가 없으면 `currentLive`는 `null`로 내려준다.
|
||||||
|
|
||||||
|
### Feature D. 신규 오디오 콘텐츠
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- Figma 홈 상단에 노출되는 신규 오디오 콘텐츠 영역에 사용할 최신 공개 오디오 콘텐츠를 내려준다.
|
||||||
|
- 예약 공개 전 콘텐츠는 신규 오디오 콘텐츠로 노출하지 않는다.
|
||||||
|
- 응답 필드는 홈 오디오 콘텐츠 카드와 동일하게 콘텐츠 ID, 제목, duration, 커버 이미지, 가격, 포인트 사용 가능 여부, 성인 여부를 포함한다.
|
||||||
|
- 정렬은 공개 시각 최신순이다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 공개된 오디오 콘텐츠가 없으면 `latestAudioContent`는 `null`로 내려준다.
|
||||||
|
|
||||||
|
### Feature E. 채널 후원
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 채널 후원은 최신순 최대 8개를 내려준다.
|
||||||
|
- 기존 `ChannelDonationService.getChannelDonationList` 응답 필드 의미를 유지한다.
|
||||||
|
- 조회 범위는 기존 채널 후원 목록과 동일하게 이번 달 기준으로 한다.
|
||||||
|
- 응답에는 후원 ID, 회원 ID, 닉네임, 프로필 이미지, 후원 can, 비밀 후원 여부, 메시지, 생성 시각 UTC를 포함한다.
|
||||||
|
- 비밀 후원 표시 정책은 기존 채널 후원 목록 정책을 따른다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 후원이 없으면 빈 배열을 내려준다.
|
||||||
|
|
||||||
|
### Feature F. 공지
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 커뮤니티 게시글 중 `isFixed == true`인 글을 홈의 공지 섹션으로 처리한다.
|
||||||
|
- 응답에는 게시글 ID, 크리에이터 ID, 크리에이터 닉네임, 크리에이터 프로필 이미지, 이미지 URL, 오디오 URL, 본문, 가격, 작성 시각 UTC, 구매 여부, 좋아요 수, 댓글 수를 포함한다.
|
||||||
|
- 홈 응답에 포함하는 게시글 요약 필드는 기존 크리에이터 커뮤니티 전체보기 게시글 리스트와 같은 의미를 유지한다.
|
||||||
|
- 정렬은 고정 시각 최신순을 우선하고, 고정 시각이 없으면 작성 시각 최신순으로 한다.
|
||||||
|
- 공지 최대 노출 개수는 기존 고정 글 제한 정책에 맞춰 최대 3개로 한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 고정 게시글이 없으면 빈 배열을 내려준다.
|
||||||
|
- 성인 커뮤니티 글은 조회자의 성인 콘텐츠 노출 정책을 따른다.
|
||||||
|
|
||||||
|
### Feature G. 스케줄
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 예약 라이브와 예약 업로드 오디오 콘텐츠를 하나의 스케줄 배열로 내려준다.
|
||||||
|
- 스케줄은 오늘 날짜와 가장 근접한 예약 항목 최대 3개를 내려준다.
|
||||||
|
- 예약 라이브는 `LiveRoomStatus.RESERVATION` 의미와 동일하게, `LiveRoom.beginDateTime > now`이고 활성 상태인 라이브를 대상으로 한다.
|
||||||
|
- 예약 업로드 오디오 콘텐츠는 `AudioContent.releaseDate > now`인 활성 또는 예약 상태 콘텐츠를 대상으로 한다.
|
||||||
|
- 응답에는 예약 날짜/시간 UTC, 제목, 타입, 대상 ID를 포함한다.
|
||||||
|
- 타입 값은 기존 추천 페이지의 `RecommendedActivityType` 코드 체계를 사용한다.
|
||||||
|
- 구현 시 `RecommendedActivityType`은 `CreatorActivityType`으로 이름을 변경하고 공용 패키지로 이동한다.
|
||||||
|
- 추천 페이지와 크리에이터 채널 홈은 이동된 공용 `CreatorActivityType`을 함께 사용한다.
|
||||||
|
- 크리에이터 채널 홈 스케줄에서는 `LIVE`, `AUDIO`만 사용한다.
|
||||||
|
- 오디오 콘텐츠가 `다시보기` 카테고리여도 스케줄 타입은 `LIVE_REPLAY`가 아니라 `AUDIO`로 내려준다.
|
||||||
|
- 대상 ID는 타입이 `LIVE`이면 라이브 ID, `AUDIO`이면 오디오 콘텐츠 ID를 의미한다.
|
||||||
|
- 정렬은 예약 날짜/시간 오름차순이다. 같은 예약 시간이면 라이브를 오디오보다 먼저 표시한다.
|
||||||
|
- 성인 예약 라이브/오디오는 조회자의 성인 노출 정책이 false이면 노출하지 않는다.
|
||||||
|
- 성인 노출 정책은 DB 조회 조건에 먼저 반영하고, 라이브/오디오 스케줄 후보를 service에서 합친 뒤에도 최종 응답 전 한 번 더 보정한다.
|
||||||
|
- service 최종 보정에 필요한 성인 여부는 내부 스케줄 후보 record/domain model에만 포함하고, 공개 스케줄 응답 필드에는 포함하지 않는다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 예약 데이터가 없으면 빈 배열을 내려준다.
|
||||||
|
|
||||||
|
### Feature H. 오디오 콘텐츠 목록
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 최근 업로드된 오디오 콘텐츠를 최대 9개 내려준다.
|
||||||
|
- 신규 오디오 콘텐츠 영역과 오디오 목록 영역의 첫 번째 항목이 겹치지 않도록, 오디오 목록에서는 Feature D의 `latestAudioContent`로 내려간 가장 최신 콘텐츠를 제외한다.
|
||||||
|
- 예약 업로드 전 콘텐츠는 포함하지 않는다.
|
||||||
|
- 응답에는 다음 값을 포함한다.
|
||||||
|
- 오디오 콘텐츠 ID
|
||||||
|
- 제목
|
||||||
|
- duration
|
||||||
|
- 이미지
|
||||||
|
- 가격
|
||||||
|
- 포인트 사용 가능 여부
|
||||||
|
- 처음 올린 콘텐츠인지 여부
|
||||||
|
- 시리즈에 속해 있는 경우 시리즈 이름
|
||||||
|
- 시리즈에 속해 있는 경우 오리지널 시리즈 여부
|
||||||
|
- 기존 오디오 콘텐츠 목록의 `isPointAvailable`, `isScheduledToOpen`, 구매/대여 상태 의미를 유지한다.
|
||||||
|
- `처음 올린 콘텐츠인지 여부`는 해당 크리에이터의 공개 오디오 콘텐츠 중 공개 순서가 첫 번째인지로 판단한다.
|
||||||
|
- 공개 순서는 공개 시각이 가장 빠른 콘텐츠 1개를 첫 콘텐츠로 판단한다. 동일한 공개 시각이 있으면 `id` 오름차순을 2차 기준으로 한다.
|
||||||
|
- `오리지널 시리즈 여부`는 콘텐츠가 속한 `Series.isOriginal == true`이면 `true`로 판단한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 시리즈에 속하지 않은 콘텐츠는 시리즈 관련 필드를 `null`로 내려준다.
|
||||||
|
- 오디오 콘텐츠가 없으면 빈 배열을 내려준다.
|
||||||
|
|
||||||
|
### Feature I. 시리즈
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 시리즈는 최대 8개를 내려준다.
|
||||||
|
- 정렬은 각 시리즈에 속한 공개 콘텐츠의 최신 공개 시각 내림차순이다.
|
||||||
|
- 응답에는 기존 시리즈 카드 구성에 필요한 시리즈 ID, 제목, 커버 이미지, 연재 요일, 완결 여부, 콘텐츠 개수, 신규/인기 표시 정보를 포함한다.
|
||||||
|
- 성인 콘텐츠 노출 정책과 조회자 콘텐츠 타입 선호 정책은 기존 `ContentSeriesService.getSeriesList` 정책을 따른다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 공개 콘텐츠가 없는 시리즈를 노출할지 여부는 기존 시리즈 목록 정책을 따른다.
|
||||||
|
- 시리즈가 없으면 빈 배열을 내려준다.
|
||||||
|
|
||||||
|
### Feature J. 커뮤니티
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 커뮤니티 섹션은 `isFixed == false`인 커뮤니티 게시글만 대상으로 한다.
|
||||||
|
- 최대 3개를 최신순으로 내려준다.
|
||||||
|
- 응답에는 게시글 ID, 크리에이터 ID, 크리에이터 닉네임, 크리에이터 프로필 이미지, 이미지 URL, 오디오 URL, 본문, 가격, 작성 시각 UTC, 구매 여부, 좋아요 수, 댓글 수를 포함한다.
|
||||||
|
- 홈 응답에 포함하는 게시글 요약 필드는 기존 크리에이터 커뮤니티 전체보기 게시글 리스트와 같은 의미를 유지한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 고정 공지는 커뮤니티 섹션에 중복 노출하지 않는다.
|
||||||
|
- 커뮤니티 게시글이 없으면 빈 배열을 내려준다.
|
||||||
|
|
||||||
|
### Feature K. 팬 Talk
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 팬 Talk는 가장 최근에 남긴 최상위 팬 Talk 1개를 내려준다.
|
||||||
|
- 전체 팬 Talk 개수를 함께 내려준다.
|
||||||
|
- 기존 `CreatorCheers`에서 `parent == null`, `isActive == true`인 항목을 팬 Talk 대상으로 본다.
|
||||||
|
- 최근 팬 Talk 응답에는 팬 Talk ID, 작성자 ID, 작성자 닉네임, 작성자 프로필 이미지, 내용, 언어 코드, 작성 시각 UTC를 포함한다.
|
||||||
|
- 답글 목록은 홈 팬 Talk 요약에서는 내려주지 않는다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 팬 Talk가 없으면 `latestFanTalk`는 `null`, `totalCount`는 `0`으로 내려준다.
|
||||||
|
- 조회자와 차단 관계가 있는 작성자의 팬 Talk는 기존 팬 Talk 목록 정책과 동일하게 제외한다.
|
||||||
|
|
||||||
|
### Feature L. 소개
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 소개는 `member.introduce` 값을 내려준다.
|
||||||
|
- 값이 비어 있으면 빈 문자열을 내려준다.
|
||||||
|
|
||||||
|
### Feature M. 활동
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 활동 섹션은 `ExplorerService.getCreatorDetail`의 활동 지표 의미를 기준으로 한다.
|
||||||
|
- 응답에는 다음 값을 포함한다.
|
||||||
|
- 데뷔일 UTC
|
||||||
|
- D-Day 표시 계산에 필요한 경과 일수 또는 `dDay`
|
||||||
|
- 라이브 진행 횟수
|
||||||
|
- 라이브 누적 진행 시간
|
||||||
|
- 라이브 누적 참여자
|
||||||
|
- 업로드한 오디오 콘텐츠 개수
|
||||||
|
- 시리즈 개수
|
||||||
|
- 데뷔일은 첫 라이브 시작 시각과 첫 공개 오디오 콘텐츠 공개 시각 중 빠른 값으로 계산한다.
|
||||||
|
- 라이브 진행 횟수, 라이브 누적 진행 시간, 라이브 누적 참여자는 기존 `ExplorerQueryRepository.getLiveCount`, `getLiveTime`, `getLiveContributorCount`의 의미를 기준으로 한다.
|
||||||
|
- 업로드한 오디오 콘텐츠 개수는 공개된 오디오 콘텐츠만 포함하고 예약 업로드는 반영하지 않는다.
|
||||||
|
- 시리즈 개수는 크리에이터의 활성 시리즈 개수를 기준으로 한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 데뷔일 후보가 없으면 데뷔일은 `null`, `dDay`는 빈 문자열로 내려준다.
|
||||||
|
- 라이브 진행 시간이 없는 경우 `0`으로 내려준다.
|
||||||
|
|
||||||
|
### Feature N. SNS
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- SNS는 `ExplorerService.getCreatorDetail`의 SNS 필드 의미를 기준으로 한다.
|
||||||
|
- 응답에는 다음 값을 포함한다.
|
||||||
|
- `instagramUrl`
|
||||||
|
- `fancimmUrl`
|
||||||
|
- `xUrl`
|
||||||
|
- `youtubeUrl`
|
||||||
|
- `kakaoOpenChatUrl`
|
||||||
|
- 값이 없으면 빈 문자열 또는 `null` 중 기존 응답 관례를 따른다. 현재 구버전 상세는 빈 문자열을 사용한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. UX / UI Expectations
|
||||||
|
- Figma node `296:14890` 기준 홈 화면 섹션 순서는 다음을 따른다.
|
||||||
|
- 크리에이터 기본 정보
|
||||||
|
- 홈 탭
|
||||||
|
- 현재 진행 중인 라이브
|
||||||
|
- 신규 오디오 콘텐츠
|
||||||
|
- 채널 후원
|
||||||
|
- 공지
|
||||||
|
- 스케줄
|
||||||
|
- 오디오 콘텐츠
|
||||||
|
- 시리즈
|
||||||
|
- 커뮤니티
|
||||||
|
- 팬 Talk
|
||||||
|
- 소개
|
||||||
|
- 활동
|
||||||
|
- SNS
|
||||||
|
- Figma에 상단 탭으로 `홈/라이브/오디오/시리즈/화보/커뮤니티/팬Talk/후원`이 보이지만, 이번 API는 홈 탭만 지원한다.
|
||||||
|
- Figma 활동 섹션에는 `화보` 항목이 보이지만 이번 범위에서는 제외한다.
|
||||||
|
- 서버는 앱 표시 문구를 조합하지 않고, 앱이 섹션 노출 여부와 텍스트 포맷을 결정할 수 있는 원천 데이터를 내려준다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Technical Constraints
|
||||||
|
- 빌드 도구는 Gradle Wrapper(`./gradlew`), 런타임은 Kotlin + Java 17, Spring Boot 2.7.14를 따른다.
|
||||||
|
- 기존 v2 공개 API처럼 Controller는 `ApiResponse.ok(...)` 형태를 사용한다.
|
||||||
|
- 신규 API/서비스/DTO는 메인 페이지 홈 패키지(`kr.co.vividnext.sodalive.v2.api.home`)와 섞지 않고 크리에이터 채널 전용 패키지 경계에 둔다.
|
||||||
|
- 구버전 `explorer`, `content`, `live`, `series` 구현 코드는 응답 의미와 도메인 정책을 맞추기 위한 근거로 참조한다. 신규 크리에이터 채널 홈 API의 application/DTO 경계는 별도로 둔다.
|
||||||
|
- 시간 응답은 UTC 기준 ISO-8601 문자열을 기본으로 한다.
|
||||||
|
- 공개 API 스키마는 구현 전 plan-task에서 DTO 필드명과 nullable 정책을 확정한 뒤 변경한다.
|
||||||
|
- 신규 쿼리는 차단 관계, 비활성 회원, 성인 콘텐츠 노출, 예약 공개 여부를 명시적으로 테스트해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Metrics
|
||||||
|
- 크리에이터 채널 홈 API 응답 성공률
|
||||||
|
- 크리에이터 채널 홈 API 평균/95퍼센타일 응답 시간
|
||||||
|
- 섹션별 빈 응답 비율
|
||||||
|
- 채널 홈 진입 후 라이브/오디오/시리즈/커뮤니티/팬 Talk/후원 탭 이동률
|
||||||
|
- AI 채팅 버튼, DM 버튼 노출 대비 클릭률
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Open Questions
|
||||||
|
- 없음.
|
||||||
@@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterS
|
|||||||
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
||||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberService
|
||||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||||
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
|
||||||
@@ -45,6 +46,7 @@ class AdminChatCharacterController(
|
|||||||
private val adminService: AdminChatCharacterService,
|
private val adminService: AdminChatCharacterService,
|
||||||
private val s3Uploader: S3Uploader,
|
private val s3Uploader: S3Uploader,
|
||||||
private val originalWorkService: AdminOriginalWorkService,
|
private val originalWorkService: AdminOriginalWorkService,
|
||||||
|
private val creatorMemberService: ChatCharacterCreatorMemberService,
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
|
|
||||||
@Value("\${weraser.api-key}")
|
@Value("\${weraser.api-key}")
|
||||||
@@ -166,6 +168,7 @@ class AdminChatCharacterController(
|
|||||||
)
|
)
|
||||||
chatCharacter.imagePath = imagePath
|
chatCharacter.imagePath = imagePath
|
||||||
service.saveChatCharacter(chatCharacter)
|
service.saveChatCharacter(chatCharacter)
|
||||||
|
creatorMemberService.syncAiCharacterCreatorMemberDisplayFields(chatCharacter)
|
||||||
|
|
||||||
// 4. 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
// 4. 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
||||||
if (request.originalWorkId != null) {
|
if (request.originalWorkId != null) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.chat.character
|
|||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import javax.persistence.CascadeType
|
import javax.persistence.CascadeType
|
||||||
import javax.persistence.Column
|
import javax.persistence.Column
|
||||||
import javax.persistence.Entity
|
import javax.persistence.Entity
|
||||||
@@ -11,6 +12,7 @@ import javax.persistence.FetchType
|
|||||||
import javax.persistence.JoinColumn
|
import javax.persistence.JoinColumn
|
||||||
import javax.persistence.ManyToOne
|
import javax.persistence.ManyToOne
|
||||||
import javax.persistence.OneToMany
|
import javax.persistence.OneToMany
|
||||||
|
import javax.persistence.OneToOne
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
class ChatCharacter(
|
class ChatCharacter(
|
||||||
@@ -75,6 +77,10 @@ class ChatCharacter(
|
|||||||
) : BaseEntity() {
|
) : BaseEntity() {
|
||||||
var imagePath: String? = null
|
var imagePath: String? = null
|
||||||
|
|
||||||
|
@OneToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "creator_member_id", nullable = false, unique = true)
|
||||||
|
var creatorMember: Member? = null
|
||||||
|
|
||||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||||
var memories: MutableList<ChatCharacterMemory> = mutableListOf()
|
var memories: MutableList<ChatCharacterMemory> = mutableListOf()
|
||||||
|
|
||||||
|
|||||||
@@ -99,4 +99,6 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
|
|||||||
fun findRandomActiveExcluding(@Param("excludeIds") excludeIds: List<Long>, pageable: Pageable): List<ChatCharacter>
|
fun findRandomActiveExcluding(@Param("excludeIds") excludeIds: List<Long>, pageable: Pageable): List<ChatCharacter>
|
||||||
|
|
||||||
fun findByIdInAndIsActiveTrue(ids: List<Long>): List<ChatCharacter>
|
fun findByIdInAndIsActiveTrue(ids: List<Long>): List<ChatCharacter>
|
||||||
|
fun findByCreatorMemberId(creatorMemberId: Long): ChatCharacter?
|
||||||
|
fun existsByCreatorMemberId(creatorMemberId: Long): Boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.character.service
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberKind
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class ChatCharacterCreatorMemberService(
|
||||||
|
private val memberRepository: MemberRepository
|
||||||
|
) {
|
||||||
|
@Transactional
|
||||||
|
fun ensureAiCharacterCreatorMember(chatCharacter: ChatCharacter): Member {
|
||||||
|
val creatorMember = chatCharacter.creatorMember
|
||||||
|
if (creatorMember != null) {
|
||||||
|
if (creatorMember.memberKind == MemberKind.AI_CHARACTER) {
|
||||||
|
syncDisplayFields(creatorMember, chatCharacter)
|
||||||
|
memberRepository.save(creatorMember)
|
||||||
|
}
|
||||||
|
return creatorMember
|
||||||
|
}
|
||||||
|
|
||||||
|
val member = Member(
|
||||||
|
email = null,
|
||||||
|
password = "",
|
||||||
|
nickname = chatCharacter.name,
|
||||||
|
profileImage = chatCharacter.imagePath,
|
||||||
|
role = MemberRole.CREATOR,
|
||||||
|
memberKind = MemberKind.AI_CHARACTER
|
||||||
|
)
|
||||||
|
member.introduce = chatCharacter.description
|
||||||
|
|
||||||
|
val savedMember = memberRepository.save(member)
|
||||||
|
chatCharacter.creatorMember = savedMember
|
||||||
|
return savedMember
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun syncAiCharacterCreatorMemberDisplayFields(chatCharacter: ChatCharacter) {
|
||||||
|
val creatorMember = chatCharacter.creatorMember
|
||||||
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
if (creatorMember.memberKind != MemberKind.AI_CHARACTER) return
|
||||||
|
|
||||||
|
syncDisplayFields(creatorMember, chatCharacter)
|
||||||
|
memberRepository.save(creatorMember)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun syncDisplayFields(member: Member, chatCharacter: ChatCharacter) {
|
||||||
|
member.nickname = chatCharacter.name
|
||||||
|
member.profileImage = chatCharacter.imagePath
|
||||||
|
member.introduce = chatCharacter.description
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ class ChatCharacterService(
|
|||||||
private val goalRepository: ChatCharacterGoalRepository,
|
private val goalRepository: ChatCharacterGoalRepository,
|
||||||
private val popularCharacterQuery: PopularCharacterQuery,
|
private val popularCharacterQuery: PopularCharacterQuery,
|
||||||
private val imageRepository: CharacterImageRepository,
|
private val imageRepository: CharacterImageRepository,
|
||||||
|
private val creatorMemberService: ChatCharacterCreatorMemberService,
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val imageHost: String
|
private val imageHost: String
|
||||||
@@ -616,6 +617,7 @@ class ChatCharacterService(
|
|||||||
addHobbiesToCharacter(chatCharacter, hobbies)
|
addHobbiesToCharacter(chatCharacter, hobbies)
|
||||||
addGoalsToCharacter(chatCharacter, goals)
|
addGoalsToCharacter(chatCharacter, goals)
|
||||||
|
|
||||||
|
creatorMemberService.ensureAiCharacterCreatorMember(chatCharacter)
|
||||||
return saveChatCharacter(chatCharacter)
|
return saveChatCharacter(chatCharacter)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -721,7 +723,9 @@ class ChatCharacterService(
|
|||||||
val randomSuffix = "_" + java.util.UUID.randomUUID().toString().replace("-", "")
|
val randomSuffix = "_" + java.util.UUID.randomUUID().toString().replace("-", "")
|
||||||
chatCharacter.name = inactiveName + randomSuffix
|
chatCharacter.name = inactiveName + randomSuffix
|
||||||
|
|
||||||
return saveChatCharacter(chatCharacter)
|
val savedChatCharacter = saveChatCharacter(chatCharacter)
|
||||||
|
creatorMemberService.syncAiCharacterCreatorMemberDisplayFields(savedChatCharacter)
|
||||||
|
return savedChatCharacter
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이미지 경로가 있으면 설정
|
// 이미지 경로가 있으면 설정
|
||||||
@@ -779,6 +783,8 @@ class ChatCharacterService(
|
|||||||
updateRelationshipsForCharacter(chatCharacter, request.relationships)
|
updateRelationshipsForCharacter(chatCharacter, request.relationships)
|
||||||
}
|
}
|
||||||
|
|
||||||
return saveChatCharacter(chatCharacter)
|
val savedChatCharacter = saveChatCharacter(chatCharacter)
|
||||||
|
creatorMemberService.syncAiCharacterCreatorMemberDisplayFields(savedChatCharacter)
|
||||||
|
return savedChatCharacter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.fcm.PushTokenService
|
|||||||
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.jwt.TokenProvider
|
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberKind
|
||||||
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
|
||||||
import kr.co.vividnext.sodalive.member.login.LoginRequest
|
import kr.co.vividnext.sodalive.member.login.LoginRequest
|
||||||
@@ -70,6 +71,10 @@ class CreatorAdminMemberService(
|
|||||||
throw SodaException(messageKey = "creator.admin.member.inactive_account")
|
throw SodaException(messageKey = "creator.admin.member.inactive_account")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (member.memberKind == MemberKind.AI_CHARACTER) {
|
||||||
|
throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
}
|
||||||
|
|
||||||
if (member.role != MemberRole.CREATOR && member.role != MemberRole.AGENT) {
|
if (member.role != MemberRole.CREATOR && member.role != MemberRole.AGENT) {
|
||||||
throw SodaException(messageKey = "common.error.bad_credentials")
|
throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ data class Member(
|
|||||||
@Enumerated(value = EnumType.STRING)
|
@Enumerated(value = EnumType.STRING)
|
||||||
var role: MemberRole = MemberRole.USER,
|
var role: MemberRole = MemberRole.USER,
|
||||||
|
|
||||||
|
@Enumerated(value = EnumType.STRING)
|
||||||
|
var memberKind: MemberKind = MemberKind.HUMAN,
|
||||||
|
|
||||||
@Column(nullable = true)
|
@Column(nullable = true)
|
||||||
var activePid: String? = null,
|
var activePid: String? = null,
|
||||||
|
|
||||||
@@ -180,6 +183,10 @@ enum class MemberRole {
|
|||||||
ADMIN, BOT, USER, CREATOR, AGENT, CONTENT_MANAGER
|
ADMIN, BOT, USER, CREATOR, AGENT, CONTENT_MANAGER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class MemberKind {
|
||||||
|
HUMAN, AI_CHARACTER
|
||||||
|
}
|
||||||
|
|
||||||
enum class MemberProvider {
|
enum class MemberProvider {
|
||||||
EMAIL, KAKAO, GOOGLE, APPLE, LINE
|
EMAIL, KAKAO, GOOGLE, APPLE, LINE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -339,6 +339,10 @@ class MemberService(
|
|||||||
throw SodaException(messageKey = "member.validation.inactive_account")
|
throw SodaException(messageKey = "member.validation.inactive_account")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (member.memberKind == MemberKind.AI_CHARACTER) {
|
||||||
|
throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
}
|
||||||
|
|
||||||
if (member.provider != MemberProvider.EMAIL) {
|
if (member.provider != MemberProvider.EMAIL) {
|
||||||
val provider = resolveProviderLabel(member.provider)
|
val provider = resolveProviderLabel(member.provider)
|
||||||
throw SodaException(message = formatMessage("member.validation.email_registered_with_provider", provider))
|
throw SodaException(message = formatMessage("member.validation.email_registered_with_provider", provider))
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.common.domain
|
||||||
|
|
||||||
|
enum class CreatorActivityType(val code: String) {
|
||||||
|
LIVE("LIVE"),
|
||||||
|
AUDIO("AUDIO"),
|
||||||
|
COMMUNITY("COMMUNITY"),
|
||||||
|
LIVE_REPLAY("LIVE_REPLAY")
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.domain
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
data class CreatorChannelHome(
|
||||||
|
val creator: CreatorChannelCreator,
|
||||||
|
val currentLive: CreatorChannelLive?,
|
||||||
|
val latestAudioContent: CreatorChannelAudioContent?,
|
||||||
|
val channelDonations: List<CreatorChannelDonation>,
|
||||||
|
val notices: List<CreatorChannelCommunityPost>,
|
||||||
|
val schedules: List<CreatorChannelSchedule>,
|
||||||
|
val audioContents: List<CreatorChannelAudioContent>,
|
||||||
|
val series: List<CreatorChannelSeries>,
|
||||||
|
val communities: List<CreatorChannelCommunityPost>,
|
||||||
|
val fanTalk: CreatorChannelFanTalkSummary,
|
||||||
|
val introduce: String,
|
||||||
|
val activity: CreatorChannelActivity,
|
||||||
|
val sns: CreatorChannelSns
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelCreator(
|
||||||
|
val creatorId: Long,
|
||||||
|
val nickname: String,
|
||||||
|
val profileImageUrl: String,
|
||||||
|
val followerCount: Int,
|
||||||
|
val isAiChatAvailable: Boolean,
|
||||||
|
val isDmAvailable: Boolean,
|
||||||
|
val isFollow: Boolean,
|
||||||
|
val isNotify: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelLive(
|
||||||
|
val liveId: Long,
|
||||||
|
val title: String,
|
||||||
|
val coverImageUrl: String?,
|
||||||
|
val beginDateTime: LocalDateTime,
|
||||||
|
val price: Int,
|
||||||
|
val isAdult: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelAudioContent(
|
||||||
|
val audioContentId: Long,
|
||||||
|
val title: String,
|
||||||
|
val duration: String?,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val price: Int,
|
||||||
|
val isAdult: Boolean,
|
||||||
|
val isPointAvailable: Boolean,
|
||||||
|
val isFirstContent: Boolean,
|
||||||
|
val publishedAt: LocalDateTime,
|
||||||
|
val seriesName: String?,
|
||||||
|
val isOriginalSeries: Boolean?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelDonation(
|
||||||
|
val donationId: Long,
|
||||||
|
val memberId: Long,
|
||||||
|
val nickname: String,
|
||||||
|
val profileImageUrl: String,
|
||||||
|
val can: Int,
|
||||||
|
val isSecret: Boolean,
|
||||||
|
val message: String,
|
||||||
|
val createdAt: LocalDateTime
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelSchedule(
|
||||||
|
val scheduledAt: LocalDateTime,
|
||||||
|
val title: String,
|
||||||
|
val type: CreatorActivityType,
|
||||||
|
val targetId: Long,
|
||||||
|
val isAdult: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelSeries(
|
||||||
|
val seriesId: Long,
|
||||||
|
val title: String,
|
||||||
|
val coverImageUrl: String,
|
||||||
|
val publishedDaysOfWeek: String,
|
||||||
|
val isComplete: Boolean,
|
||||||
|
val numberOfContent: Int,
|
||||||
|
val isNew: Boolean,
|
||||||
|
val isPopular: Boolean,
|
||||||
|
val isOriginal: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelCommunityPost(
|
||||||
|
val postId: Long,
|
||||||
|
val creatorId: Long,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val creatorProfileUrl: String,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val audioUrl: String?,
|
||||||
|
val content: String,
|
||||||
|
val price: Int,
|
||||||
|
val date: LocalDateTime,
|
||||||
|
val existOrdered: Boolean,
|
||||||
|
val likeCount: Int,
|
||||||
|
val commentCount: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalkSummary(
|
||||||
|
val totalCount: Int,
|
||||||
|
val latestFanTalk: CreatorChannelFanTalk?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalk(
|
||||||
|
val fanTalkId: Long,
|
||||||
|
val memberId: Long,
|
||||||
|
val nickname: String,
|
||||||
|
val profileImageUrl: String,
|
||||||
|
val content: String,
|
||||||
|
val languageCode: String?,
|
||||||
|
val createdAt: LocalDateTime
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelActivity(
|
||||||
|
val debutDate: LocalDateTime?,
|
||||||
|
val dDay: String,
|
||||||
|
val liveCount: Long,
|
||||||
|
val liveDurationHours: Long,
|
||||||
|
val liveContributorCount: Long,
|
||||||
|
val audioContentCount: Long,
|
||||||
|
val seriesCount: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelSns(
|
||||||
|
val instagramUrl: String,
|
||||||
|
val fancimmUrl: String,
|
||||||
|
val xUrl: String,
|
||||||
|
val youtubeUrl: String,
|
||||||
|
val kakaoOpenChatUrl: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.domain
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
class CreatorChannelHomeQueryPolicy {
|
||||||
|
fun limitSchedules(
|
||||||
|
schedules: List<CreatorChannelSchedule>,
|
||||||
|
now: LocalDateTime,
|
||||||
|
canViewAdultContent: Boolean
|
||||||
|
): List<CreatorChannelSchedule> {
|
||||||
|
return schedules
|
||||||
|
.filter { it.scheduledAt > now }
|
||||||
|
.filter { canViewAdultContent || !it.isAdult }
|
||||||
|
.sortedWith(compareBy<CreatorChannelSchedule> { it.scheduledAt }.thenBy { it.type.scheduleOrder() })
|
||||||
|
.take(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun excludeLatestAudioContent(
|
||||||
|
audioContents: List<CreatorChannelAudioContent>,
|
||||||
|
latestAudioContentId: Long?
|
||||||
|
): List<CreatorChannelAudioContent> {
|
||||||
|
return audioContents.filter { it.audioContentId != latestAudioContentId }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markFirstAudioContent(audioContents: List<CreatorChannelAudioContent>): List<CreatorChannelAudioContent> {
|
||||||
|
val firstAudioContentId = audioContents
|
||||||
|
.minWithOrNull(compareBy<CreatorChannelAudioContent> { it.publishedAt }.thenBy { it.audioContentId })
|
||||||
|
?.audioContentId
|
||||||
|
|
||||||
|
return audioContents.map { audioContent ->
|
||||||
|
audioContent.copy(isFirstContent = audioContent.audioContentId == firstAudioContentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CreatorActivityType.scheduleOrder(): Int {
|
||||||
|
return if (this == CreatorActivityType.LIVE) 0 else 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelActivity
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelAudioContent
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCommunityPost
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCreator
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelDonation
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalk
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalkSummary
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHome
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelLive
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSchedule
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSeries
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSns
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
|
data class CreatorChannelHomeResponse(
|
||||||
|
val creator: CreatorChannelCreatorResponse,
|
||||||
|
val currentLive: CreatorChannelLiveResponse?,
|
||||||
|
val latestAudioContent: CreatorChannelAudioContentResponse?,
|
||||||
|
val channelDonations: List<CreatorChannelDonationResponse>,
|
||||||
|
val notices: List<CreatorChannelCommunityPostResponse>,
|
||||||
|
val schedules: List<CreatorChannelScheduleResponse>,
|
||||||
|
val audioContents: List<CreatorChannelAudioContentResponse>,
|
||||||
|
val series: List<CreatorChannelSeriesResponse>,
|
||||||
|
val communities: List<CreatorChannelCommunityPostResponse>,
|
||||||
|
val fanTalk: CreatorChannelFanTalkSummaryResponse,
|
||||||
|
val introduce: String,
|
||||||
|
val activity: CreatorChannelActivityResponse,
|
||||||
|
val sns: CreatorChannelSnsResponse
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(home: CreatorChannelHome): CreatorChannelHomeResponse {
|
||||||
|
return CreatorChannelHomeResponse(
|
||||||
|
creator = CreatorChannelCreatorResponse.from(home.creator),
|
||||||
|
currentLive = home.currentLive?.let(CreatorChannelLiveResponse::from),
|
||||||
|
latestAudioContent = home.latestAudioContent?.let(CreatorChannelAudioContentResponse::from),
|
||||||
|
channelDonations = home.channelDonations.map(CreatorChannelDonationResponse::from),
|
||||||
|
notices = home.notices.map(CreatorChannelCommunityPostResponse::from),
|
||||||
|
schedules = home.schedules.map(CreatorChannelScheduleResponse::from),
|
||||||
|
audioContents = home.audioContents.map(CreatorChannelAudioContentResponse::from),
|
||||||
|
series = home.series.map(CreatorChannelSeriesResponse::from),
|
||||||
|
communities = home.communities.map(CreatorChannelCommunityPostResponse::from),
|
||||||
|
fanTalk = CreatorChannelFanTalkSummaryResponse.from(home.fanTalk),
|
||||||
|
introduce = home.introduce,
|
||||||
|
activity = CreatorChannelActivityResponse.from(home.activity),
|
||||||
|
sns = CreatorChannelSnsResponse.from(home.sns)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelCreatorResponse(
|
||||||
|
val creatorId: Long,
|
||||||
|
val nickname: String,
|
||||||
|
val profileImageUrl: String,
|
||||||
|
val followerCount: Int,
|
||||||
|
@JsonProperty("isAiChatAvailable")
|
||||||
|
val isAiChatAvailable: Boolean,
|
||||||
|
@JsonProperty("isDmAvailable")
|
||||||
|
val isDmAvailable: Boolean,
|
||||||
|
@JsonProperty("isFollow")
|
||||||
|
val isFollow: Boolean,
|
||||||
|
@JsonProperty("isNotify")
|
||||||
|
val isNotify: Boolean
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(creator: CreatorChannelCreator): CreatorChannelCreatorResponse {
|
||||||
|
return CreatorChannelCreatorResponse(
|
||||||
|
creatorId = creator.creatorId,
|
||||||
|
nickname = creator.nickname,
|
||||||
|
profileImageUrl = creator.profileImageUrl,
|
||||||
|
followerCount = creator.followerCount,
|
||||||
|
isAiChatAvailable = creator.isAiChatAvailable,
|
||||||
|
isDmAvailable = creator.isDmAvailable,
|
||||||
|
isFollow = creator.isFollow,
|
||||||
|
isNotify = creator.isNotify
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelLiveResponse(
|
||||||
|
val liveId: Long,
|
||||||
|
val title: String,
|
||||||
|
val coverImageUrl: String?,
|
||||||
|
val beginDateTimeUtc: String,
|
||||||
|
val price: Int,
|
||||||
|
@JsonProperty("isAdult")
|
||||||
|
val isAdult: Boolean
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(live: CreatorChannelLive): CreatorChannelLiveResponse {
|
||||||
|
return CreatorChannelLiveResponse(
|
||||||
|
liveId = live.liveId,
|
||||||
|
title = live.title,
|
||||||
|
coverImageUrl = live.coverImageUrl,
|
||||||
|
beginDateTimeUtc = live.beginDateTime.toUtcIso(),
|
||||||
|
price = live.price,
|
||||||
|
isAdult = live.isAdult
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelAudioContentResponse(
|
||||||
|
val audioContentId: Long,
|
||||||
|
val title: String,
|
||||||
|
val duration: String?,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val price: Int,
|
||||||
|
@JsonProperty("isAdult")
|
||||||
|
val isAdult: Boolean,
|
||||||
|
@JsonProperty("isPointAvailable")
|
||||||
|
val isPointAvailable: Boolean,
|
||||||
|
@JsonProperty("isFirstContent")
|
||||||
|
val isFirstContent: Boolean,
|
||||||
|
val seriesName: String?,
|
||||||
|
@JsonProperty("isOriginalSeries")
|
||||||
|
val isOriginalSeries: Boolean?
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(audioContent: CreatorChannelAudioContent): CreatorChannelAudioContentResponse {
|
||||||
|
return CreatorChannelAudioContentResponse(
|
||||||
|
audioContentId = audioContent.audioContentId,
|
||||||
|
title = audioContent.title,
|
||||||
|
duration = audioContent.duration,
|
||||||
|
imageUrl = audioContent.imageUrl,
|
||||||
|
price = audioContent.price,
|
||||||
|
isAdult = audioContent.isAdult,
|
||||||
|
isPointAvailable = audioContent.isPointAvailable,
|
||||||
|
isFirstContent = audioContent.isFirstContent,
|
||||||
|
seriesName = audioContent.seriesName,
|
||||||
|
isOriginalSeries = audioContent.isOriginalSeries
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelDonationResponse(
|
||||||
|
val donationId: Long,
|
||||||
|
val memberId: Long,
|
||||||
|
val nickname: String,
|
||||||
|
val profileImageUrl: String,
|
||||||
|
val can: Int,
|
||||||
|
@JsonProperty("isSecret")
|
||||||
|
val isSecret: Boolean,
|
||||||
|
val message: String,
|
||||||
|
val createdAtUtc: String
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(donation: CreatorChannelDonation): CreatorChannelDonationResponse {
|
||||||
|
return CreatorChannelDonationResponse(
|
||||||
|
donationId = donation.donationId,
|
||||||
|
memberId = donation.memberId,
|
||||||
|
nickname = donation.nickname,
|
||||||
|
profileImageUrl = donation.profileImageUrl,
|
||||||
|
can = donation.can,
|
||||||
|
isSecret = donation.isSecret,
|
||||||
|
message = donation.message,
|
||||||
|
createdAtUtc = donation.createdAt.toUtcIso()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelScheduleResponse(
|
||||||
|
val scheduledAtUtc: String,
|
||||||
|
val title: String,
|
||||||
|
val type: CreatorActivityType,
|
||||||
|
val targetId: Long
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(schedule: CreatorChannelSchedule): CreatorChannelScheduleResponse {
|
||||||
|
return CreatorChannelScheduleResponse(
|
||||||
|
scheduledAtUtc = schedule.scheduledAt.toUtcIso(),
|
||||||
|
title = schedule.title,
|
||||||
|
type = schedule.type,
|
||||||
|
targetId = schedule.targetId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelSeriesResponse(
|
||||||
|
val seriesId: Long,
|
||||||
|
val title: String,
|
||||||
|
val coverImageUrl: String,
|
||||||
|
val publishedDaysOfWeek: String,
|
||||||
|
@JsonProperty("isComplete")
|
||||||
|
val isComplete: Boolean,
|
||||||
|
val numberOfContent: Int,
|
||||||
|
@JsonProperty("isNew")
|
||||||
|
val isNew: Boolean,
|
||||||
|
@JsonProperty("isPopular")
|
||||||
|
val isPopular: Boolean,
|
||||||
|
@JsonProperty("isOriginal")
|
||||||
|
val isOriginal: Boolean
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(series: CreatorChannelSeries): CreatorChannelSeriesResponse {
|
||||||
|
return CreatorChannelSeriesResponse(
|
||||||
|
seriesId = series.seriesId,
|
||||||
|
title = series.title,
|
||||||
|
coverImageUrl = series.coverImageUrl,
|
||||||
|
publishedDaysOfWeek = series.publishedDaysOfWeek,
|
||||||
|
isComplete = series.isComplete,
|
||||||
|
numberOfContent = series.numberOfContent,
|
||||||
|
isNew = series.isNew,
|
||||||
|
isPopular = series.isPopular,
|
||||||
|
isOriginal = series.isOriginal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelCommunityPostResponse(
|
||||||
|
val postId: Long,
|
||||||
|
val creatorId: Long,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val creatorProfileUrl: String,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val audioUrl: String?,
|
||||||
|
val content: String,
|
||||||
|
val price: Int,
|
||||||
|
val dateUtc: String,
|
||||||
|
val existOrdered: Boolean,
|
||||||
|
val likeCount: Int,
|
||||||
|
val commentCount: Int
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(post: CreatorChannelCommunityPost): CreatorChannelCommunityPostResponse {
|
||||||
|
return CreatorChannelCommunityPostResponse(
|
||||||
|
postId = post.postId,
|
||||||
|
creatorId = post.creatorId,
|
||||||
|
creatorNickname = post.creatorNickname,
|
||||||
|
creatorProfileUrl = post.creatorProfileUrl,
|
||||||
|
imageUrl = post.imageUrl,
|
||||||
|
audioUrl = post.audioUrl,
|
||||||
|
content = post.content,
|
||||||
|
price = post.price,
|
||||||
|
dateUtc = post.date.toUtcIso(),
|
||||||
|
existOrdered = post.existOrdered,
|
||||||
|
likeCount = post.likeCount,
|
||||||
|
commentCount = post.commentCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalkSummaryResponse(
|
||||||
|
val totalCount: Int,
|
||||||
|
val latestFanTalk: CreatorChannelFanTalkResponse?
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(summary: CreatorChannelFanTalkSummary): CreatorChannelFanTalkSummaryResponse {
|
||||||
|
return CreatorChannelFanTalkSummaryResponse(
|
||||||
|
totalCount = summary.totalCount,
|
||||||
|
latestFanTalk = summary.latestFanTalk?.let(CreatorChannelFanTalkResponse::from)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalkResponse(
|
||||||
|
val fanTalkId: Long,
|
||||||
|
val memberId: Long,
|
||||||
|
val nickname: String,
|
||||||
|
val profileImageUrl: String,
|
||||||
|
val content: String,
|
||||||
|
val languageCode: String?,
|
||||||
|
val createdAtUtc: String
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(fanTalk: CreatorChannelFanTalk): CreatorChannelFanTalkResponse {
|
||||||
|
return CreatorChannelFanTalkResponse(
|
||||||
|
fanTalkId = fanTalk.fanTalkId,
|
||||||
|
memberId = fanTalk.memberId,
|
||||||
|
nickname = fanTalk.nickname,
|
||||||
|
profileImageUrl = fanTalk.profileImageUrl,
|
||||||
|
content = fanTalk.content,
|
||||||
|
languageCode = fanTalk.languageCode,
|
||||||
|
createdAtUtc = fanTalk.createdAt.toUtcIso()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelActivityResponse(
|
||||||
|
val debutDateUtc: String?,
|
||||||
|
val dDay: String,
|
||||||
|
val liveCount: Long,
|
||||||
|
val liveDurationHours: Long,
|
||||||
|
val liveContributorCount: Long,
|
||||||
|
val audioContentCount: Long,
|
||||||
|
val seriesCount: Long
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(activity: CreatorChannelActivity): CreatorChannelActivityResponse {
|
||||||
|
return CreatorChannelActivityResponse(
|
||||||
|
debutDateUtc = activity.debutDate?.toUtcIso(),
|
||||||
|
dDay = activity.dDay,
|
||||||
|
liveCount = activity.liveCount,
|
||||||
|
liveDurationHours = activity.liveDurationHours,
|
||||||
|
liveContributorCount = activity.liveContributorCount,
|
||||||
|
audioContentCount = activity.audioContentCount,
|
||||||
|
seriesCount = activity.seriesCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelSnsResponse(
|
||||||
|
val instagramUrl: String,
|
||||||
|
val fancimmUrl: String,
|
||||||
|
val xUrl: String,
|
||||||
|
val youtubeUrl: String,
|
||||||
|
val kakaoOpenChatUrl: String
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(sns: CreatorChannelSns): CreatorChannelSnsResponse {
|
||||||
|
return CreatorChannelSnsResponse(
|
||||||
|
instagramUrl = sns.instagramUrl,
|
||||||
|
fancimmUrl = sns.fancimmUrl,
|
||||||
|
xUrl = sns.xUrl,
|
||||||
|
youtubeUrl = sns.youtubeUrl,
|
||||||
|
kakaoOpenChatUrl = sns.kakaoOpenChatUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LocalDateTime.toUtcIso(): String {
|
||||||
|
return atOffset(ZoneOffset.UTC).toInstant().toString()
|
||||||
|
}
|
||||||
@@ -24,8 +24,8 @@ import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
|
|||||||
import kr.co.vividnext.sodalive.member.QMember
|
import kr.co.vividnext.sodalive.member.QMember
|
||||||
import kr.co.vividnext.sodalive.member.QMember.member
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
import kr.co.vividnext.sodalive.member.block.QBlockMember
|
import kr.co.vividnext.sodalive.member.block.QBlockMember
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScoreSpec
|
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScoreSpec
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedActivityType
|
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
|
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeBannerRecommendationRecord
|
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeBannerRecommendationRecord
|
||||||
@@ -197,7 +197,7 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
RecentlyActiveCreatorRecord(
|
RecentlyActiveCreatorRecord(
|
||||||
creatorNickname = row[0] as String,
|
creatorNickname = row[0] as String,
|
||||||
creatorProfileImage = row[1] as String?,
|
creatorProfileImage = row[1] as String?,
|
||||||
activityType = RecommendedActivityType.valueOf(row[2] as String),
|
activityType = CreatorActivityType.valueOf(row[2] as String),
|
||||||
activityAt = toLocalDateTime(row[3]),
|
activityAt = toLocalDateTime(row[3]),
|
||||||
targetId = (row[4] as Number?)?.toLong()
|
targetId = (row[4] as Number?)?.toLong()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.recommendation.application
|
package kr.co.vividnext.sodalive.v2.recommendation.application
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedActivityType
|
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
|
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeBannerRecommendationRecord
|
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeBannerRecommendationRecord
|
||||||
@@ -150,11 +150,11 @@ class HomeRecommendationQueryService(
|
|||||||
return selectedGroups.take(genreLimit)
|
return selectedGroups.take(genreLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resolveAudioContentActivityType(theme: String): RecommendedActivityType {
|
fun resolveAudioContentActivityType(theme: String): CreatorActivityType {
|
||||||
return if (theme == LIVE_REPLAY_THEME) {
|
return if (theme == LIVE_REPLAY_THEME) {
|
||||||
RecommendedActivityType.LIVE_REPLAY
|
CreatorActivityType.LIVE_REPLAY
|
||||||
} else {
|
} else {
|
||||||
RecommendedActivityType.AUDIO
|
CreatorActivityType.AUDIO
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.recommendation.domain
|
|
||||||
|
|
||||||
enum class RecommendedActivityType(val code: String) {
|
|
||||||
LIVE("LIVE"),
|
|
||||||
AUDIO("AUDIO"),
|
|
||||||
COMMUNITY("COMMUNITY"),
|
|
||||||
LIVE_REPLAY("LIVE_REPLAY")
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.recommendation.port.out
|
package kr.co.vividnext.sodalive.v2.recommendation.port.out
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedActivityType
|
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
interface HomeRecommendationQueryPort {
|
interface HomeRecommendationQueryPort {
|
||||||
@@ -97,7 +97,7 @@ data class HomeBannerRecommendationRecord(
|
|||||||
data class RecentlyActiveCreatorRecord(
|
data class RecentlyActiveCreatorRecord(
|
||||||
val creatorNickname: String,
|
val creatorNickname: String,
|
||||||
val creatorProfileImage: String?,
|
val creatorProfileImage: String?,
|
||||||
val activityType: RecommendedActivityType,
|
val activityType: CreatorActivityType,
|
||||||
val activityAt: LocalDateTime,
|
val activityAt: LocalDateTime,
|
||||||
val targetId: Long?
|
val targetId: Long?
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberKind
|
||||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
@@ -209,6 +210,9 @@ class UserCreatorChatService(
|
|||||||
|
|
||||||
private fun validateRecipient(sender: Member, recipient: Member) {
|
private fun validateRecipient(sender: Member, recipient: Member) {
|
||||||
if (!recipient.isActive) throw SodaException(messageKey = "message.error.recipient_inactive")
|
if (!recipient.isActive) throw SodaException(messageKey = "message.error.recipient_inactive")
|
||||||
|
if (recipient.memberKind == MemberKind.AI_CHARACTER) {
|
||||||
|
throw SodaException(messageKey = "message.error.recipient_not_found")
|
||||||
|
}
|
||||||
if (sender.id == recipient.id) throw SodaException(messageKey = "common.error.invalid_request")
|
if (sender.id == recipient.id) throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
if (blockMemberRepository.isBlocked(blockedMemberId = sender.id!!, memberId = recipient.id!!)) {
|
if (blockMemberRepository.isBlocked(blockedMemberId = sender.id!!, memberId = recipient.id!!)) {
|
||||||
throw SodaException(messageKey = "message.error.blocked_by_recipient")
|
throw SodaException(messageKey = "message.error.blocked_by_recipient")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.admin.chat.character
|
|||||||
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
|
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
|
||||||
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
||||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberService
|
||||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
@@ -15,6 +16,7 @@ class AdminChatCharacterControllerTest {
|
|||||||
adminService = Mockito.mock(AdminChatCharacterService::class.java),
|
adminService = Mockito.mock(AdminChatCharacterService::class.java),
|
||||||
s3Uploader = Mockito.mock(S3Uploader::class.java),
|
s3Uploader = Mockito.mock(S3Uploader::class.java),
|
||||||
originalWorkService = Mockito.mock(AdminOriginalWorkService::class.java),
|
originalWorkService = Mockito.mock(AdminOriginalWorkService::class.java),
|
||||||
|
creatorMemberService = Mockito.mock(ChatCharacterCreatorMemberService::class.java),
|
||||||
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java),
|
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java),
|
||||||
apiKey = "test-api-key",
|
apiKey = "test-api-key",
|
||||||
apiUrl = "https://example.com",
|
apiUrl = "https://example.com",
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.character.service
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberKind
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@Transactional
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
class ChatCharacterCreatorMemberServiceIntegrationTest @Autowired constructor(
|
||||||
|
private val creatorMemberService: ChatCharacterCreatorMemberService,
|
||||||
|
private val memberRepository: MemberRepository,
|
||||||
|
private val chatCharacterRepository: ChatCharacterRepository,
|
||||||
|
private val entityManager: EntityManager
|
||||||
|
) {
|
||||||
|
@Test
|
||||||
|
fun `ChatCharacter creatorMember 관계와 repository 메서드를 사용할 수 있다`() {
|
||||||
|
val member = memberRepository.save(createMember(memberKind = MemberKind.AI_CHARACTER))
|
||||||
|
val chatCharacter = chatCharacterRepository.save(createCharacter().apply { creatorMember = member })
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val found = chatCharacterRepository.findByCreatorMemberId(member.id!!)
|
||||||
|
|
||||||
|
assertNotNull(found)
|
||||||
|
assertEquals(chatCharacter.id, found!!.id)
|
||||||
|
assertEquals(true, chatCharacterRepository.existsByCreatorMemberId(member.id!!))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `AI 캐릭터용 Member를 생성하고 표시 정보를 복사한다`() {
|
||||||
|
val chatCharacter = createCharacter(
|
||||||
|
name = "소다",
|
||||||
|
description = "AI 캐릭터 설명",
|
||||||
|
imagePath = "characters/1/profile.png"
|
||||||
|
)
|
||||||
|
|
||||||
|
val member = creatorMemberService.ensureAiCharacterCreatorMember(chatCharacter)
|
||||||
|
chatCharacterRepository.save(chatCharacter)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val savedMember = memberRepository.findById(member.id!!).orElseThrow()
|
||||||
|
val savedCharacter = chatCharacterRepository.findByCreatorMemberId(member.id!!)
|
||||||
|
assertNotNull(savedCharacter)
|
||||||
|
assertNull(savedMember.email)
|
||||||
|
assertEquals("", savedMember.password)
|
||||||
|
assertEquals(MemberRole.CREATOR, savedMember.role)
|
||||||
|
assertEquals(MemberKind.AI_CHARACTER, savedMember.memberKind)
|
||||||
|
assertEquals("소다", savedMember.nickname)
|
||||||
|
assertEquals("characters/1/profile.png", savedMember.profileImage)
|
||||||
|
assertEquals("AI 캐릭터 설명", savedMember.introduce)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `AI 캐릭터용 Member 표시 정보를 동기화한다`() {
|
||||||
|
val member = memberRepository.save(
|
||||||
|
createMember(memberKind = MemberKind.AI_CHARACTER).apply {
|
||||||
|
nickname = "old-name"
|
||||||
|
profileImage = "old/profile.png"
|
||||||
|
introduce = "old-description"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
val chatCharacter = chatCharacterRepository.save(
|
||||||
|
createCharacter(
|
||||||
|
name = "new-name",
|
||||||
|
description = "new-description",
|
||||||
|
imagePath = "new/profile.png"
|
||||||
|
).apply { creatorMember = member }
|
||||||
|
)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val savedCharacter = chatCharacterRepository.findById(chatCharacter.id!!).orElseThrow()
|
||||||
|
creatorMemberService.syncAiCharacterCreatorMemberDisplayFields(savedCharacter)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val savedMember = memberRepository.findById(member.id!!).orElseThrow()
|
||||||
|
assertEquals("new-name", savedMember.nickname)
|
||||||
|
assertEquals("new/profile.png", savedMember.profileImage)
|
||||||
|
assertEquals("new-description", savedMember.introduce)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `동기화 대상 creatorMember가 없으면 저장 실패를 위해 예외를 던진다`() {
|
||||||
|
val chatCharacter = createCharacter(
|
||||||
|
id = 1L,
|
||||||
|
name = "missing-creator-member",
|
||||||
|
description = "description"
|
||||||
|
)
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
creatorMemberService.syncAiCharacterCreatorMemberDisplayFields(chatCharacter)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `사람 크리에이터 Member 표시 정보는 덮어쓰지 않는다`() {
|
||||||
|
val member = memberRepository.save(
|
||||||
|
createMember(memberKind = MemberKind.HUMAN).apply {
|
||||||
|
nickname = "human-name"
|
||||||
|
profileImage = "human/profile.png"
|
||||||
|
introduce = "human-description"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
val chatCharacter = chatCharacterRepository.save(
|
||||||
|
createCharacter(
|
||||||
|
name = "ai-name",
|
||||||
|
description = "ai-description",
|
||||||
|
imagePath = "ai/profile.png"
|
||||||
|
).apply { creatorMember = member }
|
||||||
|
)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val savedCharacter = chatCharacterRepository.findById(chatCharacter.id!!).orElseThrow()
|
||||||
|
creatorMemberService.syncAiCharacterCreatorMemberDisplayFields(savedCharacter)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val savedMember = memberRepository.findById(member.id!!).orElseThrow()
|
||||||
|
assertEquals("human-name", savedMember.nickname)
|
||||||
|
assertEquals("human/profile.png", savedMember.profileImage)
|
||||||
|
assertEquals("human-description", savedMember.introduce)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createCharacter(
|
||||||
|
id: Long? = null,
|
||||||
|
name: String = "character-name",
|
||||||
|
description: String = "character-description",
|
||||||
|
imagePath: String? = null
|
||||||
|
): ChatCharacter {
|
||||||
|
val character = ChatCharacter(
|
||||||
|
characterUUID = "character-uuid-$name",
|
||||||
|
name = name,
|
||||||
|
description = description,
|
||||||
|
systemPrompt = "system-prompt"
|
||||||
|
)
|
||||||
|
character.id = id
|
||||||
|
character.imagePath = imagePath
|
||||||
|
return character
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMember(memberKind: MemberKind): Member {
|
||||||
|
return Member(
|
||||||
|
email = if (memberKind == MemberKind.HUMAN) "human-${System.nanoTime()}@example.com" else null,
|
||||||
|
password = if (memberKind == MemberKind.HUMAN) "password" else "",
|
||||||
|
nickname = "member-name",
|
||||||
|
role = MemberRole.CREATOR,
|
||||||
|
memberKind = memberKind
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.character.service
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterGoalRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterTagRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterValueRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberKind
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import java.util.Optional
|
||||||
|
|
||||||
|
class ChatCharacterCreatorMemberServiceTest {
|
||||||
|
private lateinit var chatCharacterRepository: ChatCharacterRepository
|
||||||
|
private lateinit var creatorMemberService: ChatCharacterCreatorMemberService
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
chatCharacterRepository = Mockito.mock(ChatCharacterRepository::class.java)
|
||||||
|
creatorMemberService = Mockito.mock(ChatCharacterCreatorMemberService::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `캐릭터 생성 시 저장 전에 AI 캐릭터용 Member 생성을 요청한다`() {
|
||||||
|
val service = createChatCharacterService()
|
||||||
|
Mockito.`when`(chatCharacterRepository.save(Mockito.any(ChatCharacter::class.java))).thenAnswer { invocation ->
|
||||||
|
(invocation.arguments[0] as ChatCharacter).apply { id = 1L }
|
||||||
|
}
|
||||||
|
|
||||||
|
val chatCharacter = service.createChatCharacter(
|
||||||
|
characterUUID = "character-1",
|
||||||
|
name = "created-name",
|
||||||
|
description = "created-description",
|
||||||
|
systemPrompt = "system-prompt"
|
||||||
|
)
|
||||||
|
|
||||||
|
val inOrder = Mockito.inOrder(creatorMemberService, chatCharacterRepository)
|
||||||
|
inOrder.verify(creatorMemberService).ensureAiCharacterCreatorMember(chatCharacter)
|
||||||
|
inOrder.verify(chatCharacterRepository).save(chatCharacter)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `캐릭터 수정 시 AI 캐릭터용 Member 표시 정보 동기화를 요청한다`() {
|
||||||
|
val service = createChatCharacterService()
|
||||||
|
val chatCharacter = createCharacter(id = 1L).apply {
|
||||||
|
creatorMember = createMember(memberKind = MemberKind.AI_CHARACTER)
|
||||||
|
}
|
||||||
|
Mockito.`when`(chatCharacterRepository.findById(1L)).thenReturn(Optional.of(chatCharacter))
|
||||||
|
Mockito.`when`(chatCharacterRepository.save(Mockito.any(ChatCharacter::class.java))).thenAnswer { it.arguments[0] }
|
||||||
|
|
||||||
|
service.updateChatCharacterWithDetails(
|
||||||
|
imagePath = "updated/profile.png",
|
||||||
|
request = kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest(
|
||||||
|
id = 1L,
|
||||||
|
name = "updated-name",
|
||||||
|
description = "updated-description"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("updated-name", chatCharacter.name)
|
||||||
|
assertEquals("updated-description", chatCharacter.description)
|
||||||
|
assertEquals("updated/profile.png", chatCharacter.imagePath)
|
||||||
|
Mockito.verify(creatorMemberService).syncAiCharacterCreatorMemberDisplayFields(chatCharacter)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createChatCharacterService(): ChatCharacterService {
|
||||||
|
return ChatCharacterService(
|
||||||
|
chatCharacterRepository = chatCharacterRepository,
|
||||||
|
tagRepository = Mockito.mock(ChatCharacterTagRepository::class.java),
|
||||||
|
valueRepository = Mockito.mock(ChatCharacterValueRepository::class.java),
|
||||||
|
hobbyRepository = Mockito.mock(ChatCharacterHobbyRepository::class.java),
|
||||||
|
goalRepository = Mockito.mock(ChatCharacterGoalRepository::class.java),
|
||||||
|
popularCharacterQuery = Mockito.mock(PopularCharacterQuery::class.java),
|
||||||
|
imageRepository = Mockito.mock(CharacterImageRepository::class.java),
|
||||||
|
creatorMemberService = creatorMemberService,
|
||||||
|
imageHost = "https://cdn.example.com"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createCharacter(
|
||||||
|
id: Long? = null,
|
||||||
|
name: String = "character-name",
|
||||||
|
description: String = "character-description",
|
||||||
|
imagePath: String? = null
|
||||||
|
): ChatCharacter {
|
||||||
|
val character = ChatCharacter(
|
||||||
|
characterUUID = "character-uuid",
|
||||||
|
name = name,
|
||||||
|
description = description,
|
||||||
|
systemPrompt = "system-prompt"
|
||||||
|
)
|
||||||
|
character.id = id
|
||||||
|
character.imagePath = imagePath
|
||||||
|
return character
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMember(memberKind: MemberKind): Member {
|
||||||
|
return Member(
|
||||||
|
email = if (memberKind == MemberKind.HUMAN) "human@example.com" else null,
|
||||||
|
password = "password",
|
||||||
|
nickname = "member-name",
|
||||||
|
role = MemberRole.CREATOR,
|
||||||
|
memberKind = memberKind
|
||||||
|
).apply { id = 1L }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package kr.co.vividnext.sodalive.creator.admin.member
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberKind
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.member.login.LoginRequest
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
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.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@Transactional
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
class CreatorAdminMemberServiceTest @Autowired constructor(
|
||||||
|
private val service: CreatorAdminMemberService,
|
||||||
|
private val memberRepository: MemberRepository,
|
||||||
|
private val entityManager: EntityManager
|
||||||
|
) {
|
||||||
|
@Test
|
||||||
|
@DisplayName("AI 캐릭터용 Member는 크리에이터 관리자 로그인할 수 없다")
|
||||||
|
fun shouldRejectAiCharacterMemberCreatorAdminLogin() {
|
||||||
|
val member = memberRepository.save(
|
||||||
|
Member(
|
||||||
|
email = "ai-character-creator-admin@test.com",
|
||||||
|
password = "",
|
||||||
|
nickname = "AI 캐릭터 관리자",
|
||||||
|
role = MemberRole.CREATOR,
|
||||||
|
memberKind = MemberKind.AI_CHARACTER
|
||||||
|
)
|
||||||
|
)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.login(LoginRequest(email = member.email!!, password = "password"))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("common.error.bad_credentials", exception.messageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package kr.co.vividnext.sodalive.member
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.member.login.LoginRequest
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
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.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@Transactional
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
class MemberServiceTest @Autowired constructor(
|
||||||
|
private val service: MemberService,
|
||||||
|
private val memberRepository: MemberRepository,
|
||||||
|
private val entityManager: EntityManager
|
||||||
|
) {
|
||||||
|
@Test
|
||||||
|
@DisplayName("AI 캐릭터용 Member는 일반 로그인할 수 없다")
|
||||||
|
fun shouldRejectAiCharacterMemberLoginBeforeAuthentication() {
|
||||||
|
val member = memberRepository.save(
|
||||||
|
Member(
|
||||||
|
email = "ai-character-login@test.com",
|
||||||
|
password = "",
|
||||||
|
nickname = "AI 캐릭터 로그인",
|
||||||
|
role = MemberRole.CREATOR,
|
||||||
|
memberKind = MemberKind.AI_CHARACTER
|
||||||
|
)
|
||||||
|
)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.login(LoginRequest(email = member.email!!, password = "password"))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("common.error.bad_credentials", exception.messageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.common.domain
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class CreatorActivityTypeTest {
|
||||||
|
@Test
|
||||||
|
@DisplayName("활동 타입 enum code는 앱 다국어 처리를 위해 영문 값과 동일하게 유지한다")
|
||||||
|
fun shouldKeepCreatorActivityTypeCodeAsEnglishName() {
|
||||||
|
assertEquals("LIVE", CreatorActivityType.LIVE.code)
|
||||||
|
assertEquals("AUDIO", CreatorActivityType.AUDIO.code)
|
||||||
|
assertEquals("COMMUNITY", CreatorActivityType.COMMUNITY.code)
|
||||||
|
assertEquals("LIVE_REPLAY", CreatorActivityType.LIVE_REPLAY.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.application
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelActivity
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelAudioContent
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCommunityPost
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCreator
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelDonation
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalk
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalkSummary
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHome
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelLive
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSchedule
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSeries
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSns
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.dto.CreatorChannelHomeResponse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
class CreatorChannelHomeQueryServiceTest {
|
||||||
|
private val objectMapper = jacksonObjectMapper()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("크리에이터 채널 홈 모델은 홈 탭 전체 섹션을 담고 응답 DTO로 변환된다")
|
||||||
|
fun shouldConvertCreatorChannelHomeToResponse() {
|
||||||
|
val home = createHome()
|
||||||
|
|
||||||
|
val response = CreatorChannelHomeResponse.from(home)
|
||||||
|
|
||||||
|
assertEquals(home.creator.creatorId, response.creator.creatorId)
|
||||||
|
assertEquals(home.currentLive?.liveId, response.currentLive?.liveId)
|
||||||
|
assertEquals(home.latestAudioContent?.audioContentId, response.latestAudioContent?.audioContentId)
|
||||||
|
assertEquals(home.channelDonations.first().donationId, response.channelDonations.first().donationId)
|
||||||
|
assertEquals(home.notices.first().postId, response.notices.first().postId)
|
||||||
|
assertEquals(home.schedules.first().targetId, response.schedules.first().targetId)
|
||||||
|
assertEquals(home.audioContents.first().audioContentId, response.audioContents.first().audioContentId)
|
||||||
|
assertEquals(home.series.first().seriesId, response.series.first().seriesId)
|
||||||
|
assertEquals(home.communities.first().postId, response.communities.first().postId)
|
||||||
|
assertEquals(home.fanTalk.latestFanTalk?.fanTalkId, response.fanTalk.latestFanTalk?.fanTalkId)
|
||||||
|
assertEquals(home.introduce, response.introduce)
|
||||||
|
assertEquals(home.activity.liveCount, response.activity.liveCount)
|
||||||
|
assertEquals(home.sns.instagramUrl, response.sns.instagramUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("응답 DTO는 날짜/시간을 UTC ISO-8601 문자열로 변환한다")
|
||||||
|
fun shouldConvertDateTimeFieldsToUtcIsoString() {
|
||||||
|
val response = CreatorChannelHomeResponse.from(createHome())
|
||||||
|
|
||||||
|
assertEquals("2026-06-12T01:00:00Z", response.currentLive?.beginDateTimeUtc)
|
||||||
|
assertEquals("2026-06-12T02:00:00Z", response.channelDonations.first().createdAtUtc)
|
||||||
|
assertEquals("2026-06-12T03:00:00Z", response.schedules.first().scheduledAtUtc)
|
||||||
|
assertEquals("2026-06-12T04:00:00Z", response.notices.first().dateUtc)
|
||||||
|
assertEquals("2026-06-12T05:00:00Z", response.fanTalk.latestFanTalk?.createdAtUtc)
|
||||||
|
assertEquals("2026-06-12T06:00:00Z", response.activity.debutDateUtc)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("응답 DTO는 Boolean 공개 계약 필드를 보존한다")
|
||||||
|
fun shouldPreserveBooleanApiFields() {
|
||||||
|
val response = CreatorChannelHomeResponse.from(createHome())
|
||||||
|
|
||||||
|
assertTrue(response.creator.isAiChatAvailable)
|
||||||
|
assertFalse(response.creator.isDmAvailable)
|
||||||
|
assertTrue(response.creator.isFollow)
|
||||||
|
assertFalse(response.creator.isNotify)
|
||||||
|
assertTrue(response.currentLive?.isAdult == true)
|
||||||
|
assertTrue(response.latestAudioContent?.isPointAvailable == true)
|
||||||
|
assertTrue(response.latestAudioContent?.isFirstContent == true)
|
||||||
|
assertTrue(response.latestAudioContent?.isAdult == true)
|
||||||
|
assertTrue(response.series.first().isOriginal)
|
||||||
|
assertNotNull(response.latestAudioContent?.isOriginalSeries)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("응답 DTO는 Boolean JSON 필드명을 is prefix로 유지한다")
|
||||||
|
fun shouldSerializeBooleanFieldsWithIsPrefix() {
|
||||||
|
val response = CreatorChannelHomeResponse.from(createHome())
|
||||||
|
|
||||||
|
val json = objectMapper.readTree(objectMapper.writeValueAsString(response))
|
||||||
|
|
||||||
|
assertTrue(json["creator"]["isAiChatAvailable"].asBoolean())
|
||||||
|
assertFalse(json["creator"].has("aiChatAvailable"))
|
||||||
|
assertFalse(json["creator"]["isDmAvailable"].asBoolean())
|
||||||
|
assertFalse(json["creator"].has("dmAvailable"))
|
||||||
|
assertTrue(json["latestAudioContent"]["isPointAvailable"].asBoolean())
|
||||||
|
assertFalse(json["latestAudioContent"].has("pointAvailable"))
|
||||||
|
assertTrue(json["latestAudioContent"]["isFirstContent"].asBoolean())
|
||||||
|
assertFalse(json["latestAudioContent"].has("firstContent"))
|
||||||
|
assertTrue(json["latestAudioContent"]["isAdult"].asBoolean())
|
||||||
|
assertFalse(json["latestAudioContent"].has("adult"))
|
||||||
|
assertTrue(json["series"][0]["isOriginal"].asBoolean())
|
||||||
|
assertFalse(json["series"][0].has("original"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createHome(): CreatorChannelHome {
|
||||||
|
val post = CreatorChannelCommunityPost(
|
||||||
|
postId = 301L,
|
||||||
|
creatorId = 1L,
|
||||||
|
creatorNickname = "creator",
|
||||||
|
creatorProfileUrl = "profile.png",
|
||||||
|
imageUrl = "image.png",
|
||||||
|
audioUrl = "audio.mp3",
|
||||||
|
content = "notice",
|
||||||
|
price = 10,
|
||||||
|
date = LocalDateTime.of(2026, 6, 12, 4, 0),
|
||||||
|
existOrdered = true,
|
||||||
|
likeCount = 2,
|
||||||
|
commentCount = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
return CreatorChannelHome(
|
||||||
|
creator = CreatorChannelCreator(
|
||||||
|
creatorId = 1L,
|
||||||
|
nickname = "creator",
|
||||||
|
profileImageUrl = "profile.png",
|
||||||
|
followerCount = 100,
|
||||||
|
isAiChatAvailable = true,
|
||||||
|
isDmAvailable = false,
|
||||||
|
isFollow = true,
|
||||||
|
isNotify = false
|
||||||
|
),
|
||||||
|
currentLive = CreatorChannelLive(
|
||||||
|
liveId = 101L,
|
||||||
|
title = "live",
|
||||||
|
coverImageUrl = "live.png",
|
||||||
|
beginDateTime = LocalDateTime.of(2026, 6, 12, 1, 0),
|
||||||
|
price = 20,
|
||||||
|
isAdult = true
|
||||||
|
),
|
||||||
|
latestAudioContent = CreatorChannelAudioContent(
|
||||||
|
audioContentId = 201L,
|
||||||
|
title = "audio",
|
||||||
|
duration = "00:10:00",
|
||||||
|
imageUrl = "audio.png",
|
||||||
|
price = 30,
|
||||||
|
isAdult = true,
|
||||||
|
isPointAvailable = true,
|
||||||
|
isFirstContent = true,
|
||||||
|
publishedAt = LocalDateTime.of(2026, 6, 11, 1, 0),
|
||||||
|
seriesName = "series",
|
||||||
|
isOriginalSeries = true
|
||||||
|
),
|
||||||
|
channelDonations = listOf(
|
||||||
|
CreatorChannelDonation(
|
||||||
|
donationId = 401L,
|
||||||
|
memberId = 2L,
|
||||||
|
nickname = "fan",
|
||||||
|
profileImageUrl = "fan.png",
|
||||||
|
can = 50,
|
||||||
|
isSecret = false,
|
||||||
|
message = "thanks",
|
||||||
|
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
notices = listOf(post),
|
||||||
|
schedules = listOf(
|
||||||
|
CreatorChannelSchedule(
|
||||||
|
scheduledAt = LocalDateTime.of(2026, 6, 12, 3, 0),
|
||||||
|
title = "schedule",
|
||||||
|
type = CreatorActivityType.LIVE,
|
||||||
|
targetId = 501L,
|
||||||
|
isAdult = false
|
||||||
|
)
|
||||||
|
),
|
||||||
|
audioContents = listOf(
|
||||||
|
CreatorChannelAudioContent(
|
||||||
|
audioContentId = 202L,
|
||||||
|
title = "audio2",
|
||||||
|
duration = null,
|
||||||
|
imageUrl = null,
|
||||||
|
price = 0,
|
||||||
|
isAdult = false,
|
||||||
|
isPointAvailable = false,
|
||||||
|
isFirstContent = false,
|
||||||
|
publishedAt = LocalDateTime.of(2026, 6, 10, 1, 0),
|
||||||
|
seriesName = null,
|
||||||
|
isOriginalSeries = null
|
||||||
|
)
|
||||||
|
),
|
||||||
|
series = listOf(
|
||||||
|
CreatorChannelSeries(
|
||||||
|
seriesId = 601L,
|
||||||
|
title = "series",
|
||||||
|
coverImageUrl = "series.png",
|
||||||
|
publishedDaysOfWeek = "MON",
|
||||||
|
isComplete = false,
|
||||||
|
numberOfContent = 3,
|
||||||
|
isNew = true,
|
||||||
|
isPopular = false,
|
||||||
|
isOriginal = true
|
||||||
|
)
|
||||||
|
),
|
||||||
|
communities = listOf(post.copy(postId = 302L, content = "community")),
|
||||||
|
fanTalk = CreatorChannelFanTalkSummary(
|
||||||
|
totalCount = 1,
|
||||||
|
latestFanTalk = CreatorChannelFanTalk(
|
||||||
|
fanTalkId = 701L,
|
||||||
|
memberId = 2L,
|
||||||
|
nickname = "fan",
|
||||||
|
profileImageUrl = "fan.png",
|
||||||
|
content = "hello",
|
||||||
|
languageCode = "ko",
|
||||||
|
createdAt = LocalDateTime.of(2026, 6, 12, 5, 0)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
introduce = "introduce",
|
||||||
|
activity = CreatorChannelActivity(
|
||||||
|
debutDate = LocalDateTime.of(2026, 6, 12, 6, 0),
|
||||||
|
dDay = "D+1",
|
||||||
|
liveCount = 10,
|
||||||
|
liveDurationHours = 20,
|
||||||
|
liveContributorCount = 30,
|
||||||
|
audioContentCount = 40,
|
||||||
|
seriesCount = 50
|
||||||
|
),
|
||||||
|
sns = CreatorChannelSns(
|
||||||
|
instagramUrl = "instagram",
|
||||||
|
fancimmUrl = "fancimm",
|
||||||
|
xUrl = "x",
|
||||||
|
youtubeUrl = "youtube",
|
||||||
|
kakaoOpenChatUrl = "kakao"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.domain
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
class CreatorChannelHomeQueryPolicyTest {
|
||||||
|
private val policy = CreatorChannelHomeQueryPolicy()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("스케줄은 예약 시각 오름차순 최대 3개만 남긴다")
|
||||||
|
fun shouldLimitSchedulesToEarliestThree() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 12, 9, 0)
|
||||||
|
val schedules = listOf(
|
||||||
|
schedule(targetId = 4L, scheduledAt = LocalDateTime.of(2026, 6, 12, 13, 0)),
|
||||||
|
schedule(targetId = 2L, scheduledAt = LocalDateTime.of(2026, 6, 12, 11, 0)),
|
||||||
|
schedule(targetId = 1L, scheduledAt = LocalDateTime.of(2026, 6, 12, 10, 0)),
|
||||||
|
schedule(targetId = 3L, scheduledAt = LocalDateTime.of(2026, 6, 12, 12, 0))
|
||||||
|
)
|
||||||
|
|
||||||
|
val limited = policy.limitSchedules(schedules, now, canViewAdultContent = true)
|
||||||
|
|
||||||
|
assertEquals(listOf(1L, 2L, 3L), limited.map { it.targetId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("스케줄은 현재 시각 이후 예약만 남긴다")
|
||||||
|
fun shouldOnlyKeepSchedulesAfterNow() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 12, 10, 0)
|
||||||
|
val schedules = listOf(
|
||||||
|
schedule(targetId = 1L, scheduledAt = now.minusMinutes(1)),
|
||||||
|
schedule(targetId = 2L, scheduledAt = now),
|
||||||
|
schedule(targetId = 3L, scheduledAt = now.plusMinutes(1))
|
||||||
|
)
|
||||||
|
|
||||||
|
val limited = policy.limitSchedules(schedules, now, canViewAdultContent = true)
|
||||||
|
|
||||||
|
assertEquals(listOf(3L), limited.map { it.targetId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("같은 예약 시각이면 라이브가 오디오보다 먼저 온다")
|
||||||
|
fun shouldSortLiveBeforeAudioWhenScheduledAtIsSame() {
|
||||||
|
val scheduledAt = LocalDateTime.of(2026, 6, 12, 10, 0)
|
||||||
|
val schedules = listOf(
|
||||||
|
schedule(targetId = 2L, scheduledAt = scheduledAt, type = CreatorActivityType.AUDIO),
|
||||||
|
schedule(targetId = 1L, scheduledAt = scheduledAt, type = CreatorActivityType.LIVE)
|
||||||
|
)
|
||||||
|
|
||||||
|
val limited = policy.limitSchedules(schedules, scheduledAt.minusMinutes(1), canViewAdultContent = true)
|
||||||
|
|
||||||
|
assertEquals(listOf(CreatorActivityType.LIVE, CreatorActivityType.AUDIO), limited.map { it.type })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("조회자의 성인 노출 정책이 false이면 성인 스케줄을 제외한다")
|
||||||
|
fun shouldExcludeAdultSchedulesWhenViewerCannotViewAdultContent() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 12, 9, 0)
|
||||||
|
val schedules = listOf(
|
||||||
|
schedule(targetId = 1L, scheduledAt = now.plusMinutes(1), isAdult = true),
|
||||||
|
schedule(targetId = 2L, scheduledAt = now.plusMinutes(2), isAdult = false)
|
||||||
|
)
|
||||||
|
|
||||||
|
val limited = policy.limitSchedules(schedules, now, canViewAdultContent = false)
|
||||||
|
|
||||||
|
assertEquals(listOf(2L), limited.map { it.targetId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("오디오 목록에서는 latestAudioContentId와 같은 콘텐츠를 제외한다")
|
||||||
|
fun shouldExcludeLatestAudioContent() {
|
||||||
|
val audioContents = listOf(audioContent(1L), audioContent(2L), audioContent(3L))
|
||||||
|
|
||||||
|
val filtered = policy.excludeLatestAudioContent(audioContents, latestAudioContentId = 2L)
|
||||||
|
|
||||||
|
assertEquals(listOf(1L, 3L), filtered.map { it.audioContentId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("오디오 콘텐츠의 첫 공개 콘텐츠 여부는 공개 시각 오름차순, 동일 시각이면 id 오름차순으로 판정한다")
|
||||||
|
fun shouldMarkFirstAudioContentByPublishedAtAndId() {
|
||||||
|
val publishedAt = LocalDateTime.of(2026, 6, 12, 10, 0)
|
||||||
|
val audioContents = listOf(
|
||||||
|
audioContent(3L, publishedAt = publishedAt.plusDays(1)),
|
||||||
|
audioContent(2L, publishedAt = publishedAt),
|
||||||
|
audioContent(1L, publishedAt = publishedAt)
|
||||||
|
)
|
||||||
|
|
||||||
|
val marked = policy.markFirstAudioContent(audioContents)
|
||||||
|
|
||||||
|
assertTrue(marked.first { it.audioContentId == 1L }.isFirstContent)
|
||||||
|
assertFalse(marked.first { it.audioContentId == 2L }.isFirstContent)
|
||||||
|
assertFalse(marked.first { it.audioContentId == 3L }.isFirstContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun schedule(
|
||||||
|
targetId: Long,
|
||||||
|
scheduledAt: LocalDateTime,
|
||||||
|
type: CreatorActivityType = CreatorActivityType.LIVE,
|
||||||
|
isAdult: Boolean = false
|
||||||
|
): CreatorChannelSchedule {
|
||||||
|
return CreatorChannelSchedule(
|
||||||
|
scheduledAt = scheduledAt,
|
||||||
|
title = "schedule-$targetId",
|
||||||
|
type = type,
|
||||||
|
targetId = targetId,
|
||||||
|
isAdult = isAdult
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun audioContent(
|
||||||
|
audioContentId: Long,
|
||||||
|
publishedAt: LocalDateTime = LocalDateTime.of(2026, 6, 12, 10, 0)
|
||||||
|
): CreatorChannelAudioContent {
|
||||||
|
return CreatorChannelAudioContent(
|
||||||
|
audioContentId = audioContentId,
|
||||||
|
title = "audio-$audioContentId",
|
||||||
|
duration = null,
|
||||||
|
imageUrl = null,
|
||||||
|
price = 0,
|
||||||
|
isAdult = false,
|
||||||
|
isPointAvailable = false,
|
||||||
|
isFirstContent = false,
|
||||||
|
publishedAt = publishedAt,
|
||||||
|
seriesName = null,
|
||||||
|
isOriginalSeries = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,11 +31,12 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCo
|
|||||||
import kr.co.vividnext.sodalive.i18n.Lang
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberKind
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
import kr.co.vividnext.sodalive.member.block.BlockMember
|
import kr.co.vividnext.sodalive.member.block.BlockMember
|
||||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicy
|
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicy
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedActivityType
|
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
|
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeCheerCreatorRecommendationRecord
|
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeCheerCreatorRecommendationRecord
|
||||||
@@ -361,14 +362,14 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
listOf(liveCreator.nickname, audioCreator.nickname, replayCreator.nickname, communityCreator.nickname),
|
listOf(liveCreator.nickname, audioCreator.nickname, replayCreator.nickname, communityCreator.nickname),
|
||||||
creators.map { it.creatorNickname }
|
creators.map { it.creatorNickname }
|
||||||
)
|
)
|
||||||
assertEquals(RecommendedActivityType.LIVE, byCreatorNickname[liveCreator.nickname]!!.activityType)
|
assertEquals(CreatorActivityType.LIVE, byCreatorNickname[liveCreator.nickname]!!.activityType)
|
||||||
assertEquals(null, byCreatorNickname[liveCreator.nickname]!!.targetId)
|
assertEquals(null, byCreatorNickname[liveCreator.nickname]!!.targetId)
|
||||||
assertEquals(baseAt, byCreatorNickname[liveCreator.nickname]!!.activityAt)
|
assertEquals(baseAt, byCreatorNickname[liveCreator.nickname]!!.activityAt)
|
||||||
assertEquals(RecommendedActivityType.AUDIO, byCreatorNickname[audioCreator.nickname]!!.activityType)
|
assertEquals(CreatorActivityType.AUDIO, byCreatorNickname[audioCreator.nickname]!!.activityType)
|
||||||
assertEquals(audio.id, byCreatorNickname[audioCreator.nickname]!!.targetId)
|
assertEquals(audio.id, byCreatorNickname[audioCreator.nickname]!!.targetId)
|
||||||
assertEquals(RecommendedActivityType.LIVE_REPLAY, byCreatorNickname[replayCreator.nickname]!!.activityType)
|
assertEquals(CreatorActivityType.LIVE_REPLAY, byCreatorNickname[replayCreator.nickname]!!.activityType)
|
||||||
assertEquals(replay.id, byCreatorNickname[replayCreator.nickname]!!.targetId)
|
assertEquals(replay.id, byCreatorNickname[replayCreator.nickname]!!.targetId)
|
||||||
assertEquals(RecommendedActivityType.COMMUNITY, byCreatorNickname[communityCreator.nickname]!!.activityType)
|
assertEquals(CreatorActivityType.COMMUNITY, byCreatorNickname[communityCreator.nickname]!!.activityType)
|
||||||
assertEquals(communityCreator.id, byCreatorNickname[communityCreator.nickname]!!.targetId)
|
assertEquals(communityCreator.id, byCreatorNickname[communityCreator.nickname]!!.targetId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,10 +405,10 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
assertEquals(null, visibleCreators[1].targetId)
|
assertEquals(null, visibleCreators[1].targetId)
|
||||||
assertEquals(adultAudio.id, visibleCreators[2].targetId)
|
assertEquals(adultAudio.id, visibleCreators[2].targetId)
|
||||||
assertEquals(adultCommunityCreator.id, visibleCreators[3].targetId)
|
assertEquals(adultCommunityCreator.id, visibleCreators[3].targetId)
|
||||||
assertEquals(RecommendedActivityType.LIVE, visibleCreators[0].activityType)
|
assertEquals(CreatorActivityType.LIVE, visibleCreators[0].activityType)
|
||||||
assertEquals(RecommendedActivityType.LIVE, visibleCreators[1].activityType)
|
assertEquals(CreatorActivityType.LIVE, visibleCreators[1].activityType)
|
||||||
assertEquals(RecommendedActivityType.AUDIO, visibleCreators[2].activityType)
|
assertEquals(CreatorActivityType.AUDIO, visibleCreators[2].activityType)
|
||||||
assertEquals(RecommendedActivityType.COMMUNITY, visibleCreators[3].activityType)
|
assertEquals(CreatorActivityType.COMMUNITY, visibleCreators[3].activityType)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -429,7 +430,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
val creators = repository.findRecentlyActiveCreators(limit = 10, memberId = viewer.id)
|
val creators = repository.findRecentlyActiveCreators(limit = 10, memberId = viewer.id)
|
||||||
|
|
||||||
assertEquals(listOf(visibleCreator.nickname), creators.map { it.creatorNickname })
|
assertEquals(listOf(visibleCreator.nickname), creators.map { it.creatorNickname })
|
||||||
assertEquals(RecommendedActivityType.COMMUNITY, creators.single().activityType)
|
assertEquals(CreatorActivityType.COMMUNITY, creators.single().activityType)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1793,6 +1794,17 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun saveCharacter(name: String, isActive: Boolean, originalWork: OriginalWork? = null): ChatCharacter {
|
private fun saveCharacter(name: String, isActive: Boolean, originalWork: OriginalWork? = null): ChatCharacter {
|
||||||
|
val creatorMember = Member(
|
||||||
|
email = null,
|
||||||
|
password = "",
|
||||||
|
nickname = name,
|
||||||
|
role = MemberRole.CREATOR,
|
||||||
|
memberKind = MemberKind.AI_CHARACTER,
|
||||||
|
isActive = isActive
|
||||||
|
)
|
||||||
|
creatorMember.introduce = "description"
|
||||||
|
entityManager.persist(creatorMember)
|
||||||
|
|
||||||
val character = ChatCharacter(
|
val character = ChatCharacter(
|
||||||
characterUUID = "$name-uuid",
|
characterUUID = "$name-uuid",
|
||||||
name = name,
|
name = name,
|
||||||
@@ -1801,6 +1813,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
isActive = isActive
|
isActive = isActive
|
||||||
)
|
)
|
||||||
character.originalWork = originalWork
|
character.originalWork = originalWork
|
||||||
|
character.creatorMember = creatorMember
|
||||||
entityManager.persist(character)
|
entityManager.persist(character)
|
||||||
return character
|
return character
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.recommendation.application
|
package kr.co.vividnext.sodalive.v2.recommendation.application
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedActivityType
|
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
|
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeBannerRecommendationRecord
|
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeBannerRecommendationRecord
|
||||||
@@ -30,7 +30,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
fun shouldClassifyLiveReplayThemeContentAsLiveReplay() {
|
fun shouldClassifyLiveReplayThemeContentAsLiveReplay() {
|
||||||
val activityType = service.resolveAudioContentActivityType(theme = "다시듣기")
|
val activityType = service.resolveAudioContentActivityType(theme = "다시듣기")
|
||||||
|
|
||||||
assertEquals(RecommendedActivityType.LIVE_REPLAY, activityType)
|
assertEquals(CreatorActivityType.LIVE_REPLAY, activityType)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -38,16 +38,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
fun shouldClassifyNonLiveReplayThemeContentAsAudio() {
|
fun shouldClassifyNonLiveReplayThemeContentAsAudio() {
|
||||||
val activityType = service.resolveAudioContentActivityType(theme = "수면")
|
val activityType = service.resolveAudioContentActivityType(theme = "수면")
|
||||||
|
|
||||||
assertEquals(RecommendedActivityType.AUDIO, activityType)
|
assertEquals(CreatorActivityType.AUDIO, activityType)
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("활동 타입 enum code는 앱 다국어 처리를 위해 영문 값과 동일하게 유지한다")
|
|
||||||
fun shouldKeepRecommendedActivityTypeCodeAsEnglishName() {
|
|
||||||
assertEquals("LIVE", RecommendedActivityType.LIVE.code)
|
|
||||||
assertEquals("AUDIO", RecommendedActivityType.AUDIO.code)
|
|
||||||
assertEquals("COMMUNITY", RecommendedActivityType.COMMUNITY.code)
|
|
||||||
assertEquals("LIVE_REPLAY", RecommendedActivityType.LIVE_REPLAY.code)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -653,7 +644,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
RecentlyActiveCreatorRecord(
|
RecentlyActiveCreatorRecord(
|
||||||
creatorNickname = "creator",
|
creatorNickname = "creator",
|
||||||
creatorProfileImage = "profile.png",
|
creatorProfileImage = "profile.png",
|
||||||
activityType = RecommendedActivityType.LIVE,
|
activityType = CreatorActivityType.LIVE,
|
||||||
activityAt = LocalDateTime.of(2026, 5, 31, 10, 0),
|
activityAt = LocalDateTime.of(2026, 5, 31, 10, 0),
|
||||||
targetId = null
|
targetId = null
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.usercreatorchat
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberKind
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorTextMessageRequest
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatMessageRepository
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatParticipantRepository
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatRoomRepository
|
||||||
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService
|
||||||
|
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.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@Transactional
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
class UserCreatorChatServiceIntegrationTest @Autowired constructor(
|
||||||
|
private val service: UserCreatorChatService,
|
||||||
|
private val memberRepository: MemberRepository,
|
||||||
|
private val roomRepository: UserCreatorChatRoomRepository,
|
||||||
|
private val participantRepository: UserCreatorChatParticipantRepository,
|
||||||
|
private val messageRepository: UserCreatorChatMessageRepository,
|
||||||
|
private val entityManager: EntityManager
|
||||||
|
) {
|
||||||
|
@Test
|
||||||
|
@DisplayName("AI 캐릭터용 Member와는 유저-크리에이터 DM 방을 생성할 수 없다")
|
||||||
|
fun shouldRejectCreateRoomWhenCreatorIsAiCharacterMember() {
|
||||||
|
val user = memberRepository.save(Member(email = "dm-user@test.com", password = "pw", nickname = "user"))
|
||||||
|
val creator = memberRepository.save(
|
||||||
|
Member(
|
||||||
|
email = null,
|
||||||
|
password = "",
|
||||||
|
nickname = "ai-character",
|
||||||
|
role = MemberRole.CREATOR,
|
||||||
|
memberKind = MemberKind.AI_CHARACTER
|
||||||
|
)
|
||||||
|
)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.createOrGetRoom(user, creator.id!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("message.error.recipient_not_found", exception.messageKey)
|
||||||
|
assertEquals(0, roomRepository.findAll().size)
|
||||||
|
assertEquals(0, participantRepository.findAll().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("AI 캐릭터용 Member가 참여한 기존 DM 방에는 메시지를 보낼 수 없다")
|
||||||
|
fun shouldRejectSendTextMessageWhenOpponentIsAiCharacterMember() {
|
||||||
|
val user = memberRepository.save(Member(email = "dm-message-user@test.com", password = "pw", nickname = "user"))
|
||||||
|
val creator = memberRepository.save(
|
||||||
|
Member(
|
||||||
|
email = null,
|
||||||
|
password = "",
|
||||||
|
nickname = "ai-character-message",
|
||||||
|
role = MemberRole.CREATOR,
|
||||||
|
memberKind = MemberKind.AI_CHARACTER
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val room = roomRepository.save(UserCreatorChatRoom())
|
||||||
|
participantRepository.save(UserCreatorChatParticipant(room, user))
|
||||||
|
participantRepository.save(UserCreatorChatParticipant(room, creator))
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.sendTextMessage(user, room.id!!, SendUserCreatorTextMessageRequest("hello"))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("message.error.recipient_not_found", exception.messageKey)
|
||||||
|
assertEquals(0, messageRepository.findAll().size)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user