From 685209d47da5314e710b67ae9a5b3ca33b3a3c54 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 12 Jun 2026 00:12:17 +0900 Subject: [PATCH] =?UTF-8?q?docs(aicharacter):=20=ED=81=AC=EB=A6=AC?= =?UTF-8?q?=EC=97=90=EC=9D=B4=ED=84=B0=20=EC=97=B0=EA=B2=B0=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../alter-existing-tables.sql | 235 ++++++++++++++++ .../plan-task.md | 261 ++++++++++++++++++ .../prd.md | 208 ++++++++++++++ 3 files changed, 704 insertions(+) create mode 100644 docs/20260611_AI캐릭터_크리에이터기능_최소연결/alter-existing-tables.sql create mode 100644 docs/20260611_AI캐릭터_크리에이터기능_최소연결/plan-task.md create mode 100644 docs/20260611_AI캐릭터_크리에이터기능_최소연결/prd.md diff --git a/docs/20260611_AI캐릭터_크리에이터기능_최소연결/alter-existing-tables.sql b/docs/20260611_AI캐릭터_크리에이터기능_최소연결/alter-existing-tables.sql new file mode 100644 index 00000000..93617b41 --- /dev/null +++ b/docs/20260611_AI캐릭터_크리에이터기능_최소연결/alter-existing-tables.sql @@ -0,0 +1,235 @@ +-- 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; + +-- 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''', + '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; + +-- 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, + creator_member_id BIGINT NULL +) COMMENT 'chat_character와 backfill member.id 임시 매핑'; + +INSERT INTO tmp_chat_character_creator_member (chat_character_id) +SELECT c.id +FROM chat_character c +WHERE c.creator_member_id IS NULL; + +DROP PROCEDURE IF EXISTS backfill_chat_character_creator_member; + +DELIMITER // +CREATE PROCEDURE backfill_chat_character_creator_member() +BEGIN + DECLARE done BOOLEAN DEFAULT FALSE; + DECLARE v_chat_character_id BIGINT; + DECLARE v_name VARCHAR(255); + DECLARE v_description TEXT; + DECLARE v_image_path VARCHAR(255); + + DECLARE character_cursor CURSOR FOR + SELECT c.id, c.name, c.description, c.image_path + FROM chat_character c + INNER JOIN tmp_chat_character_creator_member m + ON m.chat_character_id = c.id + WHERE m.creator_member_id IS NULL + ORDER BY c.id; + + DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; + + OPEN character_cursor; + + read_loop: LOOP + FETCH character_cursor INTO v_chat_character_id, v_name, v_description, v_image_path; + + IF done THEN + LEAVE read_loop; + END IF; + + 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 + ) VALUES ( + NULL, + '', + v_name, + v_image_path, + 'EMAIL', + 'NONE', + 'CREATOR', + 'AI_CHARACTER', + TRUE, + 'CUMULATIVE', + TRUE, + 'web', + COALESCE(v_description, ''), + '', + '', + '', + '', + '', + '', + 0, + 0, + 0, + 0, + 0, + 0, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ); + + UPDATE tmp_chat_character_creator_member + SET creator_member_id = LAST_INSERT_ID() + WHERE chat_character_id = v_chat_character_id; + END LOOP; + + CLOSE character_cursor; +END // +DELIMITER ; + +CALL backfill_chat_character_creator_member(); +DROP PROCEDURE IF EXISTS backfill_chat_character_creator_member; + +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; + +-- 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; + +-- 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; diff --git a/docs/20260611_AI캐릭터_크리에이터기능_최소연결/plan-task.md b/docs/20260611_AI캐릭터_크리에이터기능_최소연결/plan-task.md new file mode 100644 index 00000000..6e994198 --- /dev/null +++ b/docs/20260611_AI캐릭터_크리에이터기능_최소연결/plan-task.md @@ -0,0 +1,261 @@ +# 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'`로 생성한다. + - MySQL에서 insert된 Member ID를 안전하게 매핑하기 위해 저장 프로시저 또는 임시 매핑 테이블을 사용한다. `email`을 임시 식별자로 사용하지 않는다. + - 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 연결 + +- [ ] **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: 관계 접근 컴파일 및 테스트 통과. + +- [ ] **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. + +- [ ] **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 차단 + +- [ ] **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. + +- [ ] **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. + +- [ ] **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/UserCreatorChatServiceTest.kt` + - RED: `shouldRejectCreateRoomWhenCreatorIsAiCharacterMember` 테스트를 추가한다. + - `memberRepository.findById(creatorId)`는 `role = CREATOR`, `memberKind = AI_CHARACTER`인 Member를 반환한다. + - `service.createOrGetRoom(user, creatorId)`는 예외를 던진다. + - `roomRepository.save`와 `participantRepository.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.UserCreatorChatServiceTest` + - Expected: 기존 DM 테스트와 신규 차단 테스트 PASS. + +--- + +### Phase 4: 회귀 검증 및 문서 정리 + +- [ ] **Task 4.1: 핵심 단위 테스트 실행** + - Files: 변경 없음 + - TDD 예외 사유: 구현 완료 후 회귀 검증 task다. + - 대체 검증 방법: 관련 단일 테스트를 모두 실행한다. + - Run: + ```bash + ./gradlew test \ + --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberServiceTest \ + --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest \ + --tests kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberServiceTest \ + --tests kr.co.vividnext.sodalive.member.MemberServiceTest + ``` + - Expected: PASS. + +- [ ] **Task 4.2: 정적 검증 및 전체 회귀** + - Files: 변경 없음 + - TDD 예외 사유: 전체 회귀 검증 task다. + - 대체 검증 방법: Gradle 테스트와 ktlint를 실행한다. + - Run: + ```bash + ./gradlew ktlintCheck + ./gradlew test + ``` + - Expected: 두 명령 모두 PASS. + +- [ ] **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`. diff --git a/docs/20260611_AI캐릭터_크리에이터기능_최소연결/prd.md b/docs/20260611_AI캐릭터_크리에이터기능_최소연결/prd.md new file mode 100644 index 00000000..1ebb9cf7 --- /dev/null +++ b/docs/20260611_AI캐릭터_크리에이터기능_최소연결/prd.md @@ -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 정책을 별도로 정해야 한다.