Compare commits

..

19 Commits

Author SHA1 Message Date
abc3e8e9aa feat(creator): 스케줄 성인 노출 정책을 적용한다 2026-06-12 17:23:37 +09:00
6fa7044220 docs(recommendation): 스케줄 성인 노출 정책을 보강한다 2026-06-12 17:23:16 +09:00
7be8a8c917 docs(recommendation): 크리에이터 채널 홈 계획을 갱신한다 2026-06-12 17:07:14 +09:00
530e38c1ad feat(creator): 채널 홈 조회 정책을 추가한다 2026-06-12 17:06:49 +09:00
f2c2473a47 feat(creator): 채널 홈 응답 모델을 추가한다 2026-06-12 17:06:42 +09:00
b85c61bd0b refactor(recommendation): 홈 추천 활동 타입 참조를 교체한다 2026-06-12 16:37:11 +09:00
9305dc600d feat(common): 크리에이터 활동 타입을 추가한다 2026-06-12 16:36:56 +09:00
0afab91d72 docs(recommendation): 크리에이터 채널 홈 계획을 추가한다 2026-06-12 16:36:51 +09:00
0c5234c09a docs(recommendation): 크리에이터 채널 홈 PRD를 추가한다 2026-06-12 16:36:43 +09:00
082d8457eb docs(aicharacter): 크리에이터 연결 DDL을 보강한다 2026-06-12 13:57:52 +09:00
a0f0d82b63 docs(aicharacter): 크리에이터 연결 작업 기록을 갱신한다 2026-06-12 11:40:26 +09:00
5c132c984d feat(usercreatorchat): AI 캐릭터 회원 DM을 차단한다 2026-06-12 11:40:10 +09:00
f6a07faef2 feat(member): AI 캐릭터 회원 로그인을 차단한다 2026-06-12 11:39:57 +09:00
5cf1f7d909 test(aicharacter): 크리에이터 회원 연결 검증을 보강한다 2026-06-12 11:39:50 +09:00
268ed751c3 docs(aicharacter): 크리에이터 연결 작업 기록을 갱신한다 2026-06-12 10:57:51 +09:00
ff9053d54d feat(aicharacter): 관리자 캐릭터 생성 동기화를 추가한다 2026-06-12 10:57:16 +09:00
74414937cf feat(aicharacter): 크리에이터 회원 연결을 추가한다 2026-06-12 10:56:55 +09:00
72e6efe3e6 feat(member): AI 캐릭터 회원 종류를 추가한다 2026-06-12 00:12:36 +09:00
685209d47d docs(aicharacter): 크리에이터 연결 계획을 추가한다 2026-06-12 00:12:17 +09:00
33 changed files with 3120 additions and 41 deletions

View 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;

View 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`.

View 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 정책을 별도로 정해야 한다.

View 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` 통과.

View 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
- 없음.

View File

@@ -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.aws.s3.S3Uploader
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.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
@@ -45,6 +46,7 @@ class AdminChatCharacterController(
private val adminService: AdminChatCharacterService,
private val s3Uploader: S3Uploader,
private val originalWorkService: AdminOriginalWorkService,
private val creatorMemberService: ChatCharacterCreatorMemberService,
private val applicationEventPublisher: ApplicationEventPublisher,
@Value("\${weraser.api-key}")
@@ -166,6 +168,7 @@ class AdminChatCharacterController(
)
chatCharacter.imagePath = imagePath
service.saveChatCharacter(chatCharacter)
creatorMemberService.syncAiCharacterCreatorMemberDisplayFields(chatCharacter)
// 4. 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
if (request.originalWorkId != null) {

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.chat.character
import kr.co.vividnext.sodalive.chat.original.OriginalWork
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.member.Member
import javax.persistence.CascadeType
import javax.persistence.Column
import javax.persistence.Entity
@@ -11,6 +12,7 @@ import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
import javax.persistence.OneToMany
import javax.persistence.OneToOne
@Entity
class ChatCharacter(
@@ -75,6 +77,10 @@ class ChatCharacter(
) : BaseEntity() {
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)
var memories: MutableList<ChatCharacterMemory> = mutableListOf()

View File

@@ -99,4 +99,6 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
fun findRandomActiveExcluding(@Param("excludeIds") excludeIds: List<Long>, pageable: Pageable): List<ChatCharacter>
fun findByIdInAndIsActiveTrue(ids: List<Long>): List<ChatCharacter>
fun findByCreatorMemberId(creatorMemberId: Long): ChatCharacter?
fun existsByCreatorMemberId(creatorMemberId: Long): Boolean
}

View File

@@ -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
}
}

View File

@@ -36,6 +36,7 @@ class ChatCharacterService(
private val goalRepository: ChatCharacterGoalRepository,
private val popularCharacterQuery: PopularCharacterQuery,
private val imageRepository: CharacterImageRepository,
private val creatorMemberService: ChatCharacterCreatorMemberService,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
@@ -616,6 +617,7 @@ class ChatCharacterService(
addHobbiesToCharacter(chatCharacter, hobbies)
addGoalsToCharacter(chatCharacter, goals)
creatorMemberService.ensureAiCharacterCreatorMember(chatCharacter)
return saveChatCharacter(chatCharacter)
}
@@ -721,7 +723,9 @@ class ChatCharacterService(
val randomSuffix = "_" + java.util.UUID.randomUUID().toString().replace("-", "")
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)
}
return saveChatCharacter(chatCharacter)
val savedChatCharacter = saveChatCharacter(chatCharacter)
creatorMemberService.syncAiCharacterCreatorMemberDisplayFields(savedChatCharacter)
return savedChatCharacter
}
}

View File

@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.fcm.PushTokenService
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
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.MemberRole
import kr.co.vividnext.sodalive.member.login.LoginRequest
@@ -70,6 +71,10 @@ class CreatorAdminMemberService(
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) {
throw SodaException(messageKey = "common.error.bad_credentials")
}

View File

@@ -39,6 +39,9 @@ data class Member(
@Enumerated(value = EnumType.STRING)
var role: MemberRole = MemberRole.USER,
@Enumerated(value = EnumType.STRING)
var memberKind: MemberKind = MemberKind.HUMAN,
@Column(nullable = true)
var activePid: String? = null,
@@ -180,6 +183,10 @@ enum class MemberRole {
ADMIN, BOT, USER, CREATOR, AGENT, CONTENT_MANAGER
}
enum class MemberKind {
HUMAN, AI_CHARACTER
}
enum class MemberProvider {
EMAIL, KAKAO, GOOGLE, APPLE, LINE
}

View File

@@ -339,6 +339,10 @@ class MemberService(
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) {
val provider = resolveProviderLabel(member.provider)
throw SodaException(message = formatMessage("member.validation.email_registered_with_provider", provider))

View File

@@ -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")
}

View File

@@ -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
)

View File

@@ -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
}
}

View File

@@ -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()
}

View File

@@ -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.member
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.RecommendedActivityType
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.HomeBannerRecommendationRecord
@@ -197,7 +197,7 @@ class DefaultHomeRecommendationQueryRepository(
RecentlyActiveCreatorRecord(
creatorNickname = row[0] 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]),
targetId = (row[4] as Number?)?.toLong()
)

View File

@@ -1,6 +1,6 @@
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.port.out.HomeAiCharacterRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeBannerRecommendationRecord
@@ -150,11 +150,11 @@ class HomeRecommendationQueryService(
return selectedGroups.take(genreLimit)
}
fun resolveAudioContentActivityType(theme: String): RecommendedActivityType {
fun resolveAudioContentActivityType(theme: String): CreatorActivityType {
return if (theme == LIVE_REPLAY_THEME) {
RecommendedActivityType.LIVE_REPLAY
CreatorActivityType.LIVE_REPLAY
} else {
RecommendedActivityType.AUDIO
CreatorActivityType.AUDIO
}
}

View File

@@ -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")
}

View File

@@ -1,6 +1,6 @@
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
interface HomeRecommendationQueryPort {
@@ -97,7 +97,7 @@ data class HomeBannerRecommendationRecord(
data class RecentlyActiveCreatorRecord(
val creatorNickname: String,
val creatorProfileImage: String?,
val activityType: RecommendedActivityType,
val activityType: CreatorActivityType,
val activityAt: LocalDateTime,
val targetId: Long?
)

View File

@@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.utils.generateFileName
@@ -209,6 +210,9 @@ class UserCreatorChatService(
private fun validateRecipient(sender: Member, recipient: Member) {
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 (blockMemberRepository.isBlocked(blockedMemberId = sender.id!!, memberId = recipient.id!!)) {
throw SodaException(messageKey = "message.error.blocked_by_recipient")

View File

@@ -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.original.service.AdminOriginalWorkService
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 org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@@ -15,6 +16,7 @@ class AdminChatCharacterControllerTest {
adminService = Mockito.mock(AdminChatCharacterService::class.java),
s3Uploader = Mockito.mock(S3Uploader::class.java),
originalWorkService = Mockito.mock(AdminOriginalWorkService::class.java),
creatorMemberService = Mockito.mock(ChatCharacterCreatorMemberService::class.java),
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java),
apiKey = "test-api-key",
apiUrl = "https://example.com",

View File

@@ -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
)
}
}

View File

@@ -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 }
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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"
)
)
}
}

View File

@@ -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
)
}
}

View File

@@ -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.live.room.LiveRoom
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.block.BlockMember
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.RecommendedActivityType
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.HomeCheerCreatorRecommendationRecord
@@ -361,14 +362,14 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
listOf(liveCreator.nickname, audioCreator.nickname, replayCreator.nickname, communityCreator.nickname),
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(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(RecommendedActivityType.LIVE_REPLAY, byCreatorNickname[replayCreator.nickname]!!.activityType)
assertEquals(CreatorActivityType.LIVE_REPLAY, byCreatorNickname[replayCreator.nickname]!!.activityType)
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)
}
@@ -404,10 +405,10 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(null, visibleCreators[1].targetId)
assertEquals(adultAudio.id, visibleCreators[2].targetId)
assertEquals(adultCommunityCreator.id, visibleCreators[3].targetId)
assertEquals(RecommendedActivityType.LIVE, visibleCreators[0].activityType)
assertEquals(RecommendedActivityType.LIVE, visibleCreators[1].activityType)
assertEquals(RecommendedActivityType.AUDIO, visibleCreators[2].activityType)
assertEquals(RecommendedActivityType.COMMUNITY, visibleCreators[3].activityType)
assertEquals(CreatorActivityType.LIVE, visibleCreators[0].activityType)
assertEquals(CreatorActivityType.LIVE, visibleCreators[1].activityType)
assertEquals(CreatorActivityType.AUDIO, visibleCreators[2].activityType)
assertEquals(CreatorActivityType.COMMUNITY, visibleCreators[3].activityType)
}
@Test
@@ -429,7 +430,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val creators = repository.findRecentlyActiveCreators(limit = 10, memberId = viewer.id)
assertEquals(listOf(visibleCreator.nickname), creators.map { it.creatorNickname })
assertEquals(RecommendedActivityType.COMMUNITY, creators.single().activityType)
assertEquals(CreatorActivityType.COMMUNITY, creators.single().activityType)
}
@Test
@@ -1793,6 +1794,17 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
}
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(
characterUUID = "$name-uuid",
name = name,
@@ -1801,6 +1813,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
isActive = isActive
)
character.originalWork = originalWork
character.creatorMember = creatorMember
entityManager.persist(character)
return character
}

View File

@@ -1,6 +1,6 @@
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.port.out.HomeAiCharacterRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeBannerRecommendationRecord
@@ -30,7 +30,7 @@ class HomeRecommendationQueryServiceTest {
fun shouldClassifyLiveReplayThemeContentAsLiveReplay() {
val activityType = service.resolveAudioContentActivityType(theme = "다시듣기")
assertEquals(RecommendedActivityType.LIVE_REPLAY, activityType)
assertEquals(CreatorActivityType.LIVE_REPLAY, activityType)
}
@Test
@@ -38,16 +38,7 @@ class HomeRecommendationQueryServiceTest {
fun shouldClassifyNonLiveReplayThemeContentAsAudio() {
val activityType = service.resolveAudioContentActivityType(theme = "수면")
assertEquals(RecommendedActivityType.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)
assertEquals(CreatorActivityType.AUDIO, activityType)
}
@Test
@@ -653,7 +644,7 @@ class HomeRecommendationQueryServiceTest {
RecentlyActiveCreatorRecord(
creatorNickname = "creator",
creatorProfileImage = "profile.png",
activityType = RecommendedActivityType.LIVE,
activityType = CreatorActivityType.LIVE,
activityAt = LocalDateTime.of(2026, 5, 31, 10, 0),
targetId = null
)

View File

@@ -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)
}
}