Files
sodalive-backend-spring-boot/docs/20260720_AI캐릭터_관리자기능/prd.md

262 KiB

PRD: AI 캐릭터 관리자 기능

1. Overview

AI 캐릭터는 ChatCharacterMember(role=CREATOR, memberKind=AI_CHARACTER)가 1:1로 연결되어 있지만, AI 캐릭터 자신은 로그인할 수 없다.

사람 관리자(MemberRole.ADMIN)가 AI 캐릭터와 원작을 관리하고, 하나의 AI 캐릭터를 선택한 뒤 해당 캐릭터의 크리에이터 기능을 대행할 수 있는 관리자 API를 제공한다. 관리자는 원작, 캐릭터, 콘텐츠, 댓글, 시리즈, 커뮤니티 게시글, 커뮤니티 댓글, FanTalk를 한 화면 흐름에서 관리한다.

이번 문서는 제품 범위, 인증·메뉴 정책, 신규 API 계약, v2 도메인 구현 경계 및 현재 크리에이터 기능과의 차이를 확정한다. 구현 계획과 구현은 이 문서의 범위가 아니다.

2. Problem

현재는 다음 문제가 있다.

  • AI 캐릭터 생성·조회·수정 API는 존재하지만 명시적인 삭제 API가 없다.
  • AI 캐릭터와 연결된 creatorMember는 크리에이터 기능을 수행할 수 있는 데이터 모델이지만 직접 로그인할 수 없다.
  • 기존 크리에이터 API는 인증된 ROLE_CREATOR 본인을 기준으로 동작하므로 ROLE_ADMIN이 그대로 호출할 수 없다.
  • 관리자 대행 작업을 위해 AI 캐릭터용 로그인이나 사칭 토큰을 발급하면 기존의 AI 캐릭터 로그인 금지 정책과 충돌하고 실제 작업 관리자를 추적하기 어렵다.
  • 사용자가 요구한 기능 목록에는 현재 크리에이터가 수행할 수 있는 일부 보조 기능과 라이브·정산 영역이 빠져 있다.
  • 기존 관리자 메뉴는 서버가 DB 데이터를 기준으로 내려주지만 role만으로 조회하므로, 동일한 ADMIN이 사용하는 전체 플랫폼 관리자와 AI 캐릭터 관리자 화면의 메뉴를 구분할 수 없다.
  • legacy 서비스에는 시리즈 순서 변경과 댓글·FanTalk 답글의 부모 귀속 검증처럼 그대로 계승하면 안 되는 규칙과 결손이 있다.
  • 원작 등록·수정·삭제·검색·캐릭터 배정 기능은 legacy /admin/chat/original/**에만 있고 v2에는 없다. 캐릭터 등록·수정은 originalWorkId를 받지만, 신규 클라이언트가 원작을 검색·선택할 v2 계약이 없어 legacy API 없이는 화면을 완성할 수 없다.
  • legacy 원작 배정은 존재하지 않거나 비활성인 캐릭터 ID를 조용히 무시하고, 다른 원작 소속 캐릭터도 경로 원작 확인 없이 해제한다. 원작 삭제는 연결 캐릭터를 남기지만 삭제 후 해제 API도 막아 그대로 이관할 수 없다.
  • v2가 legacy 비즈니스 서비스와 DTO에 직접 의존하면 새 API의 권한·오류·페이징 계약이 legacy 구현에 다시 결합된다.

3. Goals

  • 기존 관리자 계정으로만 AI 캐릭터 관리자 기능에 접근하게 한다.
  • 관리자가 AI 캐릭터를 조회·등록·수정·논리 삭제할 수 있게 한다.
  • 관리자가 원작을 검색·조회·등록·수정·논리 삭제하고, 원작과 AI 캐릭터의 연결을 조회·배정·해제할 수 있게 한다.
  • 관리자가 선택한 AI 캐릭터의 creatorMember 명의로 다음 작업을 수행하게 한다.
    • 콘텐츠 조회·등록·수정·논리 삭제
    • 콘텐츠 상단 고정·고정 해제
    • 콘텐츠 댓글과 답글 조회·등록·수정·논리 삭제
    • 선택한 AI 캐릭터의 콘텐츠에 작성된 다른 사용자의 댓글 논리 삭제
    • 콘텐츠 카테고리 조회·등록·수정·논리 삭제·순서 변경 및 콘텐츠 구성
    • 시리즈 조회·등록·수정·논리 삭제
    • 시리즈 콘텐츠 추가·제거·조회 및 시리즈 순서 변경
    • 커뮤니티 게시글 조회·등록·수정·논리 삭제
    • 커뮤니티 게시글 고정·고정 해제
    • 커뮤니티 게시글 댓글과 답글 조회·등록·수정·논리 삭제
    • 선택한 AI 캐릭터의 게시글에 작성된 다른 사용자의 댓글 논리 삭제
    • FanTalk 조회
    • FanTalk에 AI 캐릭터 답글 등록·수정·논리 삭제
    • 선택한 AI 캐릭터를 대상으로 작성된 FanTalk 원문 논리 삭제
    • 크리에이터 채널 공지 조회·등록·수정
    • 크리에이터 채널 SNS URL, 크리에이터 태그와 후원 랭킹 공개 설정 조회·수정
  • 신규 관리자 Endpoint의 Request와 Response 계약을 문서로 고정한다.
  • v2 내부에서 재사용할 도메인 기능과 v2 각 도메인에 새로 구현할 command 기능을 구분한다.
  • legacy 비즈니스 로직은 실행 의존성이 아니라 현행 동작을 파악하기 위한 참고 근거와 회귀 테스트 기준으로만 사용한다.
  • 현재 크리에이터 기능 중 요구 목록에서 빠진 기능을 식별하고 이번 범위 포함 여부를 확정한다.
  • v2 관리자 페이지 메뉴의 소유권과 제공 방식을 확정한다.

4. Non-Goals

  • AI 캐릭터 또는 연결된 creatorMember의 로그인 허용
  • AI 캐릭터를 가장하는 JWT, 세션 또는 토큰 발급
  • CONTENT_MANAGER, AGENT, CREATOR, 일반 사용자의 AI 캐릭터 관리 허용
  • 기존 공개 크리에이터 API의 인증·권한 계약 변경
  • 기존 /admin/chat/character/** Endpoint의 즉시 삭제 또는 호환성 파괴
  • 기존 /admin/chat/original/** Endpoint의 Method·Path·Request·성공 Response 호환성 파괴. 데이터 불변식을 지키기 위한 mutation 정책 수렴은 이번 범위에 포함한다.
  • 일반 사용자용 /api/chat/original/** 계약 변경
  • 캐릭터 및 연관 리소스의 물리 삭제
  • 삭제한 캐릭터의 복구 기능
  • 삭제한 원작의 복구 기능
  • 좋아요, 구매, 팔로우, 후원, DM 등 일반 사용자 행동의 관리자 대행
  • AI 캐릭터의 라이브 방송 운영, 예약 방송, 라이브 메뉴, 룰렛
  • AI 캐릭터의 정산·매출 조회 및 지급 처리
  • 시그니처 후원 설정
  • 관리자 메뉴 시스템 전체 개편 또는 신규 capability 시스템 도입
  • FanTalk 답글 전용 조회 Endpoint
  • 기존 AWS S3 Trigger 기반 오디오 가공 worker의 코드·스케줄·설정 변경
  • 기존 upload-complete 계약을 대체하는 신규 내부 callback Endpoint의 선제 구현
  • 기존 content 테이블의 컬럼 추가·backfill 또는 v2 전용 콘텐츠 테이블 생성
  • V1/V2 upload pipeline 분기와 v2 전용 예약 공개 scheduler 생성
  • 관리자 화면 UI 구현
  • 이미 적용된 27.8 Frontend baseline 프롬프트의 수정
  • 구현 계획 또는 서버 코드 구현

5. Target Users

Primary User

  • MemberRole.ADMIN을 가진 내부 운영 관리자

접근 불가 사용자

  • 비로그인 사용자
  • CONTENT_MANAGER
  • AGENT
  • 일반 CREATOR
  • USER
  • memberKind=AI_CHARACTER인 연결 크리에이터 Member

6. Current-State Investigation

6.1 AI 캐릭터와 크리에이터 Member

  • ChatCharacter.creatorMember는 non-null, unique 1:1 관계다.
  • AI 캐릭터 등록 시 연결 Member는 다음 값으로 생성된다.
    • role=CREATOR
    • memberKind=AI_CHARACTER
    • email=null
    • password=""
  • 캐릭터의 이름, 프로필 이미지, 소개는 연결 Member의 nickname, profileImage, introduce와 동기화된다.
  • 기존 크리에이터 공개 API의 creatorId는 계속 Member.id를 의미한다.
  • AI 캐릭터 Member는 일반 로그인과 크리에이터 관리자 로그인 모두에서 차단된다.

따라서 관리자 대행 API는 characterId로 대상 AI 캐릭터와 연결 creatorMemberId를 해석하고, 이를 v2 각 도메인 use case의 명시적인 작업 대상 ID로 전달해야 한다.

6.2 현재 관리자 인증

현재 관리자 로그인은 이미 존재한다.

POST /admin/member/login
Content-Type: application/json

ADMINCONTENT_MANAGER 계정이 로그인할 수 있고 JWT를 반환한다. AI 캐릭터 관리 API는 이 중 ADMIN만 허용한다.

별도 AI 캐릭터 관리자 로그인 Endpoint는 같은 Member 조회, 비밀번호 검증, JWT 발급 및 보안 설정을 중복할 뿐 새로운 보안 경계를 만들지 못한다.

6.3 현재 AI 캐릭터 관리자 API

기존 /admin/chat/character에는 다음 ADMIN 전용 기능이 있다.

Method Endpoint 기능
GET /admin/chat/character/list 활성 캐릭터 목록
GET /admin/chat/character/search 활성 캐릭터 검색
GET /admin/chat/character/{characterId} 캐릭터 상세
POST /admin/chat/character/register 캐릭터 등록
PUT /admin/chat/character/update 캐릭터 수정 및 활성 상태 변경

명시적인 DELETE Endpoint는 없으며 수정 요청의 isActive=false가 삭제 역할을 한다. 등록·수정 성공 응답은 현재 data=null이다.

6.4 현재 메뉴

현재 관리자 메뉴는 다음 흐름으로 서버가 결정한다.

JWT principal Member
  -> MenuService.getMenus(member)
  -> MenuRepository.getMenu(member.role)
  -> role, isActive, orders 기준 조회
  -> title, route, items 반환

GET /menu는 요청한 관리 화면 또는 애플리케이션을 식별하지 않고 로그인 Member의 role만 사용한다. 따라서 같은 ADMIN이 여러 관리자 페이지를 사용할 때 페이지별로 다른 메뉴를 반환할 수 없다. v2 AI 캐릭터 관리자 메뉴를 이 Endpoint에 추가하면 모든 legacy ADMIN 화면에도 같은 메뉴가 노출된다.

관리 화면은 다음 세 surface로 구분한다.

Admin surface 로그인 주체 메뉴 판단
전체 플랫폼 관리자 ADMIN, CONTENT_MANAGER legacy GET /menu 현행 유지
AI 캐릭터 관리자 v2 ADMIN 신규 클라이언트 정적 메뉴
크리에이터 관리자 CREATOR, AGENT legacy GET /menu 현행 유지

전체 플랫폼 관리자와 AI 캐릭터 관리자는 역할이 같으므로 기존 role만으로 메뉴를 분리할 수 없다.

6.5 v2 구현 및 재사용 조사

legacy 코드는 요구 기능의 데이터 모델과 현행 동작을 확인하는 근거로 사용하되 런타임 비즈니스 로직으로 호출하지 않는다.

영역 현행 근거 v2 판단 v2에 필요한 기능
캐릭터 legacy AdminChatCharacterService, ChatCharacterService 신규 구현 캐릭터 CRUD use case, 연결 creator 생성·동기화 port, 외부 캐릭터 API·파일 port
원작 legacy AdminOriginalWorkService, OriginalWorkRepository 신규 구현 원작 CRUD·검색, 이미지·번역 port, 캐릭터 연결 조회·배정·해제 use case
콘텐츠 legacy AudioContentService, CreatorAdminContentService 신규 구현 관리자용 content command/query use case와 소유권 정책
콘텐츠 가공 완료 연동 AWS S3 Trigger worker, 기존 PUT /audio-content/upload-complete 기존 외부 계약과 처리 흐름 유지 v2 생성도 기존 S3 key·metadata·DB 필드 계약을 따르고 별도 분기·worker·scheduler를 만들지 않음
콘텐츠 댓글 legacy AudioContentCommentService 신규 구현 comment command/query use case, 작성자·콘텐츠 소유자 삭제 정책, 부모 귀속 정책
콘텐츠 카테고리 legacy CategoryService, CreatorAdminCategoryService 신규 구현 category command/query use case, 카테고리와 포함 콘텐츠의 동일 소유자 정책
시리즈 legacy CreatorAdminContentSeriesService 신규 구현 series command/query use case, 콘텐츠 구성과 소유자 한정 순서 정책
커뮤니티 조회 기존 v2 CreatorChannelCommunityQueryService와 query port v2 내부 확장 가능 관리자 전용 projection 또는 admin query use case
커뮤니티 변경 legacy CreatorCommunityService 신규 구현 v2 community command use case와 게시글·댓글 소유권 정책
FanTalk 조회 기존 v2 CreatorChannelFanTalkQueryService와 query port v2 내부 재사용·확장 가능 관리자 조회 use case와 관리자 API DTO 매핑
FanTalk 답글 legacy ExplorerService 신규 구현 v2 FanTalk reply command use case와 루트·답글 귀속 정책
채널 공지 legacy ExplorerService.saveNotice 신규 구현 v2 creator channel notice upsert/query use case와 알림 port
채널 프로필·크리에이터 태그 legacy MemberService.profileUpdate, MemberTagService 신규 구현 v2 channel profile query/update use case와 활성 creator tag metadata query

결론은 다음과 같다.

  • legacy Controller, *Service, Request/Response DTO는 호출하거나 import하지 않는다.
  • 기존 v2 domain/application/port는 계약이 맞는 경우에만 v2 내부에서 재사용하거나 확장한다.
  • 없는 command 기능은 콘텐츠, 댓글, 시리즈, 커뮤니티, FanTalk 등 각 v2 도메인 패키지에 구현한다.
  • 원작은 캐릭터 등록·수정과 관리자 원작 화면에서 함께 사용되므로 v2.originalwork 독립 도메인이 소유하고, 캐릭터 도메인은 원작의 isDeleted=false 참조 계약만 사용한다.
  • 기존 PUT /audio-content/upload-complete는 AWS 연동 계약과 현재 처리 흐름을 그대로 사용한다. v2 콘텐츠 생성 use case가 기존과 같은 content row와 S3 입력 계약을 만들면 callback은 생성 경로를 구분하지 않고 동일하게 처리할 수 있다.
  • 관리자 API 계층은 대상 AI 캐릭터를 해석하고 각 v2 use case를 조정한다.
  • Controller 간 호출과 내부 HTTP 호출은 하지 않는다.
  • 기존 테이블과 JPA 매핑을 복제하지 않는다. v2 persistence adapter가 기존 스키마에 접근해 v2 port의 record/domain model로 변환한다.

6.6 현재 원작 관리자 API

legacy /admin/chat/original에는 ROLE_ADMIN 전용 원작 등록·수정·논리 삭제, 목록, 검색, 상세, 연결 캐릭터 목록, 캐릭터 일괄 배정·해제 기능이 있다. 캐릭터는 한 원작에만 연결될 수 있고 새 원작 배정은 기존 연결을 교체한다.

다만 신규 v2 계약에서는 다음 legacy 동작을 계승하지 않는다.

  • 목록과 검색을 별도 Endpoint로 나누고 검색만 비페이징으로 전체 반환하는 동작
  • 존재하지 않거나 비활성인 캐릭터 ID를 조용히 무시하는 부분 성공
  • 요청 경로의 원작 소속인지 확인하지 않고 다른 원작의 캐릭터까지 해제하는 동작
  • 연결 캐릭터를 남긴 채 원작을 삭제하고, 삭제 후에는 해당 연결을 해제할 수도 없게 만드는 동작
  • 수정의 nullable field에서 null을 값 삭제가 아닌 “변경 없음”으로 처리하는 부분 수정 의미
  • 생성 후 이미지 업로드가 실패하면 이미지 없는 원작 row를 남기고, DB 실패 시 업로드 이미지를 보상하지 않는 실행 순서
  • mutation 성공 응답의 data=null

v2는 legacy 기능을 1:1 URL 복사하지 않는다. 목록의 search Query로 목록·검색을 합치고, 나머지 CRUD·상세·연결 관리 능력을 11.7의 8개 Operation으로 제공한다. legacy Endpoint의 Method·Path·Request·성공 Response는 기존 클라이언트 호환을 위해 유지하되, 원작을 변경하는 요청은 같은 v2 command와 잠금 정책으로 수렴해 공존 중 불변식을 우회하지 못하게 한다.

7. Product Decisions

7.1 로그인

  • 별도의 로그인 기능을 만들지 않는다.
  • 기존 POST /admin/member/login과 Bearer JWT를 재사용한다.
  • 로그인 Endpoint 자체는 CONTENT_MANAGER에도 토큰을 발급할 수 있지만 AI 캐릭터 관리 Endpoint는 ROLE_ADMIN만 허용한다.
  • AI 캐릭터 Member로 로그인하거나 AI 캐릭터 사칭 토큰을 발급하지 않는다.
  • 모든 관리자 대행 변경 작업의 인증 주체는 실제 사람 관리자이며, 도메인 작업 대상만 AI 캐릭터의 creatorMember다. 기존 AWS worker callback은 관리자 대행 API가 아니며 현재 인증·응답 계약을 유지한다.

7.2 v2 관리자 메뉴

  • v2 AI 캐릭터 관리자 메뉴는 해당 클라이언트가 정적으로 소유한다.
  • legacy GET /menu를 v2 AI 캐릭터 관리자에서 호출하지 않는다.
  • 신규 v2 menu Endpoint도 만들지 않는다.
  • 전체 플랫폼 관리자와 크리에이터 관리자의 기존 메뉴 방식은 변경하지 않는다.
  • 서버 방식으로 분리하려면 클라이언트가 surface=PLATFORM_ADMIN|AI_CHARACTER_ADMIN|CREATOR_ADMIN 같은 값을 보내야 한다. 클라이언트가 이미 알고 있는 UI 문맥을 서버가 다시 route로 돌려주는 구조이고 인가에도 사용할 수 없으므로 이번 v2에는 채택하지 않는다.
  • 클라이언트는 현재 관리자 surface가 AI_CHARACTER_V2임을 알고 있으므로 해당 surface의 제목, 순서, 계층, route와 화면 컴포넌트를 함께 관리한다.

대안별 판단은 다음과 같다.

방식 판단 이유
legacy GET /menu 그대로 재사용 불가 입력이 인증 Member의 role뿐이라 같은 ADMIN의 전체 플랫폼 관리자와 AI 캐릭터 관리자 surface를 구분할 수 없음
GET /menu?surface=...로 확장 이번 v2에서 미채택 호출 클라이언트가 이미 아는 화면 문맥으로 정적 route를 다시 조회하며, 메뉴 응답이 Backend 인가를 대신할 수도 없음
surface별 신규 menu Endpoint 이번 v2에서 미채택 현재 요구에는 사용자별 메뉴 차이, 운영 중 메뉴 토글 또는 세부 capability가 없어 서버 계약과 저장소만 추가됨
v2 클라이언트 정적 메뉴 채택 신규 AI 캐릭터 관리자 surface의 route·컴포넌트와 메뉴를 한 곳에서 함께 변경할 수 있음
  • 최소 전역 진입 메뉴는 다음과 같다.
[
  {
    "key": "ai-characters",
    "title": "AI 캐릭터 관리",
    "route": "/ai-characters"
  },
  {
    "key": "original-works",
    "title": "원작 관리",
    "route": "/ai-characters/original-works"
  }
]
  • 원작 관리는 캐릭터 선택이 필요 없는 global route다. 캐릭터 선택 후 콘텐츠, 시리즈, 커뮤니티, FanTalk, 채널 설정은 클라이언트의 하위 route 또는 tab으로 구성한다.
  • 로그인 응답의 role=ADMIN은 클라이언트 route guard에 사용할 수 있지만 보안 경계가 아니다.
  • 백엔드의 모든 관리 API가 ROLE_ADMIN을 독립적으로 검증한다.
  • 역할별 세부 권한, 서버 feature flag 또는 운영 중 메뉴 활성·비활성 전환이 필요해지는 시점에는 UI route를 내려주는 메뉴 API보다 v2 capability API를 별도 PRD로 검토한다.

7.3 신규 API 형태

  • 신규 API는 v2 패키지에 구현하되 현재 관리자 URL 관례에 맞춰 /admin/ai-characters를 base path로 사용한다.
  • 기존 /admin/chat/character/**는 호환성을 위해 유지한다.
  • 기존 /admin/chat/original/**도 호환성을 위해 유지한다.
  • legacy 원작 관리자 mutation인 POST /register, PUT /update, DELETE /{id}, POST /{id}/assign-characters, POST /{id}/unassign-characters는 기존 URL·Request·성공 Response를 유지한 채 같은 v2 원작 command를 호출한다. legacy 캐릭터 POST /admin/chat/character/register, PUT /admin/chat/character/update는 외부 캐릭터·이미지·DB 작업 뒤 원작 최종 검증만 실패하는 부분 성공을 피하기 위해 전체 mutation을 같은 v2 캐릭터 command로 호출하고, 그 command가 v2 원작 참조·잠금 정책을 사용한다. legacy web adapter가 legacy DTO를 v2 command로 변환하며 v2가 legacy Controller·Service·DTO를 호출하는 역방향 의존은 만들지 않는다.
  • legacy 원작 PUT /update는 기존 nullable 부분 수정 의미를 유지한다. legacy adapter는 non-null 입력만 v2 호환 patch command로 변환하고, v2 application이 원작 row를 잠근 같은 transaction 안에서 현재 값에 병합한다. adapter가 먼저 읽은 stale snapshot으로 전체 교체하지 않는다. legacy DTO는 누락과 명시적 null을 구분하지 못하므로 legacy 경로의 null은 계속 “변경 없음”이며, 명시적 값 삭제는 신규 v2 API에서만 지원한다.
  • legacy 캐릭터 등록·수정 Request의 nullable 부분 수정과 body id는 호환 adapter에서 v2 캐릭터 create/patch/delete command로 변환한다. 원작 값의 정확한 호환 의미는 다음 표와 같고, 신규 CHAR-03·CHAR-04는 계속 0을 400으로 거부한다.
경로 originalWorkId 누락 또는 null originalWorkId=0 양수 ID
legacy 캐릭터 등록 원작 미연결 생성 원작 미연결 생성 활성 원작에 연결
legacy 캐릭터 수정 기존 연결 변경 없음 기존 연결 해제 활성 원작으로 연결·이동
신규 CHAR-03 원작 미연결 생성 400 활성 원작에 연결
신규 CHAR-04 기존 연결 해제 400 활성 원작으로 연결·이동
  • legacy 원작 목록·검색·상세·연결 캐릭터 목록과 일반 사용자용 /api/chat/original/** 조회 계약은 그대로 유지한다. legacy mutation의 오류는 기존 legacy 오류 envelope를 유지하되 연결된 원작 삭제, 잘못된 배정·해제처럼 데이터 불변식을 깨는 요청은 더 이상 성공시키지 않는다.
  • 신규 API는 REST resource 형태를 사용하고 삭제는 DELETE로 표현한다.
  • 캐릭터와 creator 작업 리소스는 기존 isActive=false, 원작은 기존 isDeleted=true인 논리 삭제로 처리한다.
  • 원작 관리의 global base path는 /admin/ai-characters/original-works다. 원작 목록의 search Query가 legacy 목록과 검색 능력을 하나로 합치며 별도 /search Endpoint는 만들지 않는다.
  • 캐릭터 범위 대행 작업은 Path의 characterId가 명시적인 대상이며 Request body에는 creatorId를 받지 않는다. global 원작 CRUD는 characterId를 요구하지 않는다.
  • 신규 Endpoint는 v2 외부의 legacy Controller, Service 또는 DTO를 호출하지 않는다.

7.4 삭제

  • 관리 API에서 독립 업무 리소스로 다루는 캐릭터, 콘텐츠, 댓글, 카테고리, 시리즈, 게시글, FanTalk 원문과 답글은 물리 삭제하지 않는다.
  • 원작 삭제는 기존 OriginalWork.isDeleted=true로 처리하고 링크, 태그, 이미지와 번역 이력을 보존한다.
  • 활성·비활성 여부와 관계없이 연결 캐릭터가 하나라도 남은 원작 삭제는 409를 반환한다. 관리자가 11.7의 연결 목록과 해제 API로 관계를 명시적으로 정리한 뒤 삭제하게 하며, 삭제 요청이 캐릭터를 암묵적으로 일괄 해제하지 않는다.
  • 삭제된 원작은 목록·검색·상세와 신규 배정 대상에서 제외한다. 삭제 command는 isDeleted를 연결 수보다 먼저 판정하므로, 과거 불일치로 연결 캐릭터가 남아 있더라도 이미 삭제된 같은 원작의 반복 삭제는 멱등하게 isDeleted=true를 반환한다. 복구는 제공하지 않는다.
  • legacy 삭제로 이미 isDeleted=true 원작을 가리키는 캐릭터가 있는지는 배포 전에 읽기 전용으로 건수를 확인한다. 이번 구현이 이를 자동 해제하거나 data migration하지 않는다. 결과가 0건이면 배포를 진행하고, 1건 이상이면 mutation 전환을 활성화하기 전에 대상과 영향 범위를 보고해 별도 승인된 데이터 보정 계획을 먼저 완료한다.
  • 시리즈-콘텐츠와 creator-Member-tag 연결은 독립 업무 리소스나 보존 대상 이력이 아니므로 관계 해제 시 join row만 물리 제거한다. 해당 관계 해제 작업에서는 시리즈·콘텐츠·Member·creator tag 자체를 변경하지 않는다. 콘텐츠 카테고리 연결은 기존 CategoryContent.isActive 모델을 유지한다.
  • 캐릭터 삭제는 ChatCharacter.isActive=false와 연결 creatorMember.isActive=false를 같은 작업으로 처리한다.
  • 연결 creatorMember와 기존 콘텐츠·정산·구매 이력 데이터는 삭제하지 않는다.
  • 캐릭터 삭제 시 연결 Member의 이름을 inactive_*로 변경하거나 기존 표시 정보를 덮어쓰지 않는다.
  • 삭제한 캐릭터의 이름은 재사용하지 않고 예약 상태로 유지한다.
  • 외부 캐릭터 레코드는 물리 삭제하거나 rename하지 않는다. 모든 채팅 진입점이 로컬 ChatCharacter.isActive=false를 확인해 사용을 차단한다.
  • 비활성 캐릭터는 관리자 목록에서 상태 필터로 조회할 수 있다.
  • 비활성 캐릭터에 대한 신규 등록·수정·답글 작업은 거부한다. 조회와 기존 리소스의 논리 삭제만 허용한다.
  • 캐릭터 삭제 시 소유 콘텐츠 row는 raw content.isActive=false로 전환하되 releaseDate, 가공 경로, duration과 구매 이력은 변경하지 않는다. 따라서 기존에 삭제되지 않은 콘텐츠의 계산 상태는 SUSPENDED가 되고, 이미 isActive=false && releaseDate=nullDELETED 콘텐츠도 그대로 유지한다. 활성 시리즈·커뮤니티 게시글·콘텐츠 카테고리도 비활성 상태로 전환하며 데이터와 관계는 보존한다.
  • 예약 콘텐츠와 처리 중 콘텐츠는 삭제된 캐릭터 명의로 공개되지 않는다.
  • 기존 upload-complete는 releaseDate=null인 삭제 콘텐츠 또는 비활성 creator의 콘텐츠를 다시 활성화하지 않고, 기존 예약 공개 조회도 활성 creator의 콘텐츠만 대상으로 한다. scheduler component의 cron·lock과 AWS worker는 변경하지 않는다.
  • 일반 사용자용 콘텐츠 목록·검색·추천·크리에이터 채널은 기존 content.isActive=true 공개 조건으로 삭제 캐릭터의 콘텐츠를 제외한다. 직접 상세 조회도 미구매 사용자에게는 거부하되, 기존 KEEP·RENTAL 구매자는 주문 이력 기반 상세 조회와 Signed URL 재생을 유지한다.
  • 콘텐츠·크리에이터 랭킹 snapshot row는 이력으로 보존한다. latest/previous visible snapshot 조회 시 현재 콘텐츠와 creator Member의 활성 상태를 결합해 삭제 캐릭터와 해당 콘텐츠를 제외하며 snapshot을 삭제하거나 다시 생성하지 않는다.
  • snapshot을 사용하지 않는 legacy creator ranking도 조회 시 현재 creator Member가 활성인지 확인한다.
  • 기존 캐릭터·콘텐츠·시리즈 banner row는 보존하되 공개 언어별 banner 조회에서 연결 캐릭터, creator 또는 시리즈와 시리즈 소유자가 모두 활성인 대상만 반환한다. 운영 관리자용 banner 목록은 기존 row를 계속 조회할 수 있다.
  • 삭제 캐릭터 콘텐츠의 공개 댓글·답글 조회와 신규 등록·본문 수정·재활성화는 차단한다. 작성자 또는 콘텐츠 소유자의 기존 댓글 논리 삭제는 허용한다. 기존 구매 유지 예외는 오디오 재생에만 적용하며 댓글·답글 또는 다른 소셜 작업을 허용하는 근거가 아니다.
  • 삭제 commit 후 기존 공개 DTO가 남지 않도록 default, cache_ttl_3_days, popularCharacters_24h_locale cache namespace를 1회 clear한다. 동적 key를 전부 열거하거나 Redis key scan을 추가하지 않는다. rollback과 이미 비활성인 캐릭터의 반복 삭제에서는 clear하지 않으며 legacy 비활성화도 같은 v2 삭제 use case를 통해 동일하게 처리한다.
  • FanTalk 원문과 기존 AI 답글은 사용자 작성 이력 보존을 위해 변경하지 않지만 비활성 creator 채널은 공개 조회와 새 FanTalk 수신을 거부한다.
  • 이미 구매한 콘텐츠는 구매 이력 기반 재생을 유지한다.
  • 캐릭터 복구 기능은 이번 범위에 없으므로 비활성화한 연관 리소스를 자동 복구하지 않는다.
  • 공존 기간의 legacy PUT /admin/chat/character/update가 활성 캐릭터를 isActive=false로 바꾸는 경우에도 동일한 v2 캐릭터 삭제 use case를 호출해 위 cascade를 적용한다.
  • legacy 경로를 포함해 비활성 캐릭터의 isActive=true 전환은 거부한다. 복구가 필요하면 별도 PRD에서 데이터·외부 시스템 복구 정책을 먼저 정의한다.

7.5 “모든 크리에이터 기능”의 범위

현재 요구에 열거된 기능만으로는 실제 크리에이터 기능 전체와 일치하지 않는다.

이번 PRD는 AI 캐릭터의 비동기 발행·커뮤니티 운영 기능을 완성하는 범위로 정의한다. 요구 목록과 직접 결합된 다음 누락 기능은 이번 범위에 포함한다.

  • 콘텐츠 상단 고정·고정 해제
  • 콘텐츠 카테고리 조회·등록·수정·논리 삭제·순서 변경 및 콘텐츠 구성
  • 콘텐츠 테마 조회
  • 시리즈 콘텐츠 조회·검색·추가·제거
  • 시리즈 순서 변경
  • 시리즈 장르 조회
  • 커뮤니티 게시글 고정·고정 해제
  • 크리에이터 채널 공지 조회·등록·수정
  • 크리에이터 채널 SNS·크리에이터 태그·후원 랭킹 공개 설정 조회·수정
  • 크리에이터 소유자 권한의 FanTalk 원문 논리 삭제

조사에서 추가로 확인한 주요 기능의 포함·제외 판단은 다음과 같다.

빠진 기능 이번 범위 판단
콘텐츠 카테고리 조회·편집·순서 변경 포함 콘텐츠 발행 화면과 직접 결합된 개인 분류이며 콘텐츠 소유권 검증을 v2에 구현함
콘텐츠 테마·시리즈 장르 조회 포함 legacy API를 호출하지 않고 v2 content·series query port로 같은 기준정보를 조회함
크리에이터 채널 공지 수정 포함 기존 채널 notice 저장소를 v2 port로 연결하고 관리자용 조회·upsert API를 제공함
채널 SNS URL·후원 랭킹 공개 설정 포함 캐릭터 원본 정보와 겹치지 않는 Member 채널 설정을 별도 v2 API로 관리함
크리에이터 Member 태그 조회·편집 포함 ChatCharacter.tags와 다른 채널 탐색용 데이터이며 태그가 없으면 일부 크리에이터 탐색 조회에서 제외되므로 별도 관리함
FanTalk 원문 moderation 포함 대상 크리에이터가 타인 작성 FanTalk를 비활성화할 수 있는 현재 권한을 보존함
팔로워 목록·후원 내역·후원 랭킹 조회 제외 대행 mutation이 아닌 운영 분석 조회이며 개인정보·재무 권한과 함께 별도 관리자 분석 범위로 다룸
채널 후원 메시지 조회 제외 비밀 메시지 시야와 재무 권한 정책이 필요함
시그니처 후원 설정 제외 후원 상품·정산 책임 정책 필요
라이브 방 생성·예약·메뉴·룰렛 제외 실시간 진행 주체와 장애 대응 정책 필요
라이브·콘텐츠·후원·커뮤니티 매출/정산 조회 제외 관리자 전역 정산 기능과 권한·개인정보 정책으로 다뤄야 함
크리에이터 로그인·로그아웃·메뉴 제외 AI 캐릭터 로그인 금지 정책을 유지함
이름·이미지·소개 수정 캐릭터 수정으로 대체 ChatCharacter가 원본이고 연결 Member로 동기화함
Member 성별 수정 캐릭터 수정으로 대체 AI의 성별 원본은 ChatCharacter.gender로 유지하고, 라이브가 제외된 AI creator의 Member.gender를 별도 관리하지 않음
프로필 요청의 container 제외 클라이언트 실행 환경 값이지 채널의 관리 대상 설정이 아님
좋아요·구매·팔로우·후원·DM 제외 크리에이터 운영 작업이 아닌 소비자 행동이며 AI 캐릭터 DM은 금지됨

따라서 이번 범위 완료만으로 라이브·정산을 포함한 문자 그대로의 “크리에이터 전체 기능 동등성”을 선언하지 않는다. 비동기 콘텐츠·커뮤니티 운영 범위의 기능 동등성을 완료 기준으로 삼는다.

7.6 v2 의존성 경계

“v2 외부 로직을 재사용하지 않는다”는 비즈니스 로직 경계로 확정한다.

구분 정책
legacy Controller 사용 금지
legacy application/domain Service 사용 금지
legacy Request/Response DTO 사용 금지
기존 v2 domain/application/port 계약이 맞으면 v2 내부 재사용·확장 허용
공통 인증과 ApiResponse 플랫폼 공통 기능이므로 재사용 허용
Member security principal web adapter에서 adminMemberId로 변환하는 용도로만 허용
기존 DB 테이블·JPA entity·QueryDSL Q type persistence adapter에서만 재사용 허용
S3, CDN, 외부 캐릭터 API client v2 outbound port 뒤의 infrastructure adapter에서 사용 허용

v2 domain과 application은 legacy JPA entity, legacy Repository, security principal 및 web DTO를 public signature에 노출하지 않는다. persistence adapter는 기존 테이블을 읽고 쓰되 v2 port record 또는 v2 domain model로 변환한다.

7.7 원작 관리와 캐릭터 연결

  • 원작은 특정 캐릭터의 소유 리소스가 아닌 global 관리자 기준정보다. 원작 CRUD 화면에 들어가기 전에 캐릭터를 선택하지 않는다.
  • 캐릭터 등록·수정 화면에서는 11.7의 원작 페이징 검색을 사용해 원작을 선택한다.
  • 신규 CHAR-03originalWorkId=null은 미연결 생성이고, 신규 CHAR-04의 명시적 originalWorkId=null은 기존 연결 해제다. 신규 계약은 legacy의 originalWorkId=0 sentinel을 사용하지 않으며 0 이하는 400이다.
  • 양수 originalWorkIdOriginalWork.isDeleted=false인 원작만 허용한다.
  • 한 캐릭터는 최대 한 원작에만 연결된다. 배정 API로 이미 다른 원작에 연결된 활성 캐릭터를 선택하면 새 원작으로 원자적으로 이동한다.
  • 배정은 연결 creatorMember까지 유효한 활성 AI 캐릭터만 허용한다. 해제는 삭제 전 관계 정리를 위해 현재 원작에 연결된 활성·비활성 캐릭터를 허용하며, legacy 불일치 관계도 정리할 수 있도록 연결 creatorMember의 누락·상태·종류를 해제 조건으로 사용하지 않는다.
  • 배정·해제 Request의 ID는 중복 없이 하나 이상이어야 한다. 배정은 모든 ID의 활성 AI 캐릭터 상태를, 해제는 모든 ID의 존재와 현재 원작 귀속을 먼저 검증하며 하나라도 실패하면 전체를 rollback한다.
  • 해제는 각 캐릭터가 Path의 원작에 실제 연결되어 있을 때만 수행한다. 다른 원작 소속 또는 미연결 캐릭터를 해제하려는 요청은 characterIds 400이다.
  • 원작 삭제, 배정, 해제와 CHAR-03·CHAR-04의 양수 연결 변경은 추가 연결 대상 또는 원작 Path row를 PESSIMISTIC_WRITE로 먼저 잠그고, 대상 캐릭터 row를 ID 오름차순으로 잠근 뒤 상태와 귀속을 다시 검증한다. CHAR-04null 해제와 legacy 수정의 0 해제는 양수 target 원작이 없으므로 캐릭터 row만 잠그고 현재 귀속을 다시 확인한다. 따라서 삭제의 연결 수 확인과 새 연결 사이에 삭제 원작 참조가 생기지 않는다.
  • 삭제되지 않은 동일 제목의 동시 생성을 처리하는 원작 생성과 제목이 실제 바뀌는 수정 command만 DB schema 변경 없이 MySQL SERIALIZABLE transaction에서 실행한다. deadlock·serialization 실패는 한 번만 새 transaction으로 재시도한다. 이미지가 저장된 attempt가 실패하면 해당 attempt의 object를 먼저 보상하고, 보상 성공 후에만 다음 transaction을 시작한다. 재조회에서 실제 중복이 확인된 경우에만 title 409를 반환한다.
  • 원작 생성·수정은 9.5의 전체 교체·nullable 규칙을 따른다. 이미지 생략만 기존 이미지를 유지하고 nullable JSON field의 명시적 null과 빈 목록은 값을 지운다.
  • 원작 생성은 언어 감지를, 정규화된 title, contentType, category, description, tags 중 하나 이상이 실제 변경된 수정은 번역 갱신을 commit 후 한 번 예약한다. writer, studio, URL, 이미지 변경과 단순 캐릭터 배정·해제는 원작 번역 작업을 만들지 않는다.
  • 기존 OriginalWork, link, tag, ChatCharacter.originalWork 테이블·관계를 그대로 사용하며 schema, JPA mapping 또는 데이터 migration을 추가하지 않는다.

8. User Stories

  • 관리자로서 기존 관리자 계정으로 로그인해 AI 캐릭터 관리 메뉴에 접근하고 싶다.
  • 관리자로서 활성·비활성 AI 캐릭터를 검색하고 상세 상태를 확인하고 싶다.
  • 관리자로서 AI 캐릭터를 등록·수정·논리 삭제하고 싶다.
  • 관리자로서 원작을 검색·조회·등록·수정·논리 삭제하고, 원작에 연결된 AI 캐릭터를 배정·해제하고 싶다.
  • 관리자로서 캐릭터 등록·수정 화면에서 원작을 이름으로 검색해 선택하거나 연결을 해제하고 싶다.
  • 관리자로서 선택한 AI 캐릭터 명의로 콘텐츠와 시리즈를 관리하고 싶다.
  • 관리자로서 선택한 AI 캐릭터의 콘텐츠 카테고리 순서와 포함 콘텐츠를 관리하고 싶다.
  • 관리자로서 AI 캐릭터 명의로 콘텐츠 및 커뮤니티 댓글을 작성하고 수정하고 싶다.
  • 관리자로서 AI 캐릭터 소유 콘텐츠나 게시글에 달린 부적절한 타인의 댓글을 삭제하고 싶다.
  • 관리자로서 선택한 AI 캐릭터 명의로 커뮤니티 게시글을 관리하고 싶다.
  • 관리자로서 팬이 남긴 FanTalk를 확인하고 AI 캐릭터 명의의 답글을 관리하고 싶다.
  • 관리자로서 선택한 AI 캐릭터의 채널 공지를 조회하고 변경하고 싶다.
  • 관리자로서 선택한 AI 캐릭터의 채널 SNS, creator tag와 후원 랭킹 공개 설정을 관리하고 싶다.
  • 운영 책임자로서 실제 작업 관리자와 대행 대상 AI 캐릭터를 로그에서 구분하고 싶다.

9. Common API Contract

9.1 Authentication

AI 캐릭터 관리 Endpoint에는 다음 Header가 필요하다.

Authorization: Bearer <admin-jwt>

9.2 Response Envelope

모든 응답은 기존 ApiResponse<T> 형식을 사용한다.

{
  "success": true,
  "message": null,
  "data": {},
  "errorProperty": null
}

오류 응답은 다음 형식을 사용한다.

{
  "success": false,
  "message": "요청을 처리할 수 없습니다.",
  "data": null,
  "errorProperty": "characterId"
}

9.3 Pagination

신규 목록 API의 Query 기본값과 보정 규칙은 다음과 같다.

Field Type Rule
page Int? 0부터 시작, null 또는 음수는 0
size Int? 기본 20, 1 미만은 20, 50 초과는 50

공통 목록 응답은 다음 형식이다.

{
  "items": [],
  "page": 0,
  "size": 20,
  "totalCount": 0,
  "hasNext": false
}

콘텐츠 테마, 시리즈 장르와 creator tag는 운영 기준정보 전체를 한 번에 선택해야 하고 데이터 수가 제한되므로 페이징하지 않는 예외다. 그 외 관리자 리소스 목록은 공통 페이징 계약을 사용한다.

9.4 Common Mutation Response

등록·수정·논리 삭제는 data=null 대신 변경된 리소스 식별자와 변경 후 상태 또는 표현을 반환한다. Endpoint Summary에서 전용 Response를 선언한 경우 해당 계약이 우선하며, AdminMutationResponse를 선언한 Endpoint만 다음 공통 형식을 사용한다.

Field Type Nullable Description
id Long No 변경된 리소스 ID
isActive Boolean No 변경 후 활성 상태

9.5 Common Representation Rules

  • 모든 ID는 JSON number 형식의 Long이다.
  • 모든 절대 날짜·시간 Request/Response는 ISO-8601 UTC 문자열로 전송하고 필드명은 *AtUtc를 사용한다. 값은 반드시 UTC를 뜻하는 Z suffix를 포함한다.
  • 프론트엔드는 UTC 원문 또는 epoch millisecond로 저장·비교하고 화면 표시에서만 Asia/Seoul로 변환한다. 브라우저·운영체제의 local timezone에 표시 결과를 맡기지 않는다.
  • duration과 콘텐츠 생성 Request의 previewStartTime, previewEndTimeHH:mm:ss 형식의 재생 길이·미디어 offset이므로 날짜 객체로 만들거나 KST로 변환하지 않는다.
  • 목록이 없으면 null이 아니라 빈 배열을 반환한다.
  • 선택 필드에 값이 없으면 null을 반환한다.
  • 리소스 본문을 수정하는 PUT은 해당 리소스의 수정 가능한 JSON 표현을 전체 교체한다. 각 API에서 선택 File part로 명시한 항목만 생략 시 기존 값을 유지한다.
  • 리소스 수정 PUT의 nullable JSON field도 key 자체는 필수이며 값을 지울 때 명시적으로 null을 전송한다. 필수 key 누락은 400이다.
  • /pin, /fixed, /orders 같은 명령형 PUT은 각 Endpoint에 명시한 Request 계약을 우선한다.
  • 클라이언트가 creatorId나 writer ID를 지정해 작업 주체를 바꿀 수 없게 한다.
  • Path resource가 선택한 characterId의 소유가 아니면 존재 여부를 노출하지 않는 동일한 not-found 오류로 처리한다.

9.6 Operation ID and Contract Reading Rule

  • 모든 신규 API에는 문서와 클라이언트 구현에서 공통으로 참조할 고유 Operation ID를 부여한다.
  • Operation ID는 문서 식별자이며 Request field나 HTTP Header로 전송하지 않는다.
  • Endpoint Summary의 한 행은 하나의 HTTP Endpoint, 하나의 Request 계약, 하나의 Response Data 계약만 나타낸다.
  • Request 열의 DTO 이름은 해당 도메인 절에 있는 동일 이름의 field 표와 validation을 따른다.
  • Response Data는 항상 9.2의 ApiResponse<T>.data에 들어가는 타입이다. 표에 envelope 전체가 명시된 로그인 예외를 제외하고 클라이언트가 data를 한 번 더 중첩하지 않는다.
  • 같은 DTO를 여러 Endpoint가 사용해도 각 행에서 Request와 Response를 생략하지 않는다.
  • 이 PRD의 신규 Operation은 모두 AI 캐릭터 관리자 웹 클라이언트가 호출할 수 있다. 기존 AWS worker callback은 신규 Operation에 포함하지 않는다.

10. Authentication and v2 Navigation Contract

10.1 Admin Login

Endpoint

POST /admin/member/login
Content-Type: application/json

Operation ID: AUTH-01

Request

Field Type Required Description
email String Yes 관리자 Member 이메일
password String Yes 관리자 Member 비밀번호
{
  "email": "admin@example.com",
  "password": "password"
}

Response Data

Field Type Nullable Description
token String No Bearer JWT
role String No 로그인 Member 역할
{
  "success": true,
  "message": null,
  "data": {
    "token": "<jwt>",
    "role": "ADMIN"
  },
  "errorProperty": null
}

10.2 v2 Client Navigation

  • Backend Endpoint 없음
  • Request/Response 없음
  • legacy GET /menu 사용 안 함
  • 클라이언트 route root: /ai-characters
  • route 진입 조건: 로그인 응답의 role == "ADMIN"
  • 최종 권한 판단: 각 Backend API의 ROLE_ADMIN

11. AI Character API

Base path는 /admin/ai-characters이며 모든 Endpoint는 ROLE_ADMIN 전용이다.

11.1 Endpoint Summary

Operation ID Method Endpoint Request Response Data
CHAR-01 GET /admin/ai-characters Query: page, size, search, isActive AdminPageResponse<AdminAiCharacterSummaryResponse>
CHAR-02 GET /admin/ai-characters/{characterId} Path: characterId AdminAiCharacterDetailResponse
CHAR-03 POST /admin/ai-characters multipart: image, request=AdminAiCharacterCreateRequest AdminAiCharacterMutationResponse
CHAR-04 PUT /admin/ai-characters/{characterId} Path: characterId; multipart: image?, request=AdminAiCharacterUpdateRequest AdminAiCharacterMutationResponse
CHAR-05 DELETE /admin/ai-characters/{characterId} Path: characterId; Body 없음 AdminAiCharacterDeleteResponse

11.2 CHAR-01 · Character List

Request

Field In Type Required Description
page Query Int No 기본 0
size Query Int No 기본 20, 최대 50
search Query String No 이름 검색어, trim 후 빈 값은 미적용
isActive Query Boolean No null이면 전체, true/false면 상태 필터

Response Data

AdminAiCharacterSummaryResponse

Field Type Nullable
characterId Long No
creatorId Long No
name String No
imageUrl String Yes
description String No
gender String Yes
age Int Yes
mbti String Yes
region String No
tags List<String> No
isActive Boolean No
createdAtUtc String Yes
updatedAtUtc String Yes

기본 정렬은 응답 기준 createdAtUtc DESC, characterId DESC다.

11.3 CHAR-02 · Character Detail

Request

  • Path characterId: Long

Response Data

AdminAiCharacterDetailResponse

Field Type Nullable
characterId Long No
creatorId Long No
characterUuid String No
name String No
imageUrl String Yes
description String No
systemPrompt String No
characterType String No
age Int Yes
gender String Yes
mbti String Yes
speechPattern String Yes
speechStyle String Yes
appearance String Yes
region String No
isActive Boolean No
tags List<String> No
hobbies List<String> No
values List<String> No
goals List<String> No
relationships List<CharacterRelationship> No
personalities List<CharacterPersonality> No
backgrounds List<CharacterBackground> No
memories List<CharacterMemory> No
originalWork OriginalWorkBrief Yes
createdAtUtc String Yes
updatedAtUtc String Yes

중첩 객체 필드는 기존 캐릭터 상세 응답의 다음 계약을 유지한다.

  • CharacterRelationship: personName, relationshipName, description, importance, relationshipType, currentStatus
  • CharacterPersonality: trait, description
  • CharacterBackground: topic, description
  • CharacterMemory: title, content, emotion
  • OriginalWorkBrief: id: Long, imageUrl: String?, title: String

11.4 CHAR-03 · Character Create

Request

Content-Type: multipart/form-data
Part Type Required Description
image File Yes 캐릭터 대표 이미지
request JSON string Yes AdminAiCharacterCreateRequest

AdminAiCharacterCreateRequest

Field Type Required Default
name String Yes -
systemPrompt String Yes -
description String Yes -
age Int No null
gender String No null
mbti String No null
speechPattern String No null
speechStyle String No null
appearance String No null
region String No "KR"
originalWorkId Long No null
characterType String No "Character"
tags List<String> No []
hobbies List<String> No []
values List<String> No []
goals List<String> No []
relationships List<CharacterRelationship> No []
personalities List<CharacterPersonality> No []
backgrounds List<CharacterBackground> No []
memories List<CharacterMemory> No []
  • characterTypeCharacter, Clone만 허용한다.
  • age는 0 이상의 정수다.
  • region은 대문자 ISO 3166-1 alpha-2 국가 코드다.
  • originalWorkId는 양수이고 OriginalWork.isDeleted=false인 원작을 가리켜야 한다. null이면 원작 연결 없이 생성하고 legacy의 해제 sentinel인 0은 허용하지 않는다. 0 이하는 400, 미존재 양수 ID는 404, 삭제된 원작 ID는 409이며 모두 errorProperty="originalWorkId"다.

중첩 Request 계약은 다음과 같다.

Type Required fields
CharacterRelationship personName: String, relationshipName: String, description: String, importance: Int, relationshipType: String, currentStatus: String
CharacterPersonality trait: String, description: String
CharacterBackground topic: String, description: String
CharacterMemory title: String, content: String, emotion: String

중첩 객체의 String field는 trim 후 빈 값일 수 없다.

Response Data

AdminAiCharacterMutationResponse

Field Type Nullable Description
characterId Long No 생성된 캐릭터 ID
creatorId Long No 함께 생성된 AI 크리에이터 Member ID
isActive Boolean No 변경 후 캐릭터 활성 상태
{
  "characterId": 101,
  "creatorId": 10001,
  "isActive": true
}

등록 성공 시 같은 작업 흐름에서 연결 creatorMember를 생성하고 그 ID를 응답의 creatorId로 반환한다.

11.5 CHAR-04 · Character Update

Request

Content-Type: multipart/form-data
Part Type Required Description
image File No 새 대표 이미지
request JSON string Yes AdminAiCharacterUpdateRequest

AdminAiCharacterUpdateRequestregion을 제외한 AdminAiCharacterCreateRequest의 모든 field key를 필수로 받는다. age, gender, mbti, speechPattern, speechStyle, appearance, originalWorkId는 명시적 null로 값을 지울 수 있고, 목록은 빈 배열로 전체 삭제할 수 있다. characterType은 non-null이며 Character, Clone 중 하나여야 한다. characterId, isActive는 body에서 받지 않는다.

region은 캐릭터 생성 후 변경할 수 없는 값으로 유지한다. 이미지 File part를 생략한 경우에만 기존 이미지를 유지한다. 양수 originalWorkIdOriginalWork.isDeleted=false인지 검증하고, 명시적 null은 기존 원작 연결을 해제한다. key 누락과 0 이하는 400, 미존재 양수 ID는 404, 삭제된 원작 ID는 409이며 모두 errorProperty="originalWorkId"다.

Response Data

AdminAiCharacterMutationResponse

{
  "characterId": 101,
  "creatorId": 10001,
  "isActive": true
}

이름, 이미지 또는 소개가 바뀌면 연결 Member의 nickname, profileImage, introduce도 같은 작업 안에서 동기화한다.

11.6 CHAR-05 · Character Delete

Request

  • Path characterId: Long
  • Body 없음

Response Data

AdminAiCharacterDeleteResponse

{
  "characterId": 101,
  "creatorId": 10001,
  "characterIsActive": false,
  "creatorIsActive": false
}

동일 캐릭터에 대한 반복 삭제는 멱등하게 같은 결과를 반환한다.

11.7 Original Work Management API

원작은 특정 캐릭터를 먼저 선택하지 않는 global 관리자 리소스다. Base path는 /admin/ai-characters/original-works이며 모든 Endpoint는 ROLE_ADMIN 전용이다.

11.7.1 Endpoint Summary

Operation ID Method Endpoint Request Response Data
ORIGINAL-WORK-01 GET /admin/ai-characters/original-works Query: page, size, search AdminPageResponse<AdminOriginalWorkSummaryResponse>
ORIGINAL-WORK-02 GET /admin/ai-characters/original-works/{originalWorkId} Path: originalWorkId AdminOriginalWorkDetailResponse
ORIGINAL-WORK-03 POST /admin/ai-characters/original-works multipart: image, request=AdminOriginalWorkCreateRequest AdminOriginalWorkMutationResponse
ORIGINAL-WORK-04 PUT /admin/ai-characters/original-works/{originalWorkId} Path: originalWorkId; multipart: image?, request=AdminOriginalWorkUpdateRequest AdminOriginalWorkMutationResponse
ORIGINAL-WORK-05 DELETE /admin/ai-characters/original-works/{originalWorkId} Path: originalWorkId; Body 없음 AdminOriginalWorkMutationResponse
ORIGINAL-WORK-06 GET /admin/ai-characters/original-works/{originalWorkId}/characters Path: originalWorkId; Query: page, size, search, isActive AdminPageResponse<AdminOriginalWorkCharacterResponse>
ORIGINAL-WORK-07 POST /admin/ai-characters/original-works/{originalWorkId}/characters Path: originalWorkId; JSON: AdminOriginalWorkCharacterIdsRequest AdminOriginalWorkCharacterAssignmentResponse
ORIGINAL-WORK-08 DELETE /admin/ai-characters/original-works/{originalWorkId}/characters Path: originalWorkId; JSON: AdminOriginalWorkCharacterIdsRequest AdminOriginalWorkCharacterAssignmentResponse

ORIGINAL-WORK-01search가 legacy 목록과 검색 기능을 합친다. 별도 /search Endpoint와 비페이징 전체 검색은 만들지 않는다.

11.7.2 Common Fields and Validation

원작 생성·수정의 JSON field는 다음과 같다.

Field Type Create Update Default / Null meaning
title String Required Required trim 후 빈 값 불가
contentType String Required Required trim 후 빈 값 불가
category String Required Required trim 후 빈 값 불가
isAdult Boolean Optional Required 생성 기본 false
description String Optional Required 생성 기본 "", 빈 문자열 허용
originalWork String? Optional Required 생성 기본 null, 명시적 null은 값 삭제
originalLink String? Optional Required 생성 기본 null, 명시적 null은 값 삭제
writer String? Optional Required 생성 기본 null, 명시적 null은 값 삭제
studio String? Optional Required 생성 기본 null, 명시적 null은 값 삭제
originalLinks List<String> Optional Required 생성 기본 [], 빈 배열은 전체 삭제
tags List<String> Optional Required 생성 기본 [], 빈 배열은 전체 삭제
  • 수정 JSON은 PUT 전체 교체이므로 위 11개 key를 모두 전송한다. nullable key 누락은 400이고 값 삭제는 명시적 null로 표현한다.
  • title, contentType, category, nullable 문자열, 링크와 태그는 trim한다. nullable 문자열의 trim 결과가 빈 값이면 null로 정규화한다.
  • originalLinkoriginalLinks의 값은 http 또는 https 절대 URL이어야 한다.
  • originalLinkstags는 trim 후 빈 값을 제거하고 첫 등장 순서를 유지한 채 중복을 제거한다.
  • 신규 v2 저장은 정규화된 originalLinkstags를 요청 순서대로 다시 만들고, 조회 projection은 link ID와 tag-mapping ID 오름차순으로 명시적으로 정렬한다. JPA collection의 암묵적 조회 순서에는 의존하지 않는다.
  • title 중복은 양쪽 값을 trim한 뒤 대소문자를 무시해 비교한다. 따라서 "Moon"" moon "은 같은 제목이다. 삭제되지 않은 동일 제목이 있으면 생성과 수정 모두 409, errorProperty="title"이며 현재 리소스 자신의 ID는 충돌에서 제외한다.
  • 생성 이미지 part는 필수이고 수정 이미지는 선택이다. 실제 MIME이 이미지인지 검증하며 GIF는 거부한다. 수정에서 이미지를 생략한 경우에만 기존 이미지를 유지한다.
  • 목록·검색·상세는 isDeleted=false인 원작만 반환한다. 삭제된 원작의 수정·배정·해제는 409이고 반복 삭제만 허용한다.
  • characterCount는 활성·비활성 또는 연결 creator 상태와 관계없이 현재 원작을 참조하는 ChatCharacter row 수다. 원작 삭제 가능 여부도 같은 기준을 사용한다.
  • Path와 캐릭터 요청의 originalWorkId는 양수여야 한다. 0 이하는 400, 미존재 양수 ID는 404다. 삭제된 ID는 조회에서 404, 수정·배정·해제에서 409이며 DELETE 재시도만 200이다.
  • 모든 날짜·시간은 UTC Z 문자열이다.

AdminOriginalWorkSummaryResponse

Field Type Nullable
originalWorkId Long No
title String No
contentType String No
category String No
isAdult Boolean No
imageUrl String Yes
characterCount Long No
createdAtUtc String Yes
updatedAtUtc String Yes

AdminOriginalWorkDetailResponse는 summary field에 다음 field를 추가한다.

Field Type Nullable
description String No
originalWork String Yes
originalLink String Yes
writer String Yes
studio String Yes
originalLinks List<String> No
tags List<String> No

AdminOriginalWorkMutationResponse

Field Type Nullable
originalWorkId Long No
isDeleted Boolean No

AdminOriginalWorkCharacterResponse

Field Type Nullable
characterId Long No
creatorId Long Yes
name String No
imageUrl String Yes
isActive Boolean No
createdAtUtc String Yes

creatorId는 정상 AI 캐릭터에서는 항상 값이 있지만, legacy에서 이미 생긴 잘못된 연결도 목록에 노출하고 해제할 수 있도록 nullable이다.

AdminOriginalWorkCharacterIdsRequest

Field Type Nullable Validation
characterIds List<Long> No 양수 ID 1개 이상, 중복 불가

AdminOriginalWorkCharacterAssignmentResponse

Field Type Nullable
originalWorkId Long No
characterIds List<Long> No
characterCount Long No

배정·해제 Response의 characterIds는 실제 변경 여부와 관계없이 검증을 통과한 요청 ID 전체를 요청 순서대로 반환한다. 따라서 같은 원작 반복 배정의 ID도 포함된다. characterCount는 작업 완료 후 해당 원작을 참조하는 전체 ChatCharacter row 수이며 처리 건수가 아니다.

Request:

  • Path 없음
  • Request JSON 없음
  • Query page: Int?, size: Int?, search: String?
  • search는 trim 후 빈 값이면 적용하지 않고, 값이 있으면 title, contentType, category의 대소문자 무시 부분 일치를 적용한다.
  • 기본 정렬은 createdAtUtc DESC, originalWorkId DESC다.

Response JSON:

{
  "success": true,
  "message": null,
  "data": {
    "items": [
      {
        "originalWorkId": 71,
        "title": "달빛 도서관",
        "contentType": "WEB_NOVEL",
        "category": "FANTASY",
        "isAdult": false,
        "imageUrl": "https://cdn.example.com/originals/71/original.webp",
        "characterCount": 2,
        "createdAtUtc": "2026-07-20T01:00:00Z",
        "updatedAtUtc": "2026-07-20T02:00:00Z"
      }
    ],
    "page": 0,
    "size": 20,
    "totalCount": 1,
    "hasNext": false
  },
  "errorProperty": null
}

11.7.4 ORIGINAL-WORK-02 · Original Work Detail

Request:

  • Path originalWorkId: Long
  • Request JSON 없음

Response JSON:

{
  "success": true,
  "message": null,
  "data": {
    "originalWorkId": 71,
    "title": "달빛 도서관",
    "contentType": "WEB_NOVEL",
    "category": "FANTASY",
    "isAdult": false,
    "description": "밤에만 문을 여는 도서관의 이야기",
    "originalWork": "Moonlight Library",
    "originalLink": "https://example.com/works/71",
    "writer": "김작가",
    "studio": "소다 스튜디오",
    "originalLinks": [
      "https://example.com/works/71",
      "https://example.com/works/71/official"
    ],
    "tags": ["힐링", "판타지"],
    "imageUrl": "https://cdn.example.com/originals/71/original.webp",
    "characterCount": 2,
    "createdAtUtc": "2026-07-20T01:00:00Z",
    "updatedAtUtc": "2026-07-20T02:00:00Z"
  },
  "errorProperty": null
}

11.7.5 ORIGINAL-WORK-03 · Original Work Create

Request:

Content-Type: multipart/form-data
Part Type Required Description
image File Yes 원작 대표 이미지
request JSON string Yes 아래 Request JSON을 JSON.stringify한 값

Request JSON:

{
  "title": "달빛 도서관",
  "contentType": "WEB_NOVEL",
  "category": "FANTASY",
  "isAdult": false,
  "description": "밤에만 문을 여는 도서관의 이야기",
  "originalWork": "Moonlight Library",
  "originalLink": "https://example.com/works/71",
  "writer": "김작가",
  "studio": "소다 스튜디오",
  "originalLinks": [
    "https://example.com/works/71",
    "https://example.com/works/71/official"
  ],
  "tags": ["힐링", "판타지"]
}

Response JSON:

{
  "success": true,
  "message": null,
  "data": {
    "originalWorkId": 71,
    "isDeleted": false
  },
  "errorProperty": null
}

11.7.6 ORIGINAL-WORK-04 · Original Work Update

Request:

Content-Type: multipart/form-data
Part Type Required Description
image File No 새 원작 대표 이미지, 생략 시 기존 이미지 유지
request JSON string Yes 아래 11개 key를 모두 가진 Request JSON

Request JSON:

{
  "title": "달빛 도서관 개정판",
  "contentType": "WEB_NOVEL",
  "category": "FANTASY",
  "isAdult": false,
  "description": "개정된 작품 소개",
  "originalWork": null,
  "originalLink": null,
  "writer": "김작가",
  "studio": "소다 스튜디오",
  "originalLinks": [],
  "tags": ["판타지"]
}

Response JSON:

{
  "success": true,
  "message": null,
  "data": {
    "originalWorkId": 71,
    "isDeleted": false
  },
  "errorProperty": null
}

11.7.7 ORIGINAL-WORK-05 · Original Work Delete

Request:

  • Path originalWorkId: Long
  • Request JSON 없음

삭제되지 않은 원작에 연결된 ChatCharacter가 하나라도 있으면 409와 errorProperty="originalWorkId"를 반환한다. 연결이 없으면 isDeleted=true로 변경하며 링크, 태그, 이미지와 번역 이력은 보존한다. 이미 isDeleted=true이면 연결 수를 검사하지 않고 멱등하게 같은 Response를 반환한다.

Response JSON:

{
  "success": true,
  "message": null,
  "data": {
    "originalWorkId": 71,
    "isDeleted": true
  },
  "errorProperty": null
}

11.7.8 ORIGINAL-WORK-06 · Assigned Character List

Request:

  • Path originalWorkId: Long
  • Request JSON 없음
  • Query page: Int?, size: Int?, search: String?, isActive: Boolean?
  • search는 캐릭터 이름의 대소문자 무시 부분 검색이다.
  • isActive=null이면 활성·비활성 캐릭터를 모두 반환한다.
  • 기본 정렬은 createdAtUtc DESC, characterId DESC다.

Response JSON:

{
  "success": true,
  "message": null,
  "data": {
    "items": [
      {
        "characterId": 101,
        "creatorId": 10001,
        "name": "루나",
        "imageUrl": "https://cdn.example.com/characters/101.webp",
        "isActive": true,
        "createdAtUtc": "2026-07-20T01:30:00Z"
      }
    ],
    "page": 0,
    "size": 20,
    "totalCount": 1,
    "hasNext": false
  },
  "errorProperty": null
}

11.7.9 ORIGINAL-WORK-07 · Assign Characters

CHAR-01에서 조회한 활성 AI 캐릭터를 배정한다. 이미 다른 원작에 연결된 캐릭터는 이 원작으로 이동하고, 이미 같은 원작에 연결된 캐릭터의 반복 배정은 멱등하다.

Request JSON:

{
  "characterIds": [101, 102]
}
  • characterIds는 중복 없는 양수 ID를 하나 이상 포함해야 한다.
  • 모든 ID가 존재하고 활성 AI 캐릭터인지 먼저 검증한다.
  • 하나라도 잘못되면 아무 캐릭터도 이동하지 않는다.

Response JSON:

{
  "success": true,
  "message": null,
  "data": {
    "originalWorkId": 71,
    "characterIds": [101, 102],
    "characterCount": 2
  },
  "errorProperty": null
}

11.7.10 ORIGINAL-WORK-08 · Unassign Characters

HTTP DELETE 요청에 JSON body를 전송하며 Content-Type: application/json을 사용한다.

Request JSON:

{
  "characterIds": [101, 102]
}
  • characterIds는 중복 없는 양수 ID를 하나 이상 포함해야 한다.
  • 활성·비활성 및 연결 creator 상태와 관계없이 기존 ChatCharacter를 해제할 수 있지만, 모든 캐릭터가 Path의 원작에 실제 연결되어 있어야 한다.
  • 미존재, 미연결 또는 다른 원작 소속 ID가 하나라도 있으면 400과 errorProperty="characterIds"를 반환하고 전체를 rollback한다.

Response JSON:

{
  "success": true,
  "message": null,
  "data": {
    "originalWorkId": 71,
    "characterIds": [101, 102],
    "characterCount": 0
  },
  "errorProperty": null
}

12. Content API

Base path는 /admin/ai-characters/{characterId}/contents다.

12.1 Endpoint Summary

Operation ID Caller Method Endpoint Request Response Data
CONTENT-01 Web GET /admin/ai-characters/{characterId}/contents Path: characterId; Query: page, size, search, isActive, status AdminPageResponse<AdminAiContentResponse>
CONTENT-02 Web GET /admin/ai-characters/{characterId}/contents/{contentId} Path: characterId, contentId AdminAiContentResponse
CONTENT-03 Web POST /admin/ai-characters/{characterId}/contents Path: characterId; multipart: contentFile, coverImage, request=AdminAiContentCreateRequest AdminAiContentCreateResponse
CONTENT-04 Web PUT /admin/ai-characters/{characterId}/contents/{contentId} Path: characterId, contentId; multipart: coverImage?, request=AdminAiContentUpdateRequest AdminMutationResponse
CONTENT-05 Web DELETE /admin/ai-characters/{characterId}/contents/{contentId} Path: characterId, contentId; Body 없음 AdminMutationResponse
CONTENT-06 Web PUT /admin/ai-characters/{characterId}/contents/{contentId}/pin Path: characterId, contentId; JSON: AdminAiContentPinRequest AdminAiContentPinResponse
CONTENT-07 Web GET /admin/ai-characters/metadata/content-themes 없음 List<AdminContentThemeResponse>

12.2 CONTENT-01 · Content List

Request

Field In Type Required Description
page Query Int No 기본 0
size Query Int No 기본 20, 최대 50
search Query String No 제목·설명 검색
isActive Query Boolean No null이면 전체
status Query String No PROCESSING, SCHEDULED, PUBLISHED, SUSPENDED, DELETED; null이면 전체

Response Data

AdminAiContentResponse

Field Type Nullable
contentId Long No
title String No
detail String No
coverImageUrl String Yes
contentUrl String Yes
contentUrlExpiresAtUtc String Yes
themeId Long No
theme String No
price Int No
purchaseOption String No
limited Int Yes
totalContentCount Int Yes
remainingContentCount Int Yes
isAdult Boolean No
isActive Boolean No
isPointAvailable Boolean No
isCommentAvailable Boolean No
isGeneratePreview Boolean No
isOnlyRental Boolean No
isFullDetailVisible Boolean No
languageCode String Yes
isPinned Boolean No
status String No
duration String Yes
releaseAtUtc String Yes
tags List<String> No
createdAtUtc String Yes
updatedAtUtc String Yes

기본 정렬은 고정 콘텐츠 우선, 그다음 응답 기준 createdAtUtc DESC, contentId DESC다. status는 DB에 저장하는 컬럼이 아니라 기존 content row와 연결 creator 상태에서 계산하는 PROCESSING, SCHEDULED, PUBLISHED, SUSPENDED, DELETED 중 하나다. Query application은 요청마다 하나의 UTC 기준 시각을 얻고 다음 우선순위를 목록 필터와 응답에 동일하게 적용한다.

  1. isActive=false && releaseDate=null이면 DELETED
  2. 연결 creator Member가 비활성이면 SUSPENDED
  3. isActive=true이면 PUBLISHED
  4. duration=null이면 PROCESSING
  5. isActive=false && duration!=null && releaseDate>now이면 SCHEDULED
  6. 나머지 비활성 콘텐츠는 SUSPENDED

저장된 content 경로는 상태 계산 컬럼이 아니라 Signed URL 발급 가능성을 판단하는 canonical output key로만 사용한다. AdminAiContentResponse.isActive와 CONTENT-01의 isActive 필터는 content.isActive && creatorMember.isActive인 유효 활성 상태를 사용한다. 신규·legacy 캐릭터 삭제 cascade는 raw content.isActive=false로 전환한다. 과거 데이터 불일치로 비활성 creator row에 raw content.isActive=true가 남아 있어도 관리자 응답은 isActive=false, status=SUSPENDED로 일관되게 반환한다.

coverImageUrl과 콘텐츠 재생 URL의 계약은 다음과 같다.

Field Contract
coverImageUrl 커버 저장 경로가 있을 때 반환하는 일반 CDN 절대 URL이다. Signed URL이 아니며 저장 경로나 빈 문자열을 반환하지 않는다.
contentUrl 가공 완료된 전체 오디오의 CloudFront Signed URL이다. DB/S3 원시 경로, input/* 원본 업로드 경로 또는 preview/* URL을 반환하지 않는다.
contentUrlExpiresAtUtc contentUrl의 만료 시각을 나타내는 ISO-8601 UTC 문자열이다. contentUrl=null이면 반드시 null이다.

Signed URL 발급 규칙은 다음과 같다.

  • SCHEDULED 또는 PUBLISHED이면서 12.9의 canonical output/{contentId}/... 상대 key와 유효한 duration이 모두 있을 때만 contentUrl을 발급한다.
  • PROCESSING, SUSPENDED, DELETED에서는 저장 경로가 남아 있어도 contentUrl=null, contentUrlExpiresAtUtc=null을 반환한다.
  • 목록과 상세는 응답을 만들 때마다 새로운 Signed URL을 발급한다.
  • TTL은 legacy 크리에이터 관리자와 동일하게 (duration의 HH 부분 + 2)시간이다.
  • URL policy의 실제 만료 시각과 contentUrlExpiresAtUtc는 하나의 기준 Instant에서 계산한 동일한 절대 시각이며 ISO-8601 표현 정밀도 안에서 일치해야 한다.
  • URL 만료 또는 만료 임박 시 클라이언트는 CONTENT-02를 다시 호출해 갱신한다. 별도 URL 갱신 Endpoint는 만들지 않는다.
  • 서명 실패 시 raw path, 일반 CDN URL 또는 빈 문자열로 fallback하지 않고 요청을 실패 처리한다.
  • CONTENT-01, CONTENT-02 응답에는 Cache-Control: private, no-store를 적용한다.

AdminAiContentResponse 예시는 다음과 같다.

{
  "contentId": 2001,
  "title": "비 오는 밤",
  "detail": "수면을 위한 빗소리",
  "coverImageUrl": "https://cdn.example.com/audio_content_cover/2001/cover.webp",
  "contentUrl": "https://audio.example.com/output/2001/audio.m4a?Expires=...",
  "contentUrlExpiresAtUtc": "2026-07-20T14:00:00Z",
  "themeId": 1,
  "theme": "ASMR",
  "price": 0,
  "purchaseOption": "BOTH",
  "limited": null,
  "totalContentCount": null,
  "remainingContentCount": null,
  "isAdult": false,
  "isActive": true,
  "isPointAvailable": false,
  "isCommentAvailable": true,
  "isGeneratePreview": false,
  "isOnlyRental": false,
  "isFullDetailVisible": true,
  "languageCode": "ko",
  "isPinned": false,
  "status": "PUBLISHED",
  "duration": "00:10:30",
  "releaseAtUtc": "2026-07-20T12:00:00Z",
  "tags": ["수면", "빗소리"],
  "createdAtUtc": "2026-07-20T11:00:00Z",
  "updatedAtUtc": "2026-07-20T12:00:00Z"
}

12.3 CONTENT-02 · Content Detail

Request

  • Path characterId: Long
  • Path contentId: Long
  • Query와 Body 없음

Response Data

  • AdminAiContentResponse
  • field와 Signed URL 규칙은 12.2와 같다.
  • 상세를 다시 조회할 때마다 새로운 contentUrl, contentUrlExpiresAtUtc를 반환한다.

12.4 CONTENT-03 · Content Create

Request

Content-Type: multipart/form-data
Part Type Required Description
contentFile File Yes 비동기 가공 파이프라인에 전달할 원본 오디오 파일
coverImage File Yes 커버 이미지
request JSON string Yes AdminAiContentCreateRequest

AdminAiContentCreateRequest

Field Type Required Default
title String Yes -
detail String Yes -
tags List<String> Yes -
price Int Yes -
purchaseOption String No "BOTH"
limited Int No null
releaseAtUtc String No null
themeId Long Yes -
isAdult Boolean No false
isGeneratePreview Boolean No false
isOnlyRental Boolean No false
isPointAvailable Boolean No false
isCommentAvailable Boolean No false
isFullDetailVisible Boolean No true
previewStartTime String No null
previewEndTime String No null
languageCode String No null
  • releaseAtUtc는 ISO-8601 UTC 형식이며 null이면 가공 완료 후 즉시 공개한다.
  • themeId는 0보다 커야 하고 활성 테마를 가리켜야 한다.
  • previewStartTime, previewEndTime은 둘 다 보내거나 둘 다 생략하며 값 형식은 HH:mm:ss다.
  • purchaseOptionBOTH, BUY_ONLY, RENT_ONLY만 허용한다.
  • price는 0 이상이며 1~4는 허용하지 않는다.
  • titledetail은 trim 후 빈 값일 수 없다.
  • limitednull 또는 1 이상이다.
  • 테마 ID 12, 13, 14는 price >= 5여야 하고 purchaseOption=BUY_ONLY로 저장한다.
  • 미리듣기 구간은 종료가 시작보다 늦고 길이가 15초 이상이어야 한다.
  • limited가 설정되었거나 최종 purchaseOption=BUY_ONLY이면 isOnlyRental=false로 저장한다. 이 규칙이 우선하므로 limitedRENT_ONLY를 함께 보내도 false다.
  • 위 조건이 없고 purchaseOption=RENT_ONLY이면 isOnlyRental=true로 저장한다. purchaseOption=BOTH일 때만 Request의 isOnlyRental 값을 사용한다.
  • price < 50이면 isFullDetailVisible=true로 저장하고, price >= 50일 때만 Request 값을 사용한다.
  • 무료 콘텐츠는 isGeneratePreview=false로 저장하고 worker에도 같은 값을 전달한다.
  • previewStartTime, previewEndTime은 생성 시 worker용 S3 object metadata로만 전달한다. DB 컬럼에 저장하거나 콘텐츠 목록·상세 Response로 반환하지 않는다.

Response Data

AdminAiContentCreateResponse

Field Type Nullable Description
contentId Long No 생성된 콘텐츠 ID
isActive Boolean No 생성 직후 false
status String No 생성 직후 PROCESSING
{
  "contentId": 2001,
  "isActive": false,
  "status": "PROCESSING"
}

원본 파일 저장 성공은 콘텐츠 공개 완료를 의미하지 않는다. 비동기 가공 완료 전까지 콘텐츠는 비활성 상태다. 응답의 PROCESSING은 저장된 status 값이 아니라 생성 직후의 isActive=false, releaseDate!=null, duration=null에서 계산한다.

12.5 CONTENT-04 · Content Update

Request

Content-Type: multipart/form-data
Part Type Required Description
coverImage File No 새 커버 이미지
request JSON string Yes AdminAiContentUpdateRequest

AdminAiContentUpdateRequest

Field Type Required
title String Yes
detail String Yes
tags List<String> Yes
price Int Yes
isAdult Boolean Yes
isPointAvailable Boolean Yes
isCommentAvailable Boolean Yes

contentIdisActive는 body에서 받지 않는다. 활성 상태 변경은 DELETE로만 수행한다.

수정 시에도 title·detail의 trim 후 빈 값 금지, 1~4 가격 금지 및 테마 12·13·14의 price >= 5 검증을 다시 적용한다. 기존 무료 콘텐츠는 price=0을 유지하거나 5 이상으로 변경할 수 있고, 기존 유료 콘텐츠의 가격은 5 이상만 허용해 무료 전환을 막는다. 변경 가격이 50 미만이면 isFullDetailVisible=true로 전환하고, 50 이상이면 기존 값을 유지한다.

purchaseOption, limited, releaseAtUtc, themeId, isGeneratePreview, isOnlyRental, isFullDetailVisiblelanguageCode는 생성 후 직접 변경할 수 없다. previewStartTime, previewEndTime은 생성 시에만 전달하는 worker metadata이므로 조회하거나 수정하지 않는다. 이 값들의 변경이 필요하면 기존 콘텐츠를 논리 삭제하고 새 콘텐츠를 등록한다.

Response Data

AdminMutationResponse

12.6 CONTENT-05 · Content Delete

  • Request: Path characterId, contentId; Body 없음
  • Response: { "id": 2001, "isActive": false }
  • 선택 캐릭터가 소유하지 않은 콘텐츠는 삭제할 수 없다.
  • 계산 상태가 PROCESSING, SCHEDULED, PUBLISHED, SUSPENDED인 콘텐츠는 모두 isActive=false, releaseDate=null로 논리 삭제하며 이후 DELETED로 계산한다.
  • 처리 중 삭제 후 도착한 upload callback은 콘텐츠를 다시 활성화하지 않는다.

12.7 CONTENT-06 · Content Pin

Request

AdminAiContentPinRequest

{
  "isPinned": true
}
Field Type Required
isPinned Boolean Yes

Response Data

AdminAiContentPinResponse

{
  "contentId": 2001,
  "isPinned": true,
  "replacedContentId": null
}

고정은 계산 상태가 PUBLISHED이고 공개 시각이 지난 콘텐츠에만 허용한다. 캐릭터별 최대 3개를 유지하며 네 번째 콘텐츠를 고정하면 가장 오래된 고정을 해제하고 그 ID를 replacedContentId로 반환한다. 고정 해제 응답의 replacedContentIdnull이다.

12.8 CONTENT-07 · Content Theme Metadata

Request

  • Body 없음
  • Query 없음

Response Data

AdminContentThemeResponse

Field Type Nullable
themeId Long No
name String No
imageUrl String Yes

legacy /audio-content/theme 또는 legacy service를 호출하지 않는다. v2 content query port가 같은 기준정보 테이블을 조회한다.

12.9 Existing AWS Content Upload Completion Integration

이번 범위에는 신규 upload-complete Endpoint를 만들지 않는다. 현재 AWS S3 Trigger 기반 가공 worker는 기존 계약을 그대로 사용한다.

PUT /audio-content/upload-complete
Authorization: Bearer <bot-or-admin-jwt>
Content-Type: application/json
{
  "contentId": 2001,
  "contentPath": "output/2001/2001-content.m4a",
  "duration": "00:10:30"
}

기존 성공 Response JSON은 다음과 같다.

{
  "success": true,
  "message": null,
  "data": {},
  "errorProperty": null
}
  • 위 Method, Path, Request와 ADMIN/BOT의 성공 Response 계약은 기존 AWS 연동 계약이며 이번 범위에서 변경하지 않는다.
  • 인증 정보가 없거나 JWT가 유효하지 않으면 401, 인증됐지만 ADMIN/BOT이 아니면 403을 반환한다. 이 인가 status는 유지해야 하는 기존 보안 계약이다.
  • 이 Endpoint는 기존 API이므로 신규 Operation ID를 부여하거나 12.1의 신규 Endpoint 수에 포함하지 않는다.
  • v2 콘텐츠 생성도 기존과 같은 content 테이블 필드, S3 bucket의 input/{contentId}/{contentId}-content-... 경로와 object metadata 계약을 사용한다. object basename은 기존 generateFileName(prefix = "${contentId}-content") 규칙을 따르며 metadata는 기존 generate_preview, 선택 preview_start_time, preview_end_time만 전달한다.
  • v2 생성 여부를 저장하는 DB 컬럼이나 worker metadata를 추가하지 않는다. 기존 callback Controller·Request·Response·Service 호출 흐름에도 V1/V2 dispatcher를 추가하지 않는다.
  • callback은 기존과 같이 가공 완료 contentPathduration을 기록한다. 공개 활성화는 releaseDate!=null && releaseDate<=now이고 연결 creator Member가 활성인 경우에만 허용한다.
  • releaseDate=null인 삭제 콘텐츠와 비활성 creator의 콘텐츠는 callback 이후에도 raw content.isActive=false를 유지한다. 과거 불일치 row가 raw content.isActive=true인 상태로 callback을 받으면 false로 보정한다. 두 경우 모두 구독자 공개 알림이나 home news를 발행하지 않는다.
  • 기존 예약 공개 scheduler component의 cron·lock은 변경하지 않는다. 예약 공개 대상 query는 isActive=false, releaseDate!=null, releaseDate<=now, duration!=null과 활성 creator를 모두 만족하는 기존 테이블 row만 반환한다.
  • CONTENT-01, CONTENT-02는 저장된 contentPath가 정확히 output/{contentId}/로 시작하는 canonical 상대 key인지 Signed URL 발급 직전에 검증한다. 각 후속 segment는 [A-Za-z0-9][A-Za-z0-9._-]*만 허용하며 빈 segment, ., .., URI scheme, host, 선행 / 또는 \, query, fragment, percent-encoding, input/, raw/, preview/와 다른 콘텐츠 ID를 거부한다.
  • worker 코드·스케줄·AWS Trigger 설정 변경은 이번 범위가 아니다.
  • 새 내부 callback API는 실제 AWS 전환 일정, 호출 주체와 배포 순서가 확정될 때 별도 PRD에서 Path, 인증 및 Request/Response를 정의한다.

13. Content Comment API

Base path는 /admin/ai-characters/{characterId}/contents/{contentId}/comments다.

13.1 Endpoint Summary

Operation ID Method Endpoint Request Response Data
CONTENT-COMMENT-01 GET /admin/ai-characters/{characterId}/contents/{contentId}/comments Path: characterId, contentId; Query: page, size, isActive AdminPageResponse<AdminContentCommentResponse>
CONTENT-COMMENT-02 GET /admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId}/replies Path: characterId, contentId, commentId; Query: page, size, isActive AdminPageResponse<AdminContentCommentResponse>
CONTENT-COMMENT-03 POST /admin/ai-characters/{characterId}/contents/{contentId}/comments Path: characterId, contentId; JSON: AdminContentCommentCreateRequest AdminMutationResponse
CONTENT-COMMENT-04 PUT /admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId} Path: characterId, contentId, commentId; JSON: AdminCommentUpdateRequest AdminMutationResponse
CONTENT-COMMENT-05 DELETE /admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId} Path: characterId, contentId, commentId; Body 없음 AdminMutationResponse

13.2 CONTENT-COMMENT-01/02 · List Request

Field In Type Required Description
page Query Int No 기본 0
size Query Int No 기본 20, 최대 50
isActive Query Boolean No 기본 true; false이면 논리 삭제 댓글 조회
  • CONTENT-COMMENT-01은 Path characterId, contentId와 위 Query를 받는다.
  • CONTENT-COMMENT-02는 Path characterId, contentId, 루트 commentId와 위 Query를 받는다.
  • 루트 목록 Endpoint는 parentCommentId=null인 댓글만 반환하고 createdAtUtc DESC, commentId DESC로 정렬한다.
  • 답글 목록 Endpoint는 지정한 루트의 직접 자식만 반환하고 createdAtUtc ASC, commentId ASC로 정렬한다.
  • replyCount는 현재 필터와 관계없이 활성 직접 답글 수다.

13.3 CONTENT-COMMENT-01/02 · Comment Response

AdminContentCommentResponse

Field Type Nullable
commentId Long No
parentCommentId Long Yes
writerId Long No
writerNickname String No
writerProfileImageUrl String Yes
content String No
languageCode String Yes
donationCan Int No
isSecret Boolean No
isActive Boolean No
replyCount Int No
createdAtUtc String No
updatedAtUtc String Yes

13.4 CONTENT-COMMENT-03 · Comment Create

Request

AdminContentCommentCreateRequest

{
  "content": "답변 내용",
  "parentCommentId": null,
  "isSecret": false,
  "languageCode": "ko"
}
Field Type Required Default
content String Yes -
parentCommentId Long No null
isSecret Boolean No false
languageCode String No null

작성자는 Request에서 받지 않고 선택 AI 캐릭터의 creatorMember로 고정한다. 답글이면 부모 댓글이 같은 contentId에 속하고 활성 상태이며 parentCommentId=null인 최상위 댓글인지 검증한다. 답글의 답글은 허용하지 않는다.

Response Data

AdminMutationResponse

13.5 CONTENT-COMMENT-04 · Comment Update

Request

AdminCommentUpdateRequest

{
  "content": "수정한 답변"
}
Field Type Required
content String Yes

AI 캐릭터의 creatorMember가 직접 작성한 댓글만 본문을 수정할 수 있다.

parentCommentId, isSecret, languageCode는 등록 후 변경할 수 없다.

Response Data

AdminMutationResponse

13.6 CONTENT-COMMENT-05 · Comment Delete

  • Request: Path characterId, contentId, commentId; Body 없음
  • Response Data: { "id": 2101, "isActive": false }
  • AI 캐릭터가 작성한 댓글은 작성자 권한으로 논리 삭제할 수 있다.
  • 선택 AI 캐릭터 소유 콘텐츠에 달린 댓글은 다른 사용자가 작성했어도 콘텐츠 소유자 권한으로 논리 삭제할 수 있다.
  • 다른 크리에이터의 콘텐츠에 달린 댓글은 AI 캐릭터가 작성했더라도 이 관리자 경로에서 삭제할 수 없다.
  • 삭제는 isActive=false이며 답글을 물리 삭제하지 않는다.

14. Series API

Base path는 /admin/ai-characters/{characterId}/series다.

14.1 Endpoint Summary

Operation ID Method Endpoint Request Response Data
SERIES-01 GET /admin/ai-characters/{characterId}/series Path: characterId; Query: page, size, search, isActive, state AdminPageResponse<AdminSeriesSummaryResponse>
SERIES-02 GET /admin/ai-characters/{characterId}/series/{seriesId} Path: characterId, seriesId AdminSeriesDetailResponse
SERIES-03 POST /admin/ai-characters/{characterId}/series Path: characterId; multipart: image, request=AdminSeriesCreateRequest AdminMutationResponse
SERIES-04 PUT /admin/ai-characters/{characterId}/series/{seriesId} Path: characterId, seriesId; multipart: image?, request=AdminSeriesUpdateRequest AdminMutationResponse
SERIES-05 DELETE /admin/ai-characters/{characterId}/series/{seriesId} Path: characterId, seriesId; Body 없음 AdminMutationResponse
SERIES-06 GET /admin/ai-characters/{characterId}/series/{seriesId}/contents Path: characterId, seriesId; Query: page, size AdminPageResponse<AdminSeriesContentResponse>
SERIES-07 GET /admin/ai-characters/{characterId}/series/{seriesId}/available-contents Path: characterId, seriesId; Query: page, size, search AdminPageResponse<AdminSeriesContentResponse>
SERIES-08 POST /admin/ai-characters/{characterId}/series/{seriesId}/contents Path: characterId, seriesId; JSON: AdminSeriesContentsAddRequest AdminSeriesContentsMutationResponse
SERIES-09 DELETE /admin/ai-characters/{characterId}/series/{seriesId}/contents/{contentId} Path: characterId, seriesId, contentId; Body 없음 AdminSeriesContentsMutationResponse
SERIES-10 PUT /admin/ai-characters/{characterId}/series/orders Path: characterId; JSON: AdminSeriesOrderRequest AdminSeriesOrderResponse
SERIES-11 GET /admin/ai-characters/metadata/series-genres 없음 List<AdminSeriesGenreResponse>

14.2 SERIES-01 · Series List

목록 Request는 다음 Query를 받는다.

Field Type Required Description
page Int No 기본 0
size Int No 기본 20, 최대 50
search String No 제목·소개 검색
isActive Boolean No null이면 전체
state String No PROCEEDING, SUSPEND, COMPLETE

AdminSeriesSummaryResponse

Field Type Nullable
seriesId Long No
title String No
introduction String No
coverImageUrl String Yes
publishedDaysOfWeek List<String> No
genreId Long No
isAdult Boolean No
state String No
isActive Boolean No
writer String Yes
studio String Yes

기본 정렬은 order ASC, seriesId ASC다.

14.3 SERIES-02 · Series Detail

Request

  • Path characterId: Long
  • Path seriesId: Long
  • Query와 Body 없음

Response Data

AdminSeriesDetailResponse는 목록 필드에 다음을 추가한다.

Field Type Nullable
genre String No
keyword String No
createdAtUtc String Yes
updatedAtUtc String Yes

14.4 SERIES-03 · Series Create

Request

Content-Type: multipart/form-data
Part Type Required
image File Yes
request JSON string Yes

AdminSeriesCreateRequest

Field Type Required Default
title String Yes -
introduction String Yes -
publishedDaysOfWeek Set<String> Yes -
keyword String Yes -
genreId Long Yes -
isAdult Boolean No false
writer String No null
studio String No null

title, introduction, keyword는 trim 후 빈 값일 수 없고 genreId는 활성 장르를 가리켜야 한다. 요일 값은 SUN, MON, TUE, WED, THU, FRI, SAT, RANDOM만 허용하며 빈 집합은 허용하지 않는다. RANDOM은 다른 요일 값과 함께 사용할 수 없다.

Response Data

AdminMutationResponse

{
  "id": 3001,
  "isActive": true
}

14.5 SERIES-04 · Series Update

Request

Content-Type: multipart/form-data
Part Type Required
image File No
request JSON string Yes

AdminSeriesUpdateRequest

Field Type Required
title String Yes
introduction String Yes
keyword String Yes
publishedDaysOfWeek Set<String> Yes
genreId Long Yes
isAdult Boolean Yes
state String Yes
writer String? Yes
studio String? Yes

seriesIdisActive는 body에서 받지 않는다.

등록과 같은 제목·소개·키워드·장르·요일 검증을 적용한다. statePROCEEDING, SUSPEND, COMPLETE만 허용한다.

Response Data

AdminMutationResponse

14.6 SERIES-05 · Series Delete

  • Request: Path characterId, seriesId; Body 없음
  • Response: { "id": 3001, "isActive": false }
  • 시리즈를 삭제해도 포함 콘텐츠는 삭제하지 않는다.

14.7 SERIES-06~09 · Series Contents

AdminSeriesContentResponse

Field Type Nullable
contentId Long No
title String No
coverImageUrl String Yes
isActive Boolean No
order Int Yes

SERIES-06 · Included Content List

Request:

Field In Type Required
page Query Int No
size Query Int No
  • Path는 characterId, seriesId를 받는다.
  • Response Data는 AdminPageResponse<AdminSeriesContentResponse>다.
  • 기본 정렬은 order ASC, contentId ASC다.

SERIES-07 · Available Content List

Request:

Field In Type Required
page Query Int No
size Query Int No
search Query String No
  • Path는 characterId, seriesId를 받는다.
  • Response Data는 AdminPageResponse<AdminSeriesContentResponse>다.
  • 미포함 콘텐츠 검색은 createdAtUtc DESC, contentId DESC로 정렬한다.

SERIES-08 · Add Contents

Request AdminSeriesContentsAddRequest:

{
  "contentIds": [2001, 2002]
}
Field Type Required
contentIds List<Long> Yes

Response Data AdminSeriesContentsMutationResponse:

{
  "seriesId": 3001,
  "affectedContentIds": [2001, 2002]
}

Response의 affectedContentIds는 이번 요청으로 추가된 ID다. 선택 캐릭터가 소유한 활성 콘텐츠만 추가할 수 있다. 중복 ID 또는 소유권·활성 조건을 충족하지 않는 ID가 하나라도 있으면 전체 요청을 rollback한다.

이미 연결된 콘텐츠의 중복 추가는 409다.

SERIES-09 · Remove Content

  • Request: Path characterId, seriesId, contentId; Body 없음
  • Response Data: { "seriesId": 3001, "affectedContentIds": [2001] }
  • 제거는 series_content join row만 물리 삭제하고 시리즈와 콘텐츠는 그대로 유지한다.
  • 같은 제거 요청을 반복하면 affectedContentIds=[]인 200을 반환한다.
  • 제거한 콘텐츠를 다시 추가하면 새 join row를 생성해 현재 마지막 순번 뒤에 배치한다.

14.8 SERIES-10 · Series Order

Request

AdminSeriesOrderRequest

{
  "seriesIds": [3003, 3001, 3002]
}

Response Data

AdminSeriesOrderResponse

{
  "seriesIds": [3003, 3001, 3002]
}

seriesIds는 선택 캐릭터가 소유한 활성 시리즈 전체를 중복·누락 없이 정확히 한 번씩 포함해야 한다. 조건을 충족하지 않으면 400을 반환하고 순서를 변경하지 않는다. 기존 updateSeriesOrders(ids)는 소유자 범위가 없으므로 호출하지 않는다.

14.9 SERIES-11 · Series Genre Metadata

Request

  • Body 없음
  • Query 없음

Response Data

AdminSeriesGenreResponse

Field Type Nullable
genreId Long No
name String No

legacy /creator-admin/audio-content/series/genre 또는 legacy service를 호출하지 않는다. v2 series query port가 같은 기준정보 테이블을 조회한다.

15. Community Post API

Base path는 /admin/ai-characters/{characterId}/community-posts다.

15.1 Endpoint Summary

Operation ID Method Endpoint Request Response Data
COMMUNITY-POST-01 GET /admin/ai-characters/{characterId}/community-posts Path: characterId; Query: page, size, isActive AdminPageResponse<AdminCommunityPostResponse>
COMMUNITY-POST-02 GET /admin/ai-characters/{characterId}/community-posts/{postId} Path: characterId, postId AdminCommunityPostResponse
COMMUNITY-POST-03 POST /admin/ai-characters/{characterId}/community-posts Path: characterId; multipart: audioFile?, postImage?, request=AdminCommunityPostCreateRequest AdminMutationResponse
COMMUNITY-POST-04 PUT /admin/ai-characters/{characterId}/community-posts/{postId} Path: characterId, postId; multipart: postImage?, request=AdminCommunityPostUpdateRequest AdminMutationResponse
COMMUNITY-POST-05 DELETE /admin/ai-characters/{characterId}/community-posts/{postId} Path: characterId, postId; Body 없음 AdminMutationResponse
COMMUNITY-POST-06 PUT /admin/ai-characters/{characterId}/community-posts/{postId}/fixed Path: characterId, postId; JSON: AdminCommunityPostFixedRequest AdminCommunityPostFixedResponse

15.2 COMMUNITY-POST-01 · List Request

Field In Type Required Description
page Query Int No 기본 0
size Query Int No 기본 20, 최대 50
isActive Query Boolean No null이면 전체

기본 정렬은 고정 게시글 우선, 그다음 createdAtUtc DESC, postId DESC다.

15.3 COMMUNITY-POST-01/02 · Community Post Response

COMMUNITY-POST-01은 15.2의 Query를 받고 AdminPageResponse<AdminCommunityPostResponse>를 반환한다. COMMUNITY-POST-02는 Path characterId, postId만 받고 AdminCommunityPostResponse를 반환한다.

AdminCommunityPostResponse

Field Type Nullable
postId Long No
creatorId Long No
creatorNickname String No
creatorProfileImageUrl String Yes
imageUrl String Yes
audioUrl String Yes
content String No
price Int No
isCommentAvailable Boolean No
isAdult Boolean No
isFixed Boolean No
isActive Boolean No
likeCount Int No
commentCount Int No
createdAtUtc String No
updatedAtUtc String Yes

관리자 응답은 유료 게시글의 본문을 축약하지 않고 전체 내용을 반환한다.

15.4 COMMUNITY-POST-03 · Community Post Create

Request

Content-Type: multipart/form-data
Part Type Required
audioFile File No
postImage File No
request JSON string Yes

AdminCommunityPostCreateRequest

Field Type Required Default
content String Yes -
isCommentAvailable Boolean Yes -
isAdult Boolean Yes -
price Int No 0
  • content는 trim 후 빈 값일 수 없고 price는 0 이상이다.
  • price > 0인 유료 게시글은 postImage가 필수다.
  • audioFile을 보내는 게시글은 가격과 관계없이 postImage가 필수다.
  • 업로드 이미지의 실제 MIME type은 image/jpeg, image/png, image/gif 중 하나여야 하며 GIF는 유료 게시글에서만 허용한다.
  • audioFile은 빈 파일일 수 없다. v2 community web adapter가 파일명 확장자가 아니라 실제 bytes를 검사해 M4A/AAC 계열 MIME type인 audio/mp4, audio/x-m4a, audio/aac만 허용하고, 다른 codec이나 MIME type은 400으로 거부한다.

Response Data

AdminMutationResponse

{
  "id": 4001,
  "isActive": true
}

15.5 COMMUNITY-POST-04 · Community Post Update

Request

Part Type Required
postImage File No
request JSON string Yes

AdminCommunityPostUpdateRequest

Field Type Required
content String Yes
isCommentAvailable Boolean Yes
isAdult Boolean Yes

postIdisActive는 body에서 받지 않는다.

priceaudioFile은 등록 후 변경하거나 제거할 수 없다. 선택 postImage를 보내면 이미지를 교체하고, 생략하면 기존 이미지를 유지한다. 이미지 제거는 지원하지 않는다. 수정 시에도 content는 trim 후 빈 값일 수 없다. 교체 이미지는 실제 MIME type이 image/jpeg, image/png, image/gif 중 하나여야 하며, 기존 게시글의 price=0이면 GIF를 허용하지 않는다. 기존 유료 또는 오디오 게시글은 이미지가 없는 상태로 변경할 수 없다.

Response Data

AdminMutationResponse

15.6 COMMUNITY-POST-05 · Community Post Delete

  • Request: Path characterId, postId; Body 없음
  • Response: { "id": 4001, "isActive": false }
  • 게시글 삭제 시 댓글을 물리 삭제하지 않는다.

15.7 COMMUNITY-POST-06 · Community Post Fixed

Request

AdminCommunityPostFixedRequest

{
  "isFixed": true
}
Field Type Required
isFixed Boolean Yes

Response Data

AdminCommunityPostFixedResponse

{
  "postId": 4001,
  "isFixed": true
}

활성 게시글만 고정할 수 있고 캐릭터별 최대 3개까지 허용한다. 이미 3개가 고정된 상태에서 다른 게시글을 고정하면 자동 교체하지 않고 409를 반환한다.

16. Community Post Comment API

Base path는 /admin/ai-characters/{characterId}/community-posts/{postId}/comments다.

16.1 Endpoint Summary

Operation ID Method Endpoint Request Response Data
COMMUNITY-COMMENT-01 GET /admin/ai-characters/{characterId}/community-posts/{postId}/comments Path: characterId, postId; Query: page, size, isActive AdminPageResponse<AdminCommunityCommentResponse>
COMMUNITY-COMMENT-02 GET /admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}/replies Path: characterId, postId, commentId; Query: page, size, isActive AdminPageResponse<AdminCommunityCommentResponse>
COMMUNITY-COMMENT-03 POST /admin/ai-characters/{characterId}/community-posts/{postId}/comments Path: characterId, postId; JSON: AdminCommunityCommentCreateRequest AdminMutationResponse
COMMUNITY-COMMENT-04 PUT /admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId} Path: characterId, postId, commentId; JSON: AdminCommentUpdateRequest AdminMutationResponse
COMMUNITY-COMMENT-05 DELETE /admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId} Path: characterId, postId, commentId; Body 없음 AdminMutationResponse

16.2 COMMUNITY-COMMENT-01/02 · List Request and Response

  • Query는 page, size, 선택 isActive를 받으며 공통 페이징 규칙을 적용한다. isActive 기본값은 true이고 false이면 논리 삭제 댓글을 조회한다.
  • Response item은 AdminCommunityCommentResponse를 사용한다.
  • COMMUNITY-COMMENT-01은 Path characterId, postId를 받는다.
  • COMMUNITY-COMMENT-02는 Path characterId, postId, 루트 commentId를 받는다.

루트 목록 Endpoint는 parentCommentId=null인 댓글만 createdAtUtc DESC, commentId DESC로 반환한다. 답글 목록 Endpoint는 지정한 루트의 직접 자식만 createdAtUtc ASC, commentId ASC로 반환한다. replyCount는 현재 필터와 관계없이 활성 직접 답글 수다.

Field Type Nullable
commentId Long No
parentCommentId Long Yes
writerId Long No
writerNickname String No
writerProfileImageUrl String Yes
content String No
isSecret Boolean No
isActive Boolean No
replyCount Int No
createdAtUtc String No
updatedAtUtc String Yes

16.3 COMMUNITY-COMMENT-03 · Comment Create

AdminCommunityCommentCreateRequest

{
  "content": "댓글 내용",
  "parentCommentId": null,
  "isSecret": false
}
Field Type Required Default
content String Yes -
parentCommentId Long No null
isSecret Boolean No false

작성자는 선택 AI 캐릭터의 creatorMember로 고정한다. 부모 댓글은 같은 postId에 속하고 활성 상태이며 parentCommentId=null인 최상위 댓글이어야 한다. 답글의 답글은 허용하지 않는다.

Response Data는 AdminMutationResponse다.

16.4 COMMUNITY-COMMENT-04 · Comment Update

AdminCommentUpdateRequest

{
  "content": "수정한 댓글"
}
Field Type Required
content String Yes

AI 캐릭터의 creatorMember가 작성한 댓글만 본문을 수정할 수 있다.

parentCommentIdisSecret은 등록 후 변경할 수 없다.

Response Data는 AdminMutationResponse다.

16.5 COMMUNITY-COMMENT-05 · Comment Delete

  • Request: Path characterId, postId, commentId; Body 없음
  • Response Data: { "id": 4101, "isActive": false }
  • AI 캐릭터가 작성한 댓글은 작성자 권한으로 논리 삭제할 수 있다.
  • 선택 AI 캐릭터 소유 게시글에 달린 댓글은 다른 사용자가 작성했어도 게시글 소유자 권한으로 논리 삭제할 수 있다.
  • 다른 크리에이터의 게시글에 달린 댓글은 이 관리자 경로에서 삭제할 수 없다.

17. FanTalk API

Base path는 /admin/ai-characters/{characterId}/fan-talks다.

17.1 Endpoint Summary

Operation ID Method Endpoint Request Response Data
FAN-TALK-01 GET /admin/ai-characters/{characterId}/fan-talks Path: characterId; Query: page, size AdminFanTalkPageResponse
FAN-TALK-02 POST /admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies Path: characterId, fanTalkId; JSON: AdminFanTalkReplyCreateRequest AdminFanTalkReplyResponse
FAN-TALK-03 PUT /admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId} Path: characterId, fanTalkId, replyId; JSON: AdminFanTalkReplyUpdateRequest AdminFanTalkReplyResponse
FAN-TALK-04 DELETE /admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId} Path: characterId, fanTalkId, replyId; Body 없음 AdminMutationResponse
FAN-TALK-05 DELETE /admin/ai-characters/{characterId}/fan-talks/{fanTalkId} Path: characterId, fanTalkId; Body 없음 AdminMutationResponse

답글 전용 GET Endpoint는 만들지 않는다. FanTalk 목록 조회 시 각 루트 항목의 creatorReplies에 활성 AI 캐릭터 답글을 함께 반환한다.

17.2 FAN-TALK-01 · FanTalk List

Request

Field In Type Required Description
page Query Int No 기본 0
size Query Int No 기본 20, 최대 50

활성 최상위 FanTalk만 createdAtUtc DESC, fanTalkId DESC로 반환한다. 중첩 AI 캐릭터 답글은 활성 직접 답글만 createdAtUtc ASC, replyId ASC로 정렬한다.

Response Data

기존 v2 FanTalk domain/query port는 활용할 수 있지만 공개 채널 API DTO를 직접 반환하지 않는다. 관리자 API 전용 AdminFanTalkPageResponse로 변환한다.

Field Type Nullable
items List<AdminFanTalkResponse> No
page Int No
size Int No
totalCount Long No
hasNext Boolean No

AdminFanTalkResponse

Field Type Nullable
fanTalkId Long No
writerId Long No
writerNickname String No
writerProfileImageUrl String No
content String No
createdAtUtc String No
creatorReplies List<AdminFanTalkReplyResponse> No

중첩 AdminFanTalkReplyResponse는 다음 필드를 반환한다.

Field Type Nullable
replyId Long No
fanTalkId Long No
writerId Long No
writerNickname String No
writerProfileImageUrl String No
content String No
isActive Boolean No
createdAtUtc String No
updatedAtUtc String Yes

프로필 이미지가 없는 작성자와 AI 캐릭터에는 기존 CDN 기본 프로필 이미지 URL을 적용하므로 writerProfileImageUrl은 null이 아니다.

17.3 FAN-TALK-02 · FanTalk Reply Create

Request

AdminFanTalkReplyCreateRequest

{
  "content": "AI 캐릭터 답글",
  "languageCode": "ko"
}
Field Type Required Default
content String Yes -
languageCode String No null

루트 fanTalkId가 선택 AI 캐릭터를 대상으로 한 활성 FanTalk인지 검증한다. 작성자는 선택 AI 캐릭터의 creatorMember로 고정한다.

Response Data

AdminFanTalkReplyResponse

Field Type Nullable
replyId Long No
fanTalkId Long No
writerId Long No
writerNickname String No
writerProfileImageUrl String No
content String No
isActive Boolean No
createdAtUtc String No
updatedAtUtc String Yes

17.4 FAN-TALK-03 · FanTalk Reply Update

Request

AdminFanTalkReplyUpdateRequest

{
  "content": "수정한 AI 캐릭터 답글"
}
Field Type Required
content String Yes

선택 AI 캐릭터의 creatorMember가 작성했고 해당 루트 FanTalk의 답글인 경우에만 수정한다.

fanTalkIdlanguageCode는 등록 후 변경할 수 없다.

Response Data

AdminFanTalkReplyResponse

17.5 FAN-TALK-04 · FanTalk Reply Delete

  • Request: Path characterId, fanTalkId, replyId; Body 없음
  • Response: { "id": 5002, "isActive": false }
  • AI 캐릭터가 작성한 답글만 논리 삭제할 수 있다.
  • 팬이 작성한 루트 FanTalk는 답글 삭제 Endpoint에서 함께 삭제하지 않는다.

17.6 FAN-TALK-05 · FanTalk Root Moderation

선택 AI 캐릭터를 대상으로 작성된 루트 FanTalk는 크리에이터 소유자 권한으로 논리 삭제할 수 있다.

  • Request: Path characterId, fanTalkId; Body 없음
  • Response: { "id": 5001, "isActive": false }
  • 팬이 작성한 본문을 수정할 수는 없다.
  • 루트 FanTalk 삭제는 하위 AI 답글을 물리 삭제하지 않지만 공개 조회에서 루트와 답글을 함께 제외한다.

18. Content Category and Creator Channel Settings API

18.1 Content Category Endpoint Summary

Base path는 /admin/ai-characters/{characterId}/content-categories다.

Operation ID Method Endpoint Request Response Data
CATEGORY-01 GET /admin/ai-characters/{characterId}/content-categories Path: characterId; Query: page, size, isActive AdminPageResponse<AdminContentCategoryResponse>
CATEGORY-02 POST /admin/ai-characters/{characterId}/content-categories Path: characterId; JSON: AdminContentCategoryCreateRequest AdminContentCategoryMutationResponse
CATEGORY-03 PUT /admin/ai-characters/{characterId}/content-categories/{categoryId} Path: characterId, categoryId; JSON: AdminContentCategoryUpdateRequest AdminContentCategoryMutationResponse
CATEGORY-04 DELETE /admin/ai-characters/{characterId}/content-categories/{categoryId} Path: characterId, categoryId; Body 없음 AdminContentCategoryMutationResponse
CATEGORY-05 PUT /admin/ai-characters/{characterId}/content-categories/orders Path: characterId; JSON: AdminContentCategoryOrderRequest AdminContentCategoryOrderResponse
CATEGORY-06 GET /admin/ai-characters/{characterId}/content-categories/{categoryId}/contents Path: characterId, categoryId; Query: page, size AdminPageResponse<AdminCategoryContentResponse>
CATEGORY-07 GET /admin/ai-characters/{characterId}/content-categories/{categoryId}/available-contents Path: characterId, categoryId; Query: page, size, search AdminPageResponse<AdminCategoryContentResponse>
CATEGORY-08 POST /admin/ai-characters/{characterId}/content-categories/{categoryId}/contents Path: characterId, categoryId; JSON: AdminCategoryContentsAddRequest AdminCategoryContentsMutationResponse
CATEGORY-09 DELETE /admin/ai-characters/{characterId}/content-categories/{categoryId}/contents/{contentId} Path: characterId, categoryId, contentId; Body 없음 AdminCategoryContentsMutationResponse

18.2 CATEGORY-01 · Content Category List

목록 Request는 공통 page, size와 선택 isActive를 받는다. isActive=null이면 활성·비활성 카테고리를 모두 반환한다.

AdminContentCategoryResponse

Field Type Nullable
categoryId Long No
title String No
order Int No
contentCount Int No
isActive Boolean No

기본 정렬은 order ASC, categoryId ASC다.

18.3 CATEGORY-02 · Content Category Create

Request

AdminContentCategoryCreateRequest

{
  "title": "ASMR",
  "contentIds": [2001, 2002]
}
Field Type Required Default
title String Yes -
contentIds List<Long> No []

Response Data

AdminContentCategoryMutationResponse

Field Type Nullable
categoryId Long No
isActive Boolean No
{
  "categoryId": 6001,
  "isActive": true
}

모든 contentIds는 중복이 없어야 하며 선택 AI 캐릭터가 소유한 활성 콘텐츠여야 한다. 하나라도 조건을 충족하지 않으면 카테고리를 생성하지 않는다. title은 trim 후 2자 이상이어야 하며 같은 캐릭터의 활성 카테고리 제목과 중복될 수 없다.

18.4 CATEGORY-03 · Content Category Update

Request

AdminContentCategoryUpdateRequest

{
  "title": "수면 ASMR"
}
Field Type Required Default
title String Yes -

등록과 동일하게 trim 후 2자 이상 및 같은 캐릭터의 활성 카테고리 제목 중복 금지 규칙을 적용한다.

Response Data

AdminContentCategoryMutationResponse

18.5 CATEGORY-04 · Content Category Delete

  • Request: Path characterId, categoryId; Body 없음
  • Response: { "categoryId": 6001, "isActive": false }
  • 카테고리와 콘텐츠 연결만 비활성화하고 콘텐츠는 삭제하지 않는다.

18.6 CATEGORY-05 · Content Category Order

Request

AdminContentCategoryOrderRequest

{
  "categoryIds": [6003, 6001, 6002]
}
Field Type Required
categoryIds List<Long> Yes

Response Data

AdminContentCategoryOrderResponse

{
  "categoryIds": [6003, 6001, 6002]
}

categoryIds는 선택 캐릭터가 소유한 활성 카테고리 전체를 중복·누락 없이 정확히 한 번씩 포함해야 한다. 조건을 충족하지 않으면 400을 반환하고 순서를 변경하지 않는다.

18.7 CATEGORY-06~09 · Category Contents

AdminCategoryContentResponse

Field Type Nullable
contentId Long No
title String No
coverImageUrl String Yes
isActive Boolean No

CATEGORY-06 · Included Content List

  • Request: Path characterId, categoryId; Query page, size
  • Response Data: AdminPageResponse<AdminCategoryContentResponse>
  • 활성 연결과 활성 콘텐츠만 대상으로 CategoryContent.orders ASC, contentId ASC로 정렬한다.

CATEGORY-07 · Available Content List

  • Request: Path characterId, categoryId; Query page, size, 선택 search
  • Response Data: AdminPageResponse<AdminCategoryContentResponse>
  • 활성 콘텐츠 중 해당 카테고리에 포함되지 않은 콘텐츠를 createdAtUtc DESC, contentId DESC로 정렬한다.

CATEGORY-08 · Add Contents

Request AdminCategoryContentsAddRequest:

{
  "contentIds": [2001, 2002]
}
Field Type Required
contentIds List<Long> Yes

Response Data AdminCategoryContentsMutationResponse:

{
  "categoryId": 6001,
  "affectedContentIds": [2001, 2002]
}

추가 대상은 중복이 없어야 하며 모두 선택 캐릭터 소유의 활성 콘텐츠여야 한다. 하나라도 조건을 충족하지 않으면 전체 요청을 rollback한다.

기존 비활성 연결이 있으면 새 row를 만들지 않고 다시 활성화해 현재 마지막 orders 뒤에 배치한다. 이미 활성인 콘텐츠의 중복 추가는 409다.

CATEGORY-09 · Remove Content

  • Request: Path characterId, categoryId, contentId; Body 없음
  • Response Data: { "categoryId": 6001, "affectedContentIds": [2001] }
  • 제거 대상 연결이 존재하면 CategoryContent.isActive=false로 논리 삭제한다.
  • 같은 제거 요청을 반복하면 affectedContentIds=[]인 200을 반환한다.

18.8 Channel Notice Endpoint Summary

Operation ID Method Endpoint Request Response Data
NOTICE-01 GET /admin/ai-characters/{characterId}/channel-notice Path: characterId AdminChannelNoticeResponse
NOTICE-02 PUT /admin/ai-characters/{characterId}/channel-notice Path: characterId; JSON: AdminChannelNoticeUpsertRequest AdminChannelNoticeResponse

별도 등록 Endpoint를 만들지 않고 PUT을 upsert로 사용한다.

18.9 NOTICE-01 · Channel Notice Read

Request

  • Path characterId: Long
  • Query와 Body 없음

Response Data

AdminChannelNoticeResponse

Field Type Nullable
characterId Long No
creatorId Long No
notice String No
updatedAtUtc String Yes

저장된 공지가 없으면 notice="", updatedAtUtc=null을 반환한다.

18.10 NOTICE-02 · Channel Notice Upsert

Request

AdminChannelNoticeUpsertRequest

{
  "notice": "새 콘텐츠는 매주 금요일 공개됩니다."
}
Field Type Required Description
notice String Yes 빈 문자열은 공지 내용 지우기

Response Data

변경 후 AdminChannelNoticeResponse를 반환한다.

  • 선택 AI 캐릭터의 creatorMemberId로 공지를 조회·저장한다.
  • 값이 실제로 변경된 경우에만 v2 notification port를 통해 기존 구독자 알림 이벤트와 동등한 알림을 발행한다.
  • legacy ExplorerService.saveNotice를 호출하지 않는다.

18.11 Channel Profile and Creator Tag Endpoint Summary

Operation ID Method Endpoint Request Response Data
CREATOR-TAG-01 GET /admin/ai-characters/metadata/creator-tags 없음 List<AdminCreatorTagResponse>
CHANNEL-PROFILE-01 GET /admin/ai-characters/{characterId}/channel-profile Path: characterId AdminChannelProfileResponse
CHANNEL-PROFILE-02 PUT /admin/ai-characters/{characterId}/channel-profile Path: characterId; JSON: AdminChannelProfileUpdateRequest AdminChannelProfileResponse

18.12 CREATOR-TAG-01, CHANNEL-PROFILE-01/02 · Channel Profile and Creator Tags

CREATOR-TAG-01 · Creator Tag Metadata

AdminCreatorTagResponse

Field Type Nullable
tagId Long No
name String No
imageUrl String Yes
isAdult Boolean No

creator tag metadata는 활성 태그 전체를 orders ASC, tagId ASC로 반환하는 비페이징 기준정보다.

CHANNEL-PROFILE-01 · Channel Profile Read

  • Request: Path characterId; Query와 Body 없음
  • Response Data: AdminChannelProfileResponse

AdminChannelProfileResponse

Field Type Nullable
characterId Long No
creatorId Long No
instagramUrl String No
fancimmUrl String No
xUrl String No
youtubeUrl String No
kakaoOpenChatUrl String No
creatorTags List<AdminCreatorTagResponse> No
isVisibleDonationRank Boolean No
donationRankingPeriod String Yes
updatedAtUtc String Yes

CHANNEL-PROFILE-02 · Channel Profile Update

AdminChannelProfileUpdateRequest

Field Type Required
instagramUrl String Yes
fancimmUrl String Yes
xUrl String Yes
youtubeUrl String Yes
kakaoOpenChatUrl String Yes
tagIds List<Long> Yes
isVisibleDonationRank Boolean Yes
donationRankingPeriod String? Yes
{
  "instagramUrl": "https://instagram.com/example",
  "fancimmUrl": "",
  "xUrl": "",
  "youtubeUrl": "https://youtube.com/@example",
  "kakaoOpenChatUrl": "",
  "tagIds": [11, 14],
  "isVisibleDonationRank": true,
  "donationRankingPeriod": "CUMULATIVE"
}
  • Response Data는 변경 후 AdminChannelProfileResponse다.
  • 빈 문자열은 해당 URL 지우기다.
  • URL 값은 빈 문자열 또는 http/https 절대 URL이어야 한다.
  • donationRankingPeriodWEEKLY, CUMULATIVE, null만 허용한다.
  • tagIds는 중복 없이 활성 creator tag만 포함해야 하며 AI creator Member의 태그를 전체 교체한다. 빈 배열은 모든 creator tag 제거를 의미하며 일부 크리에이터 탐색 목록에서 해당 채널이 제외될 수 있다.
  • ChatCharacter.tags는 캐릭터 대화·검색용 별도 모델이므로 creator Member 태그와 자동 동기화하지 않는다.
  • 이름, 프로필 이미지와 소개는 이 API에서 변경하지 않고 캐릭터 수정의 동기화 결과만 사용한다.

19. Authorization and Ownership Rules

요청군별 검증 경계는 다음과 같다.

Request group Authentication Target validation
GET/POST /admin/ai-characters ROLE_ADMIN 목록은 target 없음, 등록은 신규 character·creator 생성 규칙 적용
/admin/ai-characters/original-works/** ROLE_ADMIN global 원작 상태와 캐릭터 배정 집합을 검증하며 사전 character 선택은 없음
/admin/ai-characters/{characterId}/** 및 캐릭터 상세·수정·삭제 ROLE_ADMIN characterId와 연결 AI creator를 해석한 뒤 소유권·활성 상태 검증
/admin/ai-characters/metadata/** ROLE_ADMIN character target 없이 활성 기준정보만 조회

캐릭터 범위 요청은 다음 순서로 검증한다.

  1. Bearer JWT와 ROLE_ADMIN을 확인한다.
  2. characterId에 해당하는 ChatCharacter와 연결 Member가 존재하고 role=CREATOR, memberKind=AI_CHARACTER인지 확인한다.
  3. 대상 콘텐츠, 시리즈, 카테고리 또는 게시글이 연결 creatorMember.id 소유인지 확인한다.
  4. 댓글·답글은 부모와 루트 리소스의 귀속까지 확인한다.
  5. 생성·수정·구성 변경에는 캐릭터, 연결 Member 및 대상 부모 리소스가 모두 활성 상태인지 확인한다.
  6. 검증이 끝난 뒤 해당 v2 도메인 use case를 호출한다.

global 원작 변경은 사람 관리자만 인증한 뒤 originalWorkId, isDeleted와 제목 충돌을 검증한다. 배정·해제는 Request의 모든 characterIds를 한 번에 검증하고, 배정에서는 활성 AI 캐릭터인지, 해제에서는 Path 원작에 실제 연결되어 있는지 확인한 뒤 하나의 transaction으로 변경한다. 캐릭터를 먼저 선택하거나 관리자 principal을 AI 캐릭터 Member로 바꾸지 않는다.

논리 삭제된 콘텐츠, 시리즈, 카테고리, 게시글, 댓글 또는 답글에는 수정·고정·순서/구성 변경·하위 리소스 생성을 허용하지 않고 409를 반환한다. 조회 API에서 명시한 상태 필터 조회와 DELETE 재시도만 허용한다. 캐릭터 자체 및 기존 리소스의 논리 삭제는 비활성 캐릭터에서도 허용하며 반복 호출은 같은 비활성 결과를 반환한다.

관리자 principal을 AI 캐릭터 Member로 교체하지 않는다.

ADMIN JWT
  -> v2 admin web adapter
  -> v2 AI character target query
  -> characterId / creatorMemberId 해석
  -> 해당 v2 domain use case
  -> v2 persistence / external-system port

20. Error Contract

다음 표의 역할 규칙은 /admin/ai-characters/**에 적용한다.

Situation HTTP Status success errorProperty
JWT 없음 또는 유효하지 않음 401 false null
인증되었지만 ROLE_ADMIN 아님 403 false null
캐릭터 미존재 404 false characterId
originalWorkId 또는 원작 API Path ID가 0 이하 400 false originalWorkId
원작 미존재 또는 삭제된 원작 조회 404 false originalWorkId
원작 수정·삭제·배정·해제의 미존재 양수 Path ID 404 false originalWorkId
캐릭터 생성·수정의 미존재 양수 originalWorkId 404 false originalWorkId
캐릭터 생성·수정의 삭제된 originalWorkId 409 false originalWorkId
삭제된 원작 수정·배정·해제 409 false originalWorkId
연결 캐릭터가 남은 원작 삭제 409 false originalWorkId
원작 배정의 미존재·중복·비AI 캐릭터 또는 해제 귀속 불일치 400 false characterIds
원작 배정 대상 캐릭터 비활성 409 false characterIds
대상 리소스 미존재 또는 다른 캐릭터 소유 404 false 해당 resource ID field
비활성 캐릭터에 대한 DELETE 외 변경 요청 409 false characterId
논리 삭제된 리소스에 대한 삭제 외 변경 요청 409 false 해당 resource ID field
잘못된 필드·부모 귀속·페이지 요청 400 false 해당 field
동일 이름 등 현재 상태와 충돌 409 false 충돌 field
외부 캐릭터 API 또는 파일 저장 실패 502 false null
원작 이미지 저장 후 비재시도 DB 실패 또는 재시도 소진 500 false null
Signed URL 생성 또는 저장된 output key 무결성 검증 실패 500 false null

신규 Endpoint는 기존 일부 legacy handler의 HTTP 200 + success=false 관례를 답습하지 않고 위 HTTP status를 계약으로 사용한다. 오류 본문은 기존 ApiResponse.error(...) 형식을 유지한다.

이미 삭제된 원작의 반복 DELETE는 과거 불일치 연결이 남아 있어도 성공 200이므로 위 “연결 캐릭터가 남은 원작 삭제” 409보다 먼저 판정한다. 원작 이미지 보상 삭제가 실패해도 이미 발생한 로컬 transaction 실패의 500을 다른 성공이나 502로 바꾸지 않고 orphan 운영 로그를 남긴다.

기존 /audio-content/upload-complete는 인증 정보가 없거나 JWT가 유효하지 않으면 401, 인증됐지만 ADMIN/BOT이 아니면 403을 반환한다. 기존 Request와 ADMIN/BOT 성공 Response는 이번 범위에서 변경하지 않으며, 관리자 콘텐츠 조회에서 수행하는 canonical output key 검증도 callback의 Request·성공 Response를 변경하지 않는다.

21. Technical Requirements

21.1 Module Boundary

  • 관리자 inbound adapter와 DTO는 kr.co.vividnext.sodalive.v2.admin.aicharacter 아래에 둔다.
  • 신규 worker callback Controller, 전용 Request/Response, V1/V2 dispatcher 또는 v2 completion input port를 만들지 않는다. 기존 /audio-content/upload-complete의 Controller와 Service 흐름을 유지한다.
  • 비즈니스 기능은 관리자 패키지 한 곳에 모으지 않고 다음 v2 도메인이 소유한다.
기능 v2 소유 패키지
AI 캐릭터 CRUD와 관리자 대상 해석 kr.co.vividnext.sodalive.v2.aicharacter
원작 CRUD·검색과 캐릭터 배정 kr.co.vividnext.sodalive.v2.originalwork
콘텐츠·콘텐츠 댓글·콘텐츠 카테고리·콘텐츠 기준정보 kr.co.vividnext.sodalive.v2.content 하위 기능 패키지
시리즈·장르·시리즈 구성 kr.co.vividnext.sodalive.v2.creator.channel.series
커뮤니티 게시글·댓글 kr.co.vividnext.sodalive.v2.creator.channel.community
FanTalk 조회·답글 kr.co.vividnext.sodalive.v2.creator.channel.fantalk
크리에이터 채널 공지 kr.co.vividnext.sodalive.v2.creator.channel.notice
크리에이터 채널 프로필·creator tag 설정 kr.co.vividnext.sodalive.v2.creator.channel.profile
  • 각 도메인은 필요한 domain, application, port/out, adapter/out 계층을 기존 v2 구조에 맞춰 둔다.
  • admin web adapter는 Request 변환, Member principal에서 adminMemberId 추출, Response 변환만 담당한다.
  • admin application 조정 계층은 character-scoped 요청의 characterId -> creatorMemberId 해석 또는 global 원작 요청의 원작 use case 호출과 Response 변환만 담당한다.
  • 업무 규칙, 소유권 검증 및 상태 전이는 각 v2 도메인이 소유한다.
  • legacy Controller, Service, Request/Response DTO 및 Repository를 호출하지 않는다.
  • 호환용 legacy 원작 관리자 web adapter와 legacy 캐릭터 등록·수정 adapter는 각각 v2 원작·캐릭터 input command를 호출할 수 있다. 의존 방향은 legacy inbound -> v2 application 단방향이며, 기존 Method·Path·Request·성공 Response와 legacy patch 의미 변환만 legacy 계층이 담당한다.
  • Controller에서 다른 Controller를 호출하지 않고 기존 API를 내부 HTTP로 호출하지 않는다.
  • 단일 기능을 위한 범용 impersonation 프레임워크를 만들지 않는다.

21.2 Target Resolver

v2 AI character domain의 공통 대상 query는 entity가 아닌 다음 값 객체를 반환한다.

AiCharacterAdminTarget(
  characterId,
  creatorMemberId,
  characterIsActive,
  creatorMemberIsActive,
  creatorRole,
  memberKind
)

대상 query는 creatorRole=CREATOR, memberKind=AI_CHARACTER를 만족하지 않으면 대행 대상으로 반환하지 않는다. 생성·수정 작업에는 캐릭터와 연결 Member가 모두 활성 상태여야 한다. 모든 character-scoped 하위 도메인 use case는 이 해석 결과를 사용해 body의 creatorId 주입 가능성을 제거한다. 각 도메인은 전달된 creatorMemberId와 대상 리소스의 실제 소유자가 같은지도 자체 persistence port로 다시 검증한다. global 원작 CRUD에는 이 resolver를 호출하지 않는다. 원작 배정은 각 character ID의 활성 AI 캐릭터 유효성을 일괄 검증하고, 해제는 legacy 불일치 관계 정리를 위해 resolver 대신 기존 ChatCharacter의 존재와 Path 원작 귀속만 일괄 검증한다.

21.3 v2 Domain Implementation

  • legacy 구현은 데이터 의미와 회귀 시나리오를 파악하는 참고 자료로만 사용한다.
  • v2 도메인 규칙을 legacy service에 위임하지 않는다.
  • 기존 v2 query use case와 port가 이 문서의 계약을 충족하면 같은 v2 도메인 안에서 재사용하거나 확장할 수 있다.
  • command use case가 없는 콘텐츠·댓글·시리즈·커뮤니티·FanTalk 답글은 각 v2 도메인에 별도로 구현한다.
  • 관리자 조회에는 구매 여부, 성인 선호도 또는 차단 관계에 따른 소비자용 마스킹을 적용하지 않고 관리자 전용 query projection을 사용한다.
  • 다음 규칙은 legacy 동작을 복사하지 않고 v2 도메인 정책으로 명시적으로 구현한다.
    • 캐릭터 활성 상태와 연결 AI creator 유효성
    • 원작의 isDeleted=false, 제목 중복, 전체 교체, 연결 캐릭터가 없는 삭제 조건
    • 원작 캐릭터 배정·해제의 전건 검증, 원자성, 이동 및 현재 원작 귀속
    • 콘텐츠·시리즈·게시글 소유권
    • 콘텐츠 카테고리와 포함 콘텐츠의 동일 소유권
    • 콘텐츠 댓글과 커뮤니티 댓글의 부모·루트 귀속
    • 작성자만 본문 수정 가능
    • 작성자 또는 소유자만 댓글 논리 삭제 가능
    • 시리즈 순서 변경 대상 전체의 동일 소유자 검증
    • FanTalk 루트 대상과 AI 답글 작성자 검증
    • 채널 공지의 creator 소유권과 변경 알림
    • 채널 프로필·creator tag 설정의 creator 소유권과 활성 tag 검증
  • 캐릭터 등록·수정의 외부 API와 파일 저장, 원작 이미지 저장은 v2 outbound port로 정의하고 v2 infrastructure adapter에서 구현한다.

legacy Service를 호출하지 않더라도 다음 외부 관찰 가능 동작은 v2 outbound port와 after-commit event로 보존한다.

Trigger Required side effect
캐릭터 등록 description 기반 언어 감지 작업 예약
캐릭터 수정 캐릭터 번역 갱신 작업 예약
원작 등록 title·contentType·category·description 기반 언어 감지 작업 예약
원작 수정 정규화된 title·contentType·category·description·tags 중 하나 이상이 실제 변경된 경우 원작 번역 갱신 작업 예약
콘텐츠 등록·수정 언어 코드가 없으면 언어 감지, 있으면 번역 작업 예약
기존 callback 또는 예약 공개가 콘텐츠를 최초 공개 구독자 콘텐츠 공개 알림과 v2 following/home news 발행
콘텐츠 댓글 등록 대상 콘텐츠 알림, 언어 코드가 없으면 언어 감지
시리즈 등록·수정 언어 감지 또는 번역 작업 예약
콘텐츠 카테고리 등록·제목 수정 언어 감지 또는 번역 작업 예약
커뮤니티 게시글 등록 구독자 알림, 무료 게시글이면 v2 following/home news 발행
FanTalk 답글 등록 언어 코드가 없으면 언어 감지
채널 공지 변경 구독자 공지 변경 알림
캐릭터 삭제 commit 후 공개 콘텐츠·추천·랭킹·인기 캐릭터 cache 무효화

트랜잭션 rollback 시 알림·home news·번역 요청을 발행하지 않는다. 같은 공개 처리 또는 같은 command의 멱등 재시도에서 중복 발행하지 않는다.

원작 event adapter는 legacy 언어 감지 listener가 감지 transaction 안에서 후속 번역 event를 즉시 발행할 수 있다는 점을 그대로 노출하지 않는다. 원작 생성·수정 transaction이 commit된 뒤에만 감지 또는 번역 시작 event를 한 번 전달하고, 감지 결과를 사용하는 후속 번역도 해당 감지 결과 commit 이후 실행되도록 adapter test로 고정한다.

21.4 Persistence and Shared Infrastructure

  • 기존 DB 테이블을 그대로 사용하며 같은 테이블을 위한 v2 전용 JPA entity를 중복 생성하지 않는다.
  • v2 domain/application은 legacy JPA entity나 QueryDSL Q type을 참조하지 않는다.
  • v2 persistence adapter만 기존 JPA entity 또는 QueryDSL Q type을 사용해 기존 테이블에 접근할 수 있다.
  • adapter는 persistence 결과를 v2 port record 또는 v2 domain model로 변환한다.
  • v2 application과 v2 persistence adapter 모두 legacy Repository를 주입하거나 호출하지 않는다. 필요한 query/command는 v2가 소유한 port와 persistence 구현으로 정의하고, adapter에서 EntityManager, QueryDSL 또는 v2 전용 repository 구현을 사용해 기존 entity/table에 접근한다.
  • original-work persistence adapter는 기존 OriginalWork, OriginalWorkLink, OriginalWorkTag, OriginalWorkTagMapping entity와 ChatCharacter.originalWork 관계를 사용하되 v2 domain에 이를 노출하지 않는다. 같은 테이블을 위한 v2 @Entity 또는 relation mapping을 새로 만들지 않는다.
  • 원작 link와 tag-mapping 조회는 각 mapping ID 오름차순을 query에 명시하고, 변경 시 정규화된 요청 순서대로 mapping을 다시 만든다. 순서 보존을 위해 기존 entity에 @OrderColumn을 추가하지 않는다.
  • 원작 삭제·배정·해제와 캐릭터 원작 연결 변경은 v2 persistence port의 잠금 query를 사용한다. 양수 추가 연결 대상 또는 원작 Path가 있으면 해당 원작 row를 PESSIMISTIC_WRITE로 먼저 잠그고 batch 캐릭터 row를 ID 오름차순으로 잠근 뒤 isDeleted, 활성 AI 여부와 현재 귀속을 다시 검증한다. CHAR-04 null과 legacy 수정 0의 해제는 target 원작 없이 캐릭터 row만 잠그고 현재 귀속을 재검증한다.
  • 원작 생성과 제목이 실제 바뀌는 수정은 MySQL SERIALIZABLE 격리에서 중복 조회와 write를 같은 transaction으로 처리한다. deadlock·serialization 실패는 새 transaction에서 최대 한 번 재시도하며, 중복 재조회 결과가 없는 잠금 실패를 제목 충돌 409로 오인하지 않는다.
  • 공통 JWT 인증, ApiResponse, 파일 저장 client, CDN URL 정책 및 외부 캐릭터 API client는 플랫폼·인프라 기능이므로 port 경계 뒤에서 재사용할 수 있다.
  • 이번 기능을 위해 DB 테이블·컬럼·인덱스를 추가하거나 기존 JPA entity mapping을 변경하지 않는다. alter-existing-tables.sql, lifecycle backfill, upload pipeline 구분 컬럼과 v2 전용 콘텐츠 테이블도 만들지 않는다.
  • content persistence adapter는 기존 content row의 isActive, releaseDate, duration, content 경로와 연결 creator 활성 상태를 반환한다. v2 query application은 12.2의 우선순위로 status를 계산하며 계산값을 DB에 다시 저장하지 않는다.
  • CONTENT-01의 status 필터도 같은 기존 컬럼 조건과 하나의 UTC 기준 시각을 사용한다. 별도 status 컬럼이나 status 전용 인덱스는 실제 성능 근거 없이 추가하지 않는다.
  • 콘텐츠 생성 Request의 previewStartTime, previewEndTime은 S3 metadata 전달용이며 DB에 저장하지 않는다. 따라서 목록·상세 persistence projection과 Response에도 포함하지 않는다.
  • v2 content query application은 요청마다 기준 Instant를 한 번 얻고 (duration의 HH 부분 + 2)시간을 더한 절대 expiresAt을 계산한다.
  • Signed URL outbound port는 canonical output key와 절대 expiresAt을 받아 URL policy에 정확히 같은 만료 시각을 사용하고 SignedAudioUrl(url, expiresAt)을 반환한다. 현재 AudioContentCloudFront의 상대 TTL 호출과 별도로 응답 만료 시각을 계산해 두 값이 어긋나는 구현은 허용하지 않는다.
  • v2 content query application은 위 port를 통해 CONTENT-01, CONTENT-02 응답마다 Signed URL과 contentUrlExpiresAtUtc를 함께 생성한다.
  • Signed URL 생성 실패 시 persistence 경로나 비서명 오디오 URL을 응답으로 노출하지 않는다.
  • v2 콘텐츠 생성은 기존 row와 같은 방식으로 isActive=false, duration=null, content=input/{contentId}/{contentId}-content-...로 시작한다. object basename은 기존 생성 규칙을 유지해 worker가 만든 output basename이 callback의 content ID 검증을 통과하게 한다. releaseAtUtc=null이면 현재 UTC 시각을 releaseDate에 저장해 callback 완료 후 즉시 공개 조건을 표현한다.
  • 콘텐츠 논리 삭제는 기존 의미대로 isActive=false, releaseDate=null로 기록한다. callback은 가공 결과 경로와 duration을 기록할 수 있지만 이 row를 다시 활성화하지 않는다.
  • 캐릭터 삭제 cascade는 소유 콘텐츠 row의 raw isActivefalse로 전환하고 releaseDate, duration, content와 구매 이력을 보존한다. 연결 creator 비활성 상태를 포함해 계산한 유효 isActivefalse이고 기존 미삭제 콘텐츠의 statusSUSPENDED다. 이 상태 변경은 기존 컬럼을 사용하며 schema나 JPA mapping 변경을 요구하지 않는다.
  • 기존 비활성 캐릭터 row를 일괄 보정하는 데이터 migration도 현재 근거 없이 선제 수행하지 않는다. 실제 운영 불일치가 확인되면 대상·영향 건수를 먼저 조사하고 별도 승인과 migration 계획을 작성한다.

21.5 Transactions and External Systems

  • DB 안에서 끝나는 변경은 하나의 transaction으로 처리한다.
  • 캐릭터 등록·수정은 외부 캐릭터 API, 이미지 저장, DB 변경의 실패 지점을 구분해 오류를 반환한다.
  • 원작 생성·이미지 교체 command도 서버에서 requestId를 생성한다. 이는 파일 key·보상·구조화 로그 상관관계용이며 클라이언트 HTTP 재시도 멱등성 key는 아니다.
  • OriginalWorkImageStoragePortstore(originalWorkId, validatedImage, requestId, attemptNumber)와 새로 저장한 object의 delete(objectKey)를 제공한다. storeoriginals/{originalWorkId}/... 아래 attempt별 고유 key를 만들고 정확한 objectKey를 반환한다. 공통 S3Uploader에는 존재 확인 없이 정확한 bucket·object key를 deleteObject하는 최소 메서드만 추가하고, v2 S3 adapter가 이를 port 뒤에서 사용해 application에 AWS type을 노출하지 않는다.
  • 원작 생성·이미지 교체는 DB와 새 이미지의 부분 성공을 반환하지 않는다. DB commit 전에 originals/{originalWorkId}/...에 저장한 새 이미지가 이후 실패하면 같은 request의 object key만 삭제 보상한다. 기존 이미지와 논리 삭제된 원작의 이미지는 보존하며 일반 이미지 정리 기능을 추가하지 않는다.
  • 원작 이미지 orchestration은 @Transactional proxy method 내부의 try/catch에 commit 예외가 잡힌다고 가정하지 않는다. application의 비transactional 진입점이 attempt마다 하나의 REQUIRES_NEW TransactionTemplate.execute 전체를 감싸 DB flush·commit 예외까지 받은 뒤, callback 밖에서 확인한 해당 attempt의 새 object key만 보상한다. outer transaction에 join하거나 REQUIREDSERIALIZABLE template을 중첩하지 않는다.
  • 원작 이미지 저장 자체가 실패하면 DB 변경을 rollback하고 502를 반환한다. 이미지 저장 뒤 retry 가능한 deadlock·serialization 실패가 발생하면 아직 HTTP 오류로 매핑하지 않고 해당 attempt object를 삭제한 다음, 보상이 성공한 경우에만 새 transaction과 새 key로 한 번 재시도한다. 재조회에서 실제 제목 중복이 확인되면 409를 반환한다. 비재시도 DB 실패 또는 재시도 소진은 attempt object 삭제 보상 후 500을 반환한다. 보상 삭제가 실패하면 재시도를 중단하고 원래 500을 유지하며 requestId, attempt number, object key와 실패 단계를 orphan 로그 및 운영 알림에 남긴다.
  • 원작 배정·해제는 Request의 모든 캐릭터를 검증한 뒤 한 transaction에서 변경한다. 누락·비활성·귀속 불일치 ID를 조용히 건너뛰는 부분 성공을 허용하지 않는다.
  • 캐릭터 변경 command마다 requestId를 생성하고 외부 캐릭터 API와 파일 저장 port에 idempotency key로 전달한다.
  • 외부 캐릭터 또는 새 파일 생성 후 DB 작업이 실패하면 생성된 외부 리소스의 삭제·비활성화 보상을 즉시 시도한다.
  • 외부 캐릭터 수정 성공 후 DB 작업이 실패하면 command 시작 전에 읽은 remote 표현으로 compensating update를 시도한다. 원상 복구를 지원하지 않거나 실패하면 local/remote 차이와 외부 resource ID를 divergence 로그와 운영 재처리 대상으로 남긴다.
  • 외부 시스템이 보상 작업을 지원하지 않거나 보상이 실패하면 requestId, 외부 리소스 ID 또는 파일 경로, 실패 단계를 구조화 orphan 로그로 남기고 운영 알림을 발행한다. 같은 requestId의 재처리는 기존 외부 리소스를 확인해 중복 생성하지 않는다.
  • 이 문서의 “운영 알림”은 기존 로그 수집·경보가 감지하는 구조화 ERROR log를 의미한다. 이번 범위에 별도 알림 outbound port, 메시지 채널 또는 범용 운영 알림 시스템을 추가하지 않는다.
  • 외부 작업이나 보상 결과와 관계없이 DB 변경까지 완료되지 않은 요청은 성공으로 응답하지 않는다.
  • 파일 업로드 실패 시 부분 DB 리소스를 성공으로 반환하지 않는다.
  • v2 업로드 요청은 기존 worker가 이미 처리하는 S3 bucket, input/{contentId}/{contentId}-content-... key와 metadata 계약을 그대로 사용하며 callback URL, pipeline version 또는 새 분기 정보를 worker에 전달하지 않는다.
  • 기존 /audio-content/upload-complete의 Method, Path, Request와 ADMIN/BOT 성공 Response가 바뀌지 않고, 인증 정보 없음·유효하지 않은 JWT는 401, 인증됐지만 ADMIN/BOT이 아니면 403인지 contract test로 검증한다.
  • v2에서 생성한 기존 형식의 content row도 현재 AudioContentService.uploadComplete와 예약 공개 흐름이 처리하는지 통합 검증한다.
  • callback은 releaseDate=null 또는 연결 creator 비활성인 콘텐츠를 공개하지 않고, 기존 예약 공개 query는 활성 creator만 선택하도록 최소 안전 조건을 보강한다. AudioContentReleaseScheduledTask의 cron·lock과 worker 코드는 수정하지 않는다.
  • 캐릭터 삭제 시 raw content.isActive=false가 되므로 일반 사용자용 목록·검색·추천은 기존 공개 조건으로 이를 제외한다. 직접 상세 조회는 비활성 creator 또는 비활성 콘텐츠를 미구매 사용자에게 반환하지 않되, 기존 주문을 확인한 KEEP·RENTAL 구매자의 재생 경로는 유지한다.
  • content/creator ranking의 latest/previous visible snapshot query는 snapshot 생성 당시 값만 신뢰하지 않고 현재 content.isActive와 creator Member의 isActive를 확인한다. snapshot row 자체는 변경하거나 backfill하지 않는다.
  • snapshot 외 legacy creator ranking query도 현재 Member의 isActive=true를 요구한다.
  • 캐릭터·콘텐츠·시리즈의 공개 언어별 banner query는 banner 자체의 활성 상태뿐 아니라 연결 대상의 현재 활성 상태를 확인한다. banner row와 관리자용 전체 목록은 변경하지 않는다.
  • 비활성 creator 콘텐츠의 댓글·답글 공개 조회와 신규 등록·본문 수정·재활성화는 거부하고 권한 있는 기존 댓글 논리 삭제만 허용한다. 구매자는 재생에 필요한 상세와 Signed URL만 유지하며 댓글·관련 콘텐츠 같은 공개 상호작용은 제공하지 않는다.
  • 현재 v2 FanTalk 탭은 활성 creator 조회 조건을 유지하고, legacy FanTalk 목록과 신규 원문 등록도 대상 creator의 활성 상태를 확인한다. 비활성 creator의 FanTalk 원문·답글 row는 변경하지 않는다.
  • 캐릭터 삭제 event는 transaction 안에서 상태가 실제 변경된 경우에만 1회 publish한다. AFTER_COMMIT listener는 default, cache_ttl_3_days, popularCharacters_24h_locale cache 전체를 clear해 삭제 전 materialized DTO를 제거한다. rollback과 멱등 재시도에서는 clear하지 않으며 cache TTL이나 Redis 설정은 변경하지 않는다.
  • worker와 AWS Trigger 구현은 이 저장소 밖에 있으므로 배포 전 staging에서 v2 원본 업로드부터 기존 callback 완료까지 E2E를 1회 수행한다. S3 input 저장 시점과 DB transaction commit 사이에 callback이 도착할 가능성과 worker의 조회 실패 retry 여부도 이 검증에서 확인하며, 확인되지 않은 retry 동작을 Backend 보장으로 가정하지 않는다.

21.6 Audit Log

/admin/ai-characters/**의 사람 관리자 변경 작업은 최소 다음 구조화 로그를 남긴다.

Field Description
adminMemberId 실제 인증된 사람 관리자
characterId 선택 AI 캐릭터. global 원작 CRUD에서는 null, 배정·해제에서는 캐릭터별 로그에 값 기록
creatorMemberId 연결 크리에이터 Member. global 원작 CRUD에서는 null, 배정에서는 캐릭터별 값, 해제에서는 해당 Member ID를 확인할 수 있으면 값이고 과거 불일치로 연결 정보가 없거나 해석할 수 없을 때만 null 가능
action CREATE, UPDATE, DELETE, ASSIGN, UNASSIGN, PIN 등
resourceType CHARACTER, ORIGINAL_WORK, ORIGINAL_WORK_CHARACTER, CONTENT, CONTENT_COMMENT, CONTENT_CATEGORY, SERIES, COMMUNITY_POST, COMMUNITY_COMMENT, FAN_TALK, FAN_TALK_REPLY, CHANNEL_NOTICE, CHANNEL_PROFILE
resourceId 변경 대상 ID
result SUCCESS 또는 FAILURE

원작 배정·해제는 변경 캐릭터마다 resourceType=ORIGINAL_WORK_CHARACTER, resourceId=originalWorkId, 해당 characterId와 가능한 경우 creatorMemberId를 남긴다. 원작 생성·수정·삭제는 resourceType=ORIGINAL_WORK이며 두 캐릭터 field가 null이다. 정상 해제와 과거 불일치 해제를 별도 command나 audit factory로 구분하지 않는다. 해제 호출자는 캐릭터에서 creatorMemberId를 확인할 수 있으면 반드시 전달하고, 연결 정보가 없거나 해석할 수 없는 과거 불일치 상태에서만 null을 전달한다.

영속 audit table 도입은 이번 범위가 아니며 구조화 application log를 요구한다. 비밀번호, JWT, system prompt 전체, 댓글 본문 또는 업로드 파일 내용은 로그에 기록하지 않는다.

21.7 Security

  • /admin/ai-characters/** Controller는 class level에서 hasRole('ADMIN')을 선언한다.
  • 기존 /audio-content/upload-completehasAnyRole('BOT', 'ADMIN')을 유지한다. 인증 정보가 없거나 JWT가 유효하지 않으면 401, 인증됐지만 ADMIN/BOT이 아니면 403이며, ADMIN/BOT의 기존 성공 응답 계약은 변경하지 않는다.
  • /admin/ai-characters/** RequestMatcher에만 적용되는 authentication entry point, access-denied handler 및 exception response 경계를 두어 legacy API의 HTTP status를 변경하지 않고 20장의 status와 ApiResponse 계약을 보장한다.
  • 클라이언트 메뉴·route guard 테스트와 Backend API 인가 테스트를 별도로 작성한다.
  • Multipart JSON string part는 단일 JSON root만 허용하고 뒤에 이어진 추가 root나 garbage를 400으로 거부한다.
  • Multipart 요청은 애플리케이션의 max-file-size=1024MB, max-request-size=1024MB 상한을 적용한다. 이미지 part는 공통 이미지 검증기를 v2 web adapter에서 사용해 bytes 적재 전에 10MB 초과를 거부하고, 실제 MIME type이 image/jpeg, image/png, image/gif 중 하나인지 검증한다. 실제 format 확인 decode는 출력 영역을 1x1로 제한하되 내부 decoder row/loop 때문에 한 변은 최대 20,000px, 총 픽셀은 최대 40,000,000 pixels로 제한한다. PNG는 ancillary payload 합계 1MB와 chunk 4,096개를 상한으로 두고 ImageIO 입력을 ignoreMetadata=true로 설정해 metadata를 읽지 않는다. GIF는 최대 500 frame, extension 1,024개, extension당 sub-block 64개, extension payload 합계 1MB, 모든 frame의 누적 40,000,000 pixels를 상한으로 둔다. GIF logical canvas와 모든 frame header에도 같은 dimension 상한을 적용하고 각 frame의 LZW 출력 pixel 수가 선언된 width와 height의 곱과 정확히 일치해야 한다. 조기 EOI·연속 clear·EOI 뒤 data가 있는 LZW와 첫 frame이 정상이지만 후속 frame decode가 손상된 입력은 거부한다. 첫 frame뿐 아니라 모든 frame을 각각 1x1 출력 영역으로 decode해 malformed header/frame을 400으로 거부한다. GIF는 API에 별도 허용 조건이 있는 유료 커뮤니티 게시글 이미지만 허용하고, 원작·캐릭터·콘텐츠 커버·시리즈 이미지를 포함한 나머지 image part에서는 거부한다.
  • 콘텐츠 원본 오디오는 빈 파일을 거부하고 worker에 전달한다. 지원 codec과 재생 가능 여부는 worker가 검증하며 실패한 콘텐츠를 PUBLISHED로 전환하지 않는다.
  • 관리자 응답에서 system prompt는 캐릭터 상세에만 포함하며 목록에는 포함하지 않는다.
  • CONTENT-01, CONTENT-02는 Signed URL이 포함될 수 있으므로 Cache-Control: private, no-store를 반환한다.

22. Acceptance Criteria

아래 체크박스는 사용자가 추가로 결정할 항목이 아니다. 후속 plan-task.md, 구현 및 테스트 단계에서 이 PRD의 충족 여부를 추적하기 위한 검증 목록이며, 아직 구현하지 않았으므로 모두 미체크 상태로 둔다. 제품 결정이 필요한 항목은 24장에 기록하고 미결정 사항은 25장에만 기록한다.

Authentication and Menu

  • 기존 POST /admin/member/login으로 로그인한 ADMIN이 신규 API를 호출할 수 있다.
  • 별도 AI 캐릭터 관리자 로그인 Endpoint가 추가되지 않는다.
  • 비로그인 요청은 401을 반환한다.
  • /admin/ai-characters/**에 대한 CONTENT_MANAGER, CREATOR, AGENT, USER 요청은 403을 반환한다.
  • 기존 /audio-content/upload-completeBOT 또는 ADMIN 인가와 외부 계약이 변경되지 않는다.
  • Backend는 v2 AI 캐릭터 관리자용 menu Endpoint를 추가하지 않는다.
  • 클라이언트 메뉴 또는 route guard와 관계없이 Backend API 인가가 독립적으로 동작한다.

Character

  • 활성·비활성 상태 및 이름으로 AI 캐릭터를 조회할 수 있다.
  • 캐릭터 등록 시 연결 AI creatorMember가 생성된다.
  • 캐릭터 표시 정보 수정 시 연결 Member가 동기화된다.
  • 캐릭터 삭제 시 캐릭터와 연결 Member가 비활성화되고 공개 리소스가 노출되지 않으며 어떤 데이터도 물리 삭제되지 않는다.
  • 캐릭터 삭제 시 소유 콘텐츠 row의 raw isActivefalse로 전환되고 releaseDate, content, duration과 구매 이력은 보존된다. 기존 미삭제 콘텐츠는 계산 상태 SUSPENDED, 기존 삭제 콘텐츠는 DELETED로 반환된다.
  • 삭제 캐릭터의 콘텐츠는 일반 사용자용 목록·검색·추천·크리에이터 채널과 미구매 상세에서 노출되지 않지만, 기존 KEEP·RENTAL 구매자는 주문 이력 기반 재생을 유지한다.
  • content/creator ranking의 latest/previous snapshot 조회도 현재 콘텐츠·creator 활성 상태를 적용하고, 기존 snapshot row를 삭제·수정·재생성하지 않는다.
  • snapshot 외 legacy creator ranking도 비활성 creator를 반환하지 않는다.
  • 공개 캐릭터·콘텐츠·시리즈 banner는 비활성 연결 대상을 반환하지 않지만 기존 banner row와 관리자용 목록은 보존된다.
  • 삭제 캐릭터 콘텐츠의 공개 댓글·답글 조회와 신규 등록·본문 수정·재활성화는 구매 여부와 관계없이 차단되고, 권한 있는 기존 댓글 논리 삭제만 허용된다.
  • 삭제 캐릭터의 FanTalk 원문·답글 row와 관리자 조회·논리 삭제 기능은 보존되지만, 일반 사용자용 FanTalk 조회와 신규 FanTalk 원문 등록은 차단된다.
  • cache를 미리 채운 뒤 캐릭터 삭제가 commit되면 default, cache_ttl_3_days, popularCharacters_24h_locale의 stale 공개 응답이 제거되고, rollback·반복 삭제에서는 cache clear가 발생하지 않는다.
  • legacy 캐릭터 비활성화 경로도 같은 v2 삭제 cascade를 사용하고 비활성 캐릭터 재활성화는 거부한다.
  • 비활성 캐릭터의 신규 발행·수정 작업은 409를 반환한다.

Original Work

  • ORIGINAL-WORK-01~ORIGINAL-WORK-08의 method와 path가 11.7과 정확히 일치한다.
  • 원작 목록이 공통 페이징과 search Query를 사용하고 삭제 원작을 제외하며 별도 비페이징 /search Endpoint를 만들지 않는다.
  • 원작 상세·등록·수정·논리 삭제와 연결 캐릭터 목록을 사용할 수 있다.
  • 생성·수정 이미지의 실제 MIME을 검증하고 GIF를 거부하며 이미지 또는 DB 실패를 부분 성공으로 반환하지 않는다.
  • 원작 수정은 nullable field의 명시적 null과 빈 목록 전체 삭제를 포함한 전체 교체 계약을 지킨다.
  • 생성과 수정 모두 삭제되지 않은 동일 제목 충돌을 409로 반환하고, 동시 요청에서도 MySQL SERIALIZABLE transaction과 한 번의 재시도로 중복 활성 제목을 만들지 않는다.
  • 신규 CHAR-03·CHAR-04의 양수 originalWorkIdisDeleted=false 원작만 허용하고, null은 각각 미연결과 연결 해제를 의미하며 0 sentinel을 허용하지 않는다. legacy 호환 adapter는 기존 등록의 0을 미연결로, 수정의 0만 해제로 변환한다.
  • 원작 배정은 모든 캐릭터가 활성 AI 캐릭터인지 먼저 검증하고 다른 원작 연결을 새 원작으로 원자적으로 이동한다.
  • 원작 해제는 활성·비활성 및 연결 creator 상태와 관계없이 기존 캐릭터를 허용하되 모든 캐릭터가 Path 원작에 실제 연결되어 있는지 먼저 검증한다.
  • 배정·해제에서 누락 ID를 조용히 무시하거나 일부만 성공하지 않고, 잘못된 ID가 하나라도 있으면 전체를 rollback한다.
  • 원작 삭제·배정·해제와 양수 캐릭터 원작 연결 변경은 원작/캐릭터 잠금 순서를 사용하고, 대상 원작 없는 해제는 캐릭터만 잠가 동시 요청에서도 삭제 원작 참조를 만들지 않는다.
  • 활성·비활성 연결 캐릭터가 하나라도 남은 원작 삭제는 409이며 관계를 암묵적으로 해제하지 않는다.
  • 원작 삭제는 isDeleted=true만 변경하고 링크·태그·이미지·번역 이력을 물리 삭제하지 않으며, 이미 삭제된 원작은 연결 수보다 먼저 판정해 반복 삭제가 멱등하다.
  • 원작 생성 언어 감지와 title, contentType, category, description, tags가 실제 바뀐 수정의 번역 갱신은 commit 후 한 번만 실행되고 다른 field 변경·배정·해제에서는 실행되지 않는다.
  • legacy /admin/chat/original/**와 legacy 캐릭터 등록·수정의 Method·Path·Request·성공 Response는 유지되고, 원작 mutation 5개와 캐릭터 mutation 2개 전체가 각각 같은 v2 원작·캐릭터 command로 수렴한다.
  • 배포 전 isDeleted=true 원작을 참조하는 캐릭터가 0건임을 확인하며, 1건 이상이면 자동 migration 대신 별도 승인된 데이터 보정을 완료한 뒤 mutation 전환을 활성화한다.

Creator Operations

  • 선택 AI 캐릭터의 콘텐츠 CRUD와 고정 상태 변경이 가능하다.
  • 선택 AI 캐릭터 명의로 콘텐츠 댓글·답글 CRUD가 가능하다.
  • 선택 AI 캐릭터 소유 콘텐츠에 달린 타인의 댓글을 삭제할 수 있다.
  • 선택 AI 캐릭터의 콘텐츠 카테고리 CRUD, 콘텐츠 구성 및 카테고리 순서 변경이 가능하다.
  • 선택 AI 캐릭터의 시리즈 CRUD, 콘텐츠 구성 및 순서 변경이 가능하다.
  • 선택 AI 캐릭터의 커뮤니티 게시글 CRUD와 고정 상태 변경이 가능하다.
  • 선택 AI 캐릭터 명의로 커뮤니티 댓글·답글 CRUD가 가능하다.
  • 선택 AI 캐릭터 소유 게시글에 달린 타인의 댓글을 삭제할 수 있다.
  • 선택 AI 캐릭터의 FanTalk를 조회하고 AI 캐릭터 답글을 등록·수정·삭제할 수 있다.
  • FanTalk 목록의 각 항목에 replyId가 있는 AI 캐릭터 답글이 함께 반환되고 별도 답글 조회 API는 없다.
  • 선택 AI 캐릭터를 대상으로 한 FanTalk 원문을 소유자 권한으로 논리 삭제할 수 있다.
  • 선택 AI 캐릭터의 채널 공지를 조회하고 upsert할 수 있다.
  • 선택 AI 캐릭터의 채널 SNS URL, creator tag와 후원 랭킹 공개 설정을 조회·수정할 수 있다.
  • 다른 캐릭터 소유 리소스는 조회·변경할 수 없다.
  • 댓글과 FanTalk의 부모·루트 귀속을 우회할 수 없다.

API Contract

  • 모든 신규 Endpoint가 이 문서의 Path, Request, Response 계약을 따른다.
  • 신규 관리자 Operation 66개가 route inventory에 중복·누락·추가 없이 존재한다.
  • 등록·수정·삭제 응답이 변경된 resource ID와 상태를 반환한다.
  • 페이징 대상 목록 API가 동일한 페이징 규칙을 사용하고, 콘텐츠 테마·시리즈 장르·creator tag 기준정보만 명시된 비페이징 예외로 동작한다.
  • 신규 오류 응답이 정의된 HTTP status와 ApiResponse body를 사용한다.
  • 실제 관리자와 대행 AI 캐릭터를 구분하는 구조화 로그가 남는다.
  • v2 비즈니스 로직과 persistence adapter가 legacy Controller, Service, Repository 또는 web DTO를 호출하지 않는다.
  • 신규 upload-complete Endpoint를 만들지 않고 기존 AWS S3 Trigger worker의 코드·스케줄·설정을 변경하지 않는다.
  • 기존 /audio-content/upload-complete의 Method, Path, Request와 ADMIN/BOT 성공 Response가 유지되고 인증 정보 없음·유효하지 않은 JWT는 401, 인증됐지만 ADMIN/BOT이 아니면 403을 반환한다.
  • v2 콘텐츠 생성이 기존 content row와 S3 key·metadata 계약을 따르고 별도 V1/V2 callback 분기 없이 기존 callback과 예약 공개 흐름으로 처리된다.
  • CONTENT-03 원본 object가 input/{contentId}/{contentId}-content-... key를 사용하고 worker 결과 basename이 기존 callback의 content ID 검증을 통과한다.
  • staging에서 v2 S3 input 저장부터 기존 callback 완료까지 E2E가 통과하고 DB commit 전 callback 도착 시 worker retry 동작이 확인된다.
  • 이번 기능을 위한 DB 컬럼·인덱스·테이블·DDL·backfill·데이터 migration이나 기존 JPA mapping 변경을 추가하지 않고 v2 전용 예약 공개 scheduler도 만들지 않는다.
  • 콘텐츠 생성은 기존 초기 필드에서 계산한 PROCESSING으로 응답하고 목록·상세의 statusisActive 필터도 기존 필드와 creator 활성 상태에서 계산한다.
  • previewStartTime, previewEndTime은 생성 시 S3 metadata로만 전달되고 콘텐츠 목록·상세 Response에는 포함되지 않는다.
  • 콘텐츠 목록과 상세가 계산 상태 SCHEDULED, PUBLISHED이고 canonical output key와 duration이 있는 가공 완료 오디오에만 Signed URL을 반환한다.
  • Signed URL의 TTL이 legacy와 같은 (duration의 HH 부분 + 2)시간이고 URL policy 만료 시각과 contentUrlExpiresAtUtc가 동일한 기준 시각에서 계산되어 일치한다.
  • coverImageUrl은 일반 CDN 절대 URL이며 Signed URL이나 저장 경로가 아니다.
  • 관리자 CONTENT-01, CONTENT-02에서 계산 상태가 PROCESSING, SUSPENDED, DELETED이거나 creator가 비활성이면 원본 업로드 경로나 오디오 URL이 노출되지 않는다. 기존 구매자의 소비자용 재생 예외는 이 관리자 응답 계약에 적용하지 않는다.
  • 콘텐츠 목록과 상세 재조회 시 Signed URL이 갱신되고 별도 브라우저용 URL 갱신 Endpoint는 없다.
  • Signed URL 생성 또는 output key 무결성 검증 실패 시 500으로 실패하고 raw path, 비서명 오디오 URL 또는 빈 문자열로 fallback하지 않는다.
  • CONTENT-01, CONTENT-02 응답에 Cache-Control: private, no-store가 포함된다.
  • 처리 중 삭제되어 releaseDate=null이 된 콘텐츠는 늦은 callback 이후에도 isActive=false를 유지하고 공개 side effect를 발생시키지 않는다.
  • 비활성 creator의 콘텐츠는 늦은 callback 이후에도 raw content.isActive=false를 유지하며, 과거 불일치 row는 false로 보정되고 공개 side effect를 발생시키지 않는다.
  • 기존 예약 공개 query는 활성 creator의 공개 시각이 지난 가공 완료 콘텐츠만 선택하며 scheduler component의 cron·lock은 변경하지 않는다.

Frontend Handoff

다음 체크박스는 Backend 구현 완료 조건이 아니라 27장의 프론트엔드 개발 프롬프트와 함께 전달할 클라이언트 검증 기준이다.

  • /ai-characters 메뉴와 하위 route/tab을 typed static configuration으로 관리하고 legacy GET /menu를 호출하지 않는다.
  • 로그인 응답의 {token, role}sessionStorage에서 함께 복원하고 role=ADMIN으로 화면 진입을 제어하되 이를 Backend API 인가의 대체 수단으로 취급하지 않는다.
  • child resource 등록·수정 전에 사용자가 캐릭터를 명시적으로 선택하고 URL Path의 characterId를 source of truth로 사용한다.
  • child resource form에서 캐릭터를 다시 선택하게 하거나 Request body에 characterId, creatorId, writer ID를 추가하지 않는다.
  • 27.4의 기준에 따라 주요 목록·상세·등록·수정은 Page, 부모 문맥 안의 짧은 입력·선택·확인은 Dialog 또는 inline UI로 구현한다.
  • 선택 AI 캐릭터가 작성한 댓글에만 수정 action을 표시하고 선택 AI 캐릭터 소유 부모의 댓글에는 writer와 관계없이 삭제 action을 제공한다.
  • 브라우저용 AUTH-01과 신규 관리자 Operation 58개를 API client와 화면에 필요한 범위로 연결한다.
  • 기존 27.8의 58개 baseline 구현은 유지하고, frontend-original-work-prompt.md의 원작 Operation 8개를 추가해 브라우저용 신규 관리자 Operation 66개를 연결한다.
  • contentUrl을 영속 저장하지 않고 contentUrlExpiresAtUtc 기준으로 player 진입·만료 임박 시 CONTENT-02를 재조회하며 TTL을 재계산하거나 raw 업로드 경로를 조합하지 않는다.
  • character-scoped query key와 캐릭터 목록·기준정보용 global query key를 분리한다.
  • 공통 UI는 실제 반복 사용되는 단위로 component화하고 도메인별 validation과 form을 범용 CRUD 설정 하나로 합치지 않는다.
  • 27.2의 선택 stack과 제외 목록을 지키고 lockfile, typecheck, ESLint, unit/UI test와 production build 검증을 통과한다.
  • 현재 비어 있는 작업 디렉터리를 frontend project root로 사용하고 그 아래에 프로젝트 디렉터리를 다시 중첩 생성하지 않는다.
  • Backend DTO/data class 이름을 전제로 하지 않고 27.8의 Operation별 실제 Request/Response JSON만으로 type, API client와 mock을 구현한다.
  • .env.development.env.production에서 동일한 VITE_API_BASE_URL key에 서로 다른 dev·production API Base URL을 설정하고 source code에 두 URL을 하드코딩하지 않는다.
  • packageManagerpnpm@11.15.0으로 고정하고 Jenkins가 pnpm install --frozen-lockfile, pnpm run ci:prod 순서로 typecheck, lint, unit test와 production build를 검증해 dist/를 생성한다.
  • mutation 성공과 일시적 오류는 Sonner, field/form 오류는 inline, 최초 조회 실패는 ErrorState, 파괴적 작업의 사전 확인은 AlertDialog로 분리하고 중복 알림이나 별도 notification center를 만들지 않는다.
  • API의 절대 날짜·시간을 UTC Z로 보관·비교하고 Asia/Seoul로만 표시하며, KST 입력은 전송 직전에 UTC Z로 변환한다. duration과 preview offset은 timezone 변환하지 않는다.
  • production host가 app route를 index.html로 rewrite하고 정적 asset과 절대 API Base URL 요청에는 SPA fallback을 적용하지 않는지 deep-link 새로고침으로 검증한다.
  • 환경 책임자의 edge 접근 제어, HTML meta noindex, 응답 X-Robots-Tag를 적용하고 robots.txt Disallow: /로 noindex 확인을 차단하지 않는다.
  • 승인된 production host/edge와 설정 책임자가 없으면 production 배포 완료로 판단하지 않는다.

23. Metrics

  • 권한 없는 신규 API 접근 성공 건수: 0
  • 다른 AI 캐릭터 소유 리소스 변경 성공 건수: 0
  • 캐릭터 삭제로 인한 연관 리소스 물리 삭제 건수: 0
  • 원작 삭제로 인한 원작·링크·태그·이미지·캐릭터 관계 물리 삭제 건수: 0
  • 원작 배정·해제 Request의 부분 성공 건수: 0
  • 삭제된 원작을 새로 참조하는 캐릭터 관계 건수: 0
  • 삭제되지 않은 동일 제목 원작의 동시 생성 건수: 0
  • 신규 변경 API 구조화 audit log 누락 건수: 0
  • 신규 API에서 parent/root 귀속 검증 우회 성공 건수: 0

24. Decisions

  • 관리자 로그인은 기존 POST /admin/member/login을 재사용한다.
  • AI 캐릭터 관리자 전용 로그인과 AI 캐릭터 사칭 토큰은 만들지 않는다.
  • 1차 접근 권한은 ROLE_ADMIN으로 제한한다.
  • v2 AI 캐릭터 관리자 메뉴는 클라이언트가 소유하며 legacy GET /menu를 재사용하지 않는다.
  • 신규 menu Endpoint 또는 capability Endpoint는 이번 범위에 만들지 않는다.
  • FanTalk 답글은 FanTalk 목록 응답에 포함하고 별도 답글 조회 Endpoint는 만들지 않는다.
  • 신규 API는 v2 패키지에 두고 /admin/ai-characters를 base path로 사용한다.
  • 원작 CRUD·검색·캐릭터 배정은 global /admin/ai-characters/original-works 아래 8개 Operation으로 v2에 이관하고 legacy /admin/chat/original/**는 호환을 위해 유지한다.
  • legacy 원작 mutation 5개와 legacy 캐릭터 등록·수정 2개 전체는 기존 외부 계약과 patch 의미를 유지한 채 같은 v2 원작·캐릭터 command로 위임해 공존 중 정책 우회와 외부 작업 뒤 원작 연결 실패의 부분 성공을 막는다. legacy 원작 조회와 일반 사용자용 원작 조회는 기존 흐름을 유지한다.
  • legacy 원작 목록과 검색은 ORIGINAL-WORK-01의 페이징 search Query로 합치고 별도 v2 /search Endpoint는 만들지 않는다.
  • 원작 도메인은 v2.originalwork가 소유하고 관리자 HTTP 계층만 v2.admin.aicharacter에 둔다.
  • 원작에 연결된 캐릭터가 하나라도 있으면 삭제를 409로 거부하고 명시적 해제를 요구한다.
  • 이미 삭제된 원작의 반복 DELETE는 연결 수보다 먼저 판정해 멱등 성공한다. 배포 전 삭제 원작 연결 불일치가 1건 이상이면 별도 승인된 보정을 완료하기 전 mutation 전환을 활성화하지 않는다.
  • 원작 배정은 다른 원작의 활성 AI 캐릭터를 새 원작으로 이동하며, 해제는 Path 원작 귀속을 전건 검증한다. 어느 작업도 부분 성공하지 않는다.
  • 원작 관계 mutation은 양수 연결 대상 또는 원작 Path가 있으면 원작 row, 캐릭터 row 순서의 pessimistic lock을 사용하고, CHAR-04null과 legacy 수정의 0 해제는 캐릭터 row만 잠근다. 원작 생성과 제목이 실제 바뀌는 수정은 MySQL SERIALIZABLE transaction과 1회 재시도로 동시성 불변식을 보장한다.
  • 신규 CHAR-03originalWorkId=null은 미연결, CHAR-04의 명시적 null은 해제이며 신규 계약은 legacy의 0 sentinel을 사용하지 않는다. legacy 호환 adapter는 기존 등록의 0을 미연결로, 수정의 0만 해제로 변환한다.
  • v2 Kotlin package와 HTTP URL version은 별개이므로 기존 v2 관리자 관례에 없는 /admin/v2 또는 /v2/admin prefix를 추가하지 않는다.
  • 관리자 principal은 실제 ADMIN으로 유지하고 선택 캐릭터의 creatorMember만 도메인 작업 주체로 전달한다.
  • legacy Controller, Service, Repository 및 Request/Response DTO를 신규 v2 비즈니스 로직에서 재사용하지 않는다.
  • AWS S3 Trigger worker의 기존 PUT /audio-content/upload-complete 계약과 처리 흐름을 유지한다. v2 콘텐츠도 기존 row·S3 계약으로 처리하며 V1/V2 dispatcher 또는 v2 completion use case를 추가하지 않는다.
  • 신규 upload-complete Endpoint는 실제 AWS 연동 전환 일정과 호출 주체가 확정될 때 별도 PRD에서 정의하며 이번 범위에는 선제 구현하지 않는다.
  • 기존 오디오 가공 worker의 코드·스케줄·AWS Trigger 설정은 변경하지 않는다.
  • v2 전용 예약 공개 scheduler를 만들지 않고 기존 scheduler component의 cron·lock을 유지한다. 삭제 콘텐츠와 비활성 creator를 공개하지 않는 callback·조회 조건만 최소 보강한다.
  • 캐릭터 삭제 시 소유 콘텐츠의 기존 raw isActivefalse로 전환하고 나머지 콘텐츠 필드와 구매 이력을 보존한다. 일반 공개 탐색과 미구매 상세는 차단하고 기존 구매 재생은 유지한다.
  • 콘텐츠·creator ranking snapshot은 현재 콘텐츠·creator 활성 상태를 visible query에 적용해 stale 노출만 차단하고 snapshot row는 보존한다.
  • legacy creator ranking과 공개 캐릭터·콘텐츠·시리즈 banner도 연결 대상의 현재 활성 상태를 확인하며 원본 ranking/banner row는 보존한다.
  • 삭제 성공 commit 후 영향받는 기존 cache namespace 3개만 clear하며 별도 범용 cache invalidation framework나 Redis key scan은 만들지 않는다.
  • 필요한 command/query 기능은 콘텐츠, 댓글, 시리즈, 커뮤니티, FanTalk 등 v2 각 도메인 패키지에 구현한다.
  • 기존 v2 domain/application/port는 계약이 맞는 경우 v2 내부에서 재사용하거나 확장한다.
  • 기존 DB 스키마와 JPA 매핑은 변경하거나 복제하지 않고 v2 persistence adapter 뒤에서 연결한다. 이번 기능을 위한 DDL·backfill·데이터 migration은 만들지 않는다.
  • 기존 OriginalWork 관련 entity와 ChatCharacter.originalWork 관계도 v2 persistence adapter에서 그대로 사용하며 원작용 schema·JPA mapping·data migration을 추가하지 않는다.
  • 콘텐츠 상태는 기존 isActive, releaseDate, duration과 연결 creator 활성 상태로 계산하고, 저장된 content 경로는 Signed URL 발급 전 canonical output key인지 검증한다.
  • 콘텐츠 생성의 previewStartTime, previewEndTime은 기존 worker용 S3 metadata로만 전달하고 DB나 목록·상세 Response에 저장하지 않는다.
  • 공통 인증, ApiResponse 및 외부 인프라 client는 v2 port 또는 web adapter 경계에서 재사용한다.
  • 콘텐츠 목록·상세의 가공 오디오는 legacy와 같은 TTL의 CloudFront Signed URL로 반환하고 커버 이미지는 비서명 CDN URL로 반환한다.
  • Controller 간 호출과 내부 HTTP 호출은 금지한다.
  • 독립 업무 리소스 삭제는 논리 삭제로 통일하고, 시리즈-콘텐츠 및 creator-Member-tag 연결 해제만 join row 물리 제거 예외로 둔다.
  • legacy 캐릭터 비활성화 경로는 v2 삭제 cascade를 사용한다. 기존 비활성 AI 캐릭터 데이터의 선제 migration은 하지 않으며 실제 불일치가 확인되면 별도 승인 범위로 다룬다.
  • 댓글·FanTalk의 타인 작성 본문 수정은 금지하고, 선택 캐릭터 소유 리소스에 달린 댓글의 삭제만 허용한다.
  • 요구 목록에서 빠진 콘텐츠 고정, 콘텐츠 카테고리, 시리즈 구성·순서, 커뮤니티 고정, 기준정보 조회, FanTalk 원문 moderation, 채널 공지, 채널 프로필과 creator tag 설정을 이번 범위에 포함한다.
  • 기존 27.8 Frontend 프롬프트는 적용된 baseline이므로 수정하지 않고, 원작 화면·선택 UI와 8개 JSON 계약은 별도 frontend-original-work-prompt.md로 추가한다.
  • 라이브, 정산 및 시그니처 후원은 별도 제품 범위로 둔다.

25. Open Questions

  • 제품·API·UX 미결정 사항은 없다. 요구사항이 바뀌면 구현 전에 이 PRD와 후속 plan-task.md를 먼저 갱신한다.
  • dev·production API Base URL 실제 값과 production static host/edge 접근 제어 설정은 배포 전에 환경 책임자가 제공해야 한다. 값이 없으면 예시 URL로 배포하지 않고 production 배포를 차단 상태로 보고한다.

Documents

  • AGENTS.md
  • docs/agent-guides/작업절차.md
  • docs/agent-guides/문서유지보수.md
  • docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md
  • docs/20260611_AI캐릭터_크리에이터기능_최소연결/prd.md
  • docs/20260622_크리에이터_채널_FanTalk_탭_API/prd.md
  • docs/20260709_팬톡_작성수정_응답보강/prd.md
  • docs/20260706_커뮤니티_게시물_상세_API/prd.md

Authentication and Menu

  • src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberLoginController.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberLoginService.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuController.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuService.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuRepository.kt

AI Character

  • src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterCreatorMemberService.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDto.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/service/AdminChatCharacterService.kt

Original Work

  • src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkController.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/dto/OriginalWorkDtos.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWork.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkLink.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkTag.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkTagMapping.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/aws/s3/S3Uploader.kt
  • src/main/resources/application.yml
  • src/test/resources/application.yml

Creator Operations

  • src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryService.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/category/CreatorAdminCategoryService.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentService.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityCommentRepository.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/ChannelNotice.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheers.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileUpdateRequest.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberTagController.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberTagService.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/member/tag/CreatorTag.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberCreatorTag.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageValidation.kt
  • src/main/resources/application.yml

v2 Admin URL, Existing AWS Callback, and Content Signed URL

  • src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobController.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobController.kt
  • src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobControllerTest.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentController.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentService.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentRepository.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/GetCreatorAdminContentListResponse.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/content/UploadCompleteRequest.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/scheduler/AudioContentReleaseScheduledTask.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/aws/s3/S3Uploader.kt
  • src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/AudioContentCloudFront.kt
  • src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt

27. Web Frontend Client Development Handoff

27.1 Purpose and Scope

이 장은 AI 캐릭터 관리자 웹 클라이언트를 구현할 때 사용할 기술·UX 결정과 복사 가능한 개발 프롬프트다. 27장 전체는 프론트엔드 전용 handoff appendix이며 Backend 구현 범위나 Backend 완료 조건이 아니다. 기준일은 2026-07-20이며, 구현을 시작할 때는 아래 major/minor 범위 안의 최신 보안 patch를 확인한 뒤 lockfile로 고정한다.

  • 브라우저가 호출하는 Operation은 기존 로그인 AUTH-01과 신규 관리자 Operation 58개다.
  • 프론트엔드 에이전트는 Backend DTO/data class를 볼 수 없다고 가정한다. 27.8의 Operation별 실제 Request/Response JSON만으로 type, API client와 mock을 구현할 수 있어야 한다.
  • Request/Response의 canonical contract는 9~18장이며, Operation ID로 화면·API 함수·테스트를 연결한다. 27.8 JSON과 본문 계약이 다르면 구현하지 않고 계약 불일치로 보고한다.
  • 이미 생성되어 있는 빈 작업 디렉터리 자체를 frontend project root로 사용하며 하위에 프로젝트 디렉터리를 다시 만들지 않는다.
  • production 배포 인프라는 25장의 외부 prerequisite다. 프론트엔드 구현자는 승인되지 않은 hosting 또는 edge 제품을 임의 도입하지 않는다.

27.2 Selected Frontend Stack

Area Decision Reason
Build/CI tooling runtime Node.js 24 LTS, pnpm 11.15.0, packageManager와 lockfile 고정 local과 Jenkins가 같은 package manager version과 dependency graph를 사용함
UI runtime React 19.2 stable line 현재 공식 안정 React major/minor
Build Vite 8.1 stable line, React TypeScript template 별도 Spring API를 호출하는 내부 SPA이며 SSR, RSC, BFF가 필요하지 않음
Language TypeScript 6.0 stable line, strict=true TypeScript 7 생태계 전환 비용 없이 최신 안정 도구와 호환되는 기준
Routing React Router 8.2 Declarative Mode TanStack Query가 data layer를 소유하므로 loader/action을 중복하지 않음
Styling Tailwind CSS 4.3 + @tailwindcss/vite Vite 공식 통합 방식
Components shadcn/ui latest stable CLI + Base UI primitive 신규 shadcn 프로젝트의 기본 primitive이며 필요한 component source만 가져옴
UI feedback shadcn/ui Sonner + inline field/form error + ErrorState 성공·일시 오류, 입력 오류, 조회 오류를 서로 다른 수명과 위치로 표시함
Server state TanStack Query v5 조회, mutation, cache invalidation과 loading/error 상태를 관리함
Tables TanStack Table v8 서버 페이징 목록에만 manualPagination으로 사용함
Forms TanStack Form v1 + Zod 4 중첩 캐릭터 field array와 multipart form을 type-safe하게 관리함
HTTP Browser fetch 기반 얇은 wrapper Axios와 별도 API framework 없이 Bearer, JSON, multipart, ApiResponse만 공통 처리함
Date/time Native Date, epoch millisecond, Intl.DateTimeFormat API UTC instant를 유지하고 별도 date library 없이 KST 표시만 수행함
Lint Vite React TypeScript template의 ESLint + typescript-eslint 별도 formatter/linter 경쟁 구성을 추가하지 않고 template 기준을 유지함
Unit/UI test Vitest 4.1 + jsdom + Testing Library + user-event + jest-dom browser DOM 환경에서 사용자 동작 중심으로 검증함
E2E Playwright latest stable patch 로그인, route guard, 캐릭터 scope, CRUD 핵심 흐름만 검증함

Node.js와 vite preview는 production application server가 아니다. vite builddist를 25장에서 환경 책임자가 정한 static host/edge가 제공한다.

다음은 이번 범위에서 사용하지 않는다.

  • Next.js: SSR, Server Component, Server Action 또는 BFF 요구가 없고 공개 검색 노출도 금지한다.
  • Redux, Zustand: 서버 상태는 TanStack Query, 선택 캐릭터와 필터는 URL이 source of truth다.
  • React Hook Form: 폼 라이브러리는 TanStack Form 하나로 통일한다.
  • Axios: fetch wrapper로 필요한 계약을 충족한다.
  • OpenAPI code generator: 실제 OpenAPI 문서가 발행되기 전에는 수기 PRD와 생성 결과가 어긋날 수 있다.
  • 범용 CRUD engine: 도메인별 validation과 화면 차이를 거대한 설정 객체 하나로 숨기지 않는다.
  • prerelease, beta, RC package: 운영 관리자 페이지에 사용하지 않는다.

27.3 Menu, Character Selection, and Routes

메뉴는 Backend에서 받지 않고 클라이언트의 typed static configuration으로 관리한다.

type AdminMenuItem = {
  key: string
  label: string
  to: string
  scope: "GLOBAL" | "CHARACTER"
}

전역 메뉴는 AI 캐릭터 관리 -> /ai-characters 하나다. 캐릭터를 선택한 뒤에만 다음 character scope 메뉴를 표시한다.

  • 캐릭터 정보
  • 콘텐츠
  • 콘텐츠 카테고리
  • 시리즈
  • 커뮤니티
  • FanTalk
  • 채널 설정

콘텐츠 댓글과 커뮤니티 댓글은 부모 리소스의 목록·상세 화면에서 진입하며 sidebar 최상위 메뉴로 만들지 않는다.

등록·수정 화면의 캐릭터 선택 정책은 다음과 같다.

  • AI 캐릭터 자체 등록은 선택할 기존 캐릭터가 없으므로 /ai-characters/new에서 시작한다.
  • 콘텐츠, 카테고리, 시리즈, 커뮤니티, FanTalk 답글 및 채널 설정은 먼저 캐릭터를 선택한 뒤 접근한다.
  • 선택한 characterId는 전역 memory가 아니라 URL Path가 source of truth다.
  • child resource Request body에 characterId 또는 creatorId를 추가하지 않는다. PRD의 Path parameter만 사용한다.
  • deep link로 진입하면 URL의 characterIdCHAR-02를 조회해 CharacterContextBar를 복원한다.
  • 캐릭터를 임의로 자동 선택하지 않는다. 전환은 사용자의 명시적 선택으로만 수행한다.
  • dirty form에서 캐릭터를 전환하거나 route를 이탈하면 확인한다.
  • 비활성 캐릭터는 조회와 허용된 삭제만 제공하고 그 밖의 mutation control을 disabled 처리한다. Backend 409도 그대로 처리한다.

권장 route는 다음과 같다.

Route UI
/login 관리자 로그인
/ai-characters 캐릭터 목록과 명시적 선택
/ai-characters/new 캐릭터 등록 Page
/ai-characters/:characterId 캐릭터 상세
/ai-characters/:characterId/edit 캐릭터 수정 Page
/ai-characters/:characterId/contents 콘텐츠 목록
/ai-characters/:characterId/contents/new 콘텐츠 등록 Page
/ai-characters/:characterId/contents/:contentId 콘텐츠 상세와 Signed URL player
/ai-characters/:characterId/contents/:contentId/edit 콘텐츠 수정 Page
/ai-characters/:characterId/contents/:contentId/comments 콘텐츠 댓글 moderation
/ai-characters/:characterId/content-categories 콘텐츠 카테고리 목록
/ai-characters/:characterId/content-categories/:categoryId/contents 카테고리 콘텐츠 구성
/ai-characters/:characterId/series 시리즈 목록
/ai-characters/:characterId/series/new 시리즈 등록 Page
/ai-characters/:characterId/series/:seriesId 시리즈 상세와 콘텐츠 구성
/ai-characters/:characterId/series/:seriesId/edit 시리즈 수정 Page
/ai-characters/:characterId/community-posts 커뮤니티 게시글 목록
/ai-characters/:characterId/community-posts/new 커뮤니티 게시글 등록 Page
/ai-characters/:characterId/community-posts/:postId 게시글 상세와 댓글 진입
/ai-characters/:characterId/community-posts/:postId/edit 게시글 수정 Page
/ai-characters/:characterId/community-posts/:postId/comments 커뮤니티 댓글 moderation
/ai-characters/:characterId/fan-talks FanTalk 목록과 embedded 답글
/ai-characters/:characterId/channel-settings 공지·프로필·creator tag 설정

BrowserRouter deep link와 새로고침을 위해 production static host/edge는 /login, /ai-characters, /ai-characters/**의 파일이 아닌 GET 요청을 index.html로 rewrite한다. 정적 asset 요청에는 SPA fallback을 적용하지 않는다. API 요청은 현재 Vite mode의 VITE_API_BASE_URL 절대 URL로 보내므로 frontend route rewrite 대상이 아니다.

27.4 Page, Dialog, and Inline UI Rule

URL 복원, 새로고침, 서버 페이징, 복잡한 validation 또는 감사 대상 문맥이 필요한 primary resource는 Page로 만든다. 부모 화면 안에서 끝나는 짧은 입력·선택·확인만 Dialog 또는 inline UI로 만든다.

Use case UI Reason
캐릭터 목록·상세·등록·수정 Page 중첩 관계·성격·배경·기억과 이미지 form이 큼
콘텐츠 목록·상세·등록·수정 Page multipart, 계산 status, player, 다양한 validation이 있음
콘텐츠·커뮤니티 댓글 목록 Page 루트·답글 서버 페이징과 moderation 문맥이 필요함
콘텐츠·커뮤니티 댓글 작성·수정 inline 또는 작은 Dialog 짧은 본문 입력이며 부모 목록을 벗어날 필요가 없음
시리즈 목록·상세·등록·수정·구성 Page 콘텐츠 검색·구성과 순서 관리가 있음
커뮤니티 게시글 목록·상세·등록·수정 Page multipart와 댓글 진입 문맥이 있음
FanTalk 목록 Page 답글이 목록 응답에 포함되고 root 문맥이 필요함
FanTalk 답글 등록·수정 inline 또는 작은 Dialog 별도 조회 Endpoint 없이 선택 root 안에서 완료됨
콘텐츠 카테고리 목록 Page 서버 페이징과 순서 관리가 있음
카테고리 생성·이름 수정 Dialog 짧은 단일 작업
시리즈·카테고리 available content 선택 Dialog 부모 구성 작업을 위한 검색·다중 선택
creator tag 선택 Dialog 또는 Combobox 채널 설정 form의 종속 선택
채널 공지·프로필 하나의 Page 안 section 또는 tab 같은 캐릭터의 채널 설정 문맥을 공유함
삭제·비활성화·고정 해제 AlertDialog 파괴적 또는 노출 상태를 바꾸는 작업
캐릭터 전환 Command/Dialog 현재 character scope를 명시적으로 바꿈

Dialog가 여러 tab, 중첩 form, browser history 또는 독립적인 서버 페이징 URL을 요구하기 시작하면 Page로 승격한다.

댓글 row action은 권한 계약을 UI에도 반영한다.

  • 선택 AI 캐릭터가 작성한 댓글과 답글만 수정 action을 표시한다.
  • 선택 AI 캐릭터 소유 콘텐츠·게시글에 달린 댓글은 writer와 관계없이 삭제 action을 표시할 수 있다.
  • 다른 캐릭터 소유 부모의 댓글은 route에 진입시키지 않고, Backend의 동일 소유권 검증도 유지한다.

27.5 Reusable Component Boundary

아래 경로는 현재 비어 있는 작업 디렉터리를 그대로 사용하는 project root 기준이다.

shadcn source component는 src/components/ui, 두 개 이상의 실제 화면에서 반복되는 조합은 src/components/shared, 도메인별 화면·schema·column은 src/features/{domain}에 둔다.

최초 공통 component 후보는 다음으로 제한한다.

  • AppShell
  • AppSidebar
  • CharacterContextBar
  • PageHeader
  • SearchFilterBar
  • ServerDataTable
  • ServerPagination
  • StatusBadge
  • EmptyState
  • ErrorState
  • AppToaster
  • FormErrorSummary
  • ConfirmDeleteDialog
  • FormActions
  • ImageUploadField
  • UtcDateTime

두 번째 실제 사용처가 생기기 전에는 도메인 component를 공통 component로 승격하지 않는다.

권장 feature 경계는 다음과 같다.

src/
  app/
  routes/
  components/ui/
  components/shared/
  features/auth/
  features/ai-characters/
  features/contents/
  features/content-comments/
  features/content-categories/
  features/series/
  features/community-posts/
  features/community-comments/
  features/fan-talks/
  features/channel-settings/
  lib/api/
  lib/query/
  lib/routes/
  test/

27.6 Search Indexing Prohibition and Security Layers

robots.txtnoindex만으로 “절대 검색 노출 금지”를 보장할 수 없다. 접근 제어를 1차 경계로 두고 다음 책임을 분리한다.

Owner Required Contract
환경·플랫폼 책임자 production HTML 앞에 조직 승인 IAP, Zero Trust, VPN, IP allowlist 또는 edge authentication을 적용하고 비인가 요청을 차단한다. 제품별 redirect 또는 401/403 동작은 배포 검증에 기록한다.
환경·플랫폼 책임자 HTML과 비인가 응답에 X-Robots-Tag: noindex, nofollow, noarchive, nosnippet, noimageindex, HTML에 Cache-Control: no-store를 적용한다.
환경·플랫폼 책임자 27.3의 SPA rewrite를 구성하고 dev·production API Base URL 실제 값을 각 환경에 제공한다. 정적 asset에는 SPA fallback을 적용하지 않는다.
프론트엔드 공통 index.html<meta name="robots" content="noindex,nofollow,noarchive,nosnippet,noimageindex">를 둔다.
프론트엔드 AUTH-01로 로그인하고 role=ADMIN만 route에 진입시킨다. route guard를 Backend ROLE_ADMIN 검사의 대체 수단으로 사용하지 않는다.
프론트엔드 sitemap, prerender, SSR, 공개 marketing page를 만들지 않고 VITE_*에는 API base URL 같은 공개 설정만 둔다. JWT, API key, private key 또는 다른 secret을 build-time 변수에 넣지 않는다.

robots.txtDisallow: /를 두면 crawler가 meta 또는 HTTP noindex를 읽지 못해 URL만 검색 결과에 남을 수 있으므로 이 방식은 사용하지 않는다. robots.txt는 보안 경계가 아니며 생략하거나 noindex 확인을 막지 않는 형태로만 제공한다.

25장의 승인된 production host/edge와 설정 책임자가 제공되지 않으면 프론트엔드 에이전트는 production 인프라를 임의 선택하지 않는다. local production build와 설정 요구사항 문서까지만 만들고 배포는 차단 상태로 보고한다.

27.7 Frontend Runtime, Build, Feedback, Date, and API Rules

27.7.1 Environment and API Base URL

Vite mode별로 같은 key에 다른 API Base URL을 주입한다. 아래 host는 형식 설명용 예시이며 실제 배포 값이 아니다.

.env.development

VITE_API_BASE_URL=https://dev-api.example.com

.env.production

VITE_API_BASE_URL=https://api.example.com
  • source code에 dev·production URL을 동시에 하드코딩하거나 runtime host를 보고 추측하지 않는다.
  • ImportMetaEnv를 선언해 VITE_API_BASE_URL을 필수 string으로 취급한다.
  • 앱 bootstrap에서 값의 존재, http: 또는 https: 절대 URL 여부를 검증하고 trailing slash는 한 곳에서만 제거한다. 누락·예시 값·잘못된 URL이면 시작 또는 build를 실패시킨다.
  • 모든 API URL은 검증된 Base URL과 이 PRD의 /admin/... Path를 결합해 만든다.
  • pnpm dev는 development mode, pnpm run build:prod는 production mode를 사용한다.
  • VITE_* 값은 browser bundle에 노출되므로 API Base URL 같은 공개 설정만 넣고 secret, JWT 또는 API key를 넣지 않는다.
  • API가 cross-origin이면 허용 origin, method, header와 credential 정책은 환경·Backend 책임자가 명시적으로 구성한다. 프론트엔드는 Vite proxy나 same-origin reverse proxy가 있다고 가정하지 않는다.

27.7.2 Package Scripts and Jenkins Build

package.json"packageManager": "pnpm@11.15.0"을 선언하고 pnpm-lock.yaml을 commit한다. script의 canonical contract는 다음과 같다.

{
  "scripts": {
    "dev": "vite --mode development",
    "typecheck": "tsc -b --pretty false",
    "lint": "eslint . --max-warnings=0",
    "test:run": "vitest run",
    "build:dev": "vite build --mode development",
    "build:prod": "vite build --mode production",
    "ci:prod": "pnpm run typecheck && pnpm run lint && pnpm run test:run && pnpm run build:prod"
  },
  "packageManager": "pnpm@11.15.0"
}

Jenkins의 install·검증·build 명령은 다음 순서를 기준으로 한다.

npm install --global corepack@latest
corepack enable
corepack prepare pnpm@11.15.0 --activate
pnpm --version
pnpm install --frozen-lockfile
pnpm run ci:prod
  • pnpm --version 결과가 11.15.0이 아니면 pipeline을 실패시킨다.
  • production VITE_API_BASE_URL은 승인된 Jenkins environment 또는 workspace의 .env.production으로 제공한다. 값이 없거나 example.com이면 build를 실패시킨다.
  • pnpm run ci:prod가 성공한 뒤 생성된 dist/만 정적 배포 artifact로 보관한다.
  • vite preview는 production server로 사용하지 않는다.

27.7.3 In-Page Feedback and Error Display

이 관리자 페이지의 “내부 알림”은 별도 알림함이나 실시간 알림 시스템이 아니라 현재 사용자 작업 결과를 알려 주는 UI feedback이다.

  • root에 Sonner Toaster를 하나만 두고 position="top-right", richColors, closeButton, 기본 표시 시간 4초를 적용한다.
  • mutation 성공과 짧게 확인하면 되는 background 오류는 Sonner에 표시한다. 성공 toast는 HTTP 성공 응답 뒤에만 표시하고 optimistic success toast는 사용하지 않는다.
  • errorProperty가 특정 field를 가리키면 해당 field 아래 inline error에 연결한다. field에 귀속되지 않는 form 오류는 FormErrorSummary에 표시하며 같은 오류를 toast로 중복 표시하지 않는다.
  • 최초 목록·상세 조회 실패는 해당 content 영역의 ErrorState와 재시도 action으로 표시한다.
  • background refetch 또는 field에 귀속되지 않는 mutation 실패만 중복을 제거한 error toast로 표시한다.
  • 401은 session을 정리하고 /login으로 이동한 뒤 “세션이 만료되었습니다”를 한 번 표시한다. 403은 권한 없음 Page를 표시한다.
  • 삭제·비활성화·고정 해제는 호출 전에 AlertDialog로 확인한다. 처리 결과는 toast 또는 inline error로 표시하며 AlertDialog를 결과 알림으로 재사용하지 않는다.
  • 이번 범위에는 notification center, 읽음 상태, WebSocket/SSE, push 알림 또는 알림 영속 저장을 추가하지 않는다.

27.7.4 UTC Storage and KST Display

  • Request와 Response의 절대 날짜·시간은 ISO-8601 UTC Z 문자열을 사용한다. 필드 이름은 createdAtUtc, updatedAtUtc, releaseAtUtc, contentUrlExpiresAtUtc처럼 *AtUtc를 사용한다.
  • API 원문, query cache와 비교 로직은 UTC 문자열 또는 epoch millisecond를 유지한다. browser·운영체제 timezone을 저장 기준으로 사용하지 않는다.
  • 화면 표시는 Intl.DateTimeFormat("ko-KR", { timeZone: "Asia/Seoul", ... })로 KST 변환하고 날짜·시간을 표시하는 곳에 KST를 명시한다.
  • 목록은 분 단위, 상세·tooltip은 초 단위로 표시하고 hourCycle: "h23"을 사용한다. 상대 시간만 단독으로 표시하지 않는다.
  • datetime-local 입력값은 KST wall-clock으로 해석하고 전송 직전에 명시적 +09:00 instant로 만든 뒤 toISOString()의 UTC Z 값으로 변환한다. 입력 문자열 뒤에 Z만 붙이지 않는다.
  • 공통 utility는 최소한 parseUtcInstant, formatUtcInKst, kstInputToUtcIso, utcIsoToKstInput으로 제한하고 invalid 또는 UTC Z가 아닌 API instant는 계약 오류로 처리한다.
  • nullable 날짜는 조회 화면에서 -, form에서 빈 값으로 표시한다.
  • duration, previewStartTime, previewEndTime 같은 HH:mm:ss offset은 절대 시각이 아니므로 timezone 변환하지 않는다.
  • 자정·연말·월말 경계, nullable 값, invalid 값과 KST 입력→UTC→KST round trip을 unit test로 검증한다.

27.7.5 API Client and Cache

  • 27.8의 실제 JSON에서 공통 응답 envelope와 page shape를 TypeScript generic으로 추출할 수 있지만 Backend DTO/data class 이름에 의존하지 않는다.
  • AUTH-01 성공 시 { token, role }sessionStorage에 보관하고 새로고침 시 함께 복원한다. localStorage, URL, log 또는 build-time 환경변수에는 저장하지 않는다.
  • 모든 관리자 API에 Authorization: Bearer <token>을 추가한다. 복원된 roleADMIN이 아니거나 값이 손상되면 session을 지우고 로그인으로 이동한다.
  • Query parameter는 URLSearchParams로 만들고 null, undefined와 빈 검색어는 보내지 않는다.
  • JSON mutation에는 Content-Type: application/json을 명시한다.
  • multipart는 API별 정확한 file part 이름을 지키고 request part에는 27.8에 표시된 Request JSON을 JSON.stringify한 문자열을 넣는다. 브라우저가 boundary를 만들게 하므로 multipart 전체 Content-Type을 직접 지정하지 않는다.
  • child resource body에 characterId, creatorId, writer ID를 추가하지 않는다.
  • character-scoped query key는 ["ai-character", characterId, domain, ...]로 시작하고 캐릭터 전환 시 이전 캐릭터 mutation을 재사용하지 않는다.
  • CHAR-01과 content theme, series genre, creator tag metadata는 characterId가 없는 별도 global key factory를 사용한다.
  • 목록은 응답의 page, size, totalCount, hasNext를 사용하고 TanStack Table은 manualPagination=true로 둔다.
  • mutation 성공 후 해당 character와 parent resource 범위의 query만 invalidate한다.
  • CONTENT-01, CONTENT-02contentUrl과 전체 응답을 persistent storage에 보관하지 않는다.
  • player 진입 시 CONTENT-02를 조회한다. duration으로 TTL을 재계산하지 않고 contentUrlExpiresAtUtc만 기준으로 만료 또는 만료 임박 여부를 판단해 상세를 한 번 재조회한다.
  • contentUrl=null이면 player를 숨기고 API가 반환한 계산 status를 표시한다. raw path나 preview path를 조합하지 않는다.

27.8 Copy-Paste Frontend Development Prompt

아래 블록은 이 PRD 전체와 함께 프론트엔드 구현 에이전트에 제공한다. 프론트엔드 구현자는 별도 구현 타입 정보 없이 문서의 HTTP 계약만 사용한다. 각 Operation의 Request JSON과 Response JSON은 클라이언트 type·API client·mock을 만들 수 있는 self-contained 예시이며, validation과 권한의 최종 기준은 9~20장이다.

당신은 운영용 AI 캐릭터 관리자 웹 클라이언트를 구현한다.

[작업 위치]
- 현재 비어 있는 작업 디렉터리 자체를 project root로 사용하고 하위에 별도 프로젝트 디렉터리를 만들지 않는다.
- 이 프롬프트만으로 독립 실행 가능한 frontend project를 만들고 문서에 없는 외부 구현 타입을 찾거나 전제하지 않는다.
- production host/edge, 접근 제어 제품과 설정 책임자는 환경 책임자가 제공한다. 값이 없으면 local production
  build와 배포 요구사항 문서까지만 만들고 production 배포를 완료했다고 말하지 않는다.

[목표]
- 기존 POST /admin/member/login으로 로그인한 ADMIN만 접근하는 SPA를 만든다.
- 관리자가 캐릭터를 명시적으로 선택한 뒤 해당 characterId scope에서 콘텐츠, 댓글, 카테고리,
  시리즈, 커뮤니티, FanTalk, 채널 설정을 관리하게 한다.
- 아래 HTTP Endpoint와 JSON 계약을 임의 변경하지 않는다.
- legacy GET /menu를 호출하지 않고 메뉴를 클라이언트 typed static config로 제공한다.

[기술 스택]
- Node.js 24 LTS, pnpm 11.15.0, packageManager와 pnpm-lock.yaml 고정
- React 19.2 stable, Vite 8.1 stable, TypeScript 6.0 strict
- React Router 8.2 Declarative Mode
- Tailwind CSS 4.3, @tailwindcss/vite
- shadcn/ui latest stable CLI, Base UI primitive
- shadcn/ui Sonner, inline field/form error, 조회 ErrorState
- TanStack Query v5, TanStack Table v8, TanStack Form v1, Zod 4
- fetch wrapper
- Vite React TypeScript template의 ESLint와 typescript-eslint
- Vitest 4.1, jsdom, Testing Library, user-event, jest-dom, Playwright
- beta, RC, prerelease package를 사용하지 않는다.
- Next.js, Redux, Zustand, React Hook Form, Axios, OpenAPI generator를 추가하지 않는다.
- Node나 vite preview를 production server로 사용하지 않는다. vite build의 dist는 승인된 static host/edge가 제공한다.

[환경별 API Base URL]
- .env.development: VITE_API_BASE_URL=https://dev-api.example.com
- .env.production: VITE_API_BASE_URL=https://api.example.com
- 위 URL은 예시다. 실제 dev·production 값이 제공되기 전에는 배포하지 않는다.
- 같은 VITE_API_BASE_URL key를 Vite mode별로 다르게 설정하고 source code에 두 URL을 하드코딩하지 않는다.
- 값을 필수 typed env로 선언하고 누락, example.com, http/https가 아닌 값이면 시작 또는 build를 실패시킨다.
- API URL은 검증된 Base URL과 아래 절대 Path를 결합한다. Vite proxy나 same-origin reverse proxy를 가정하지 않는다.
- VITE_*에는 browser에 노출해도 되는 값만 넣고 secret, JWT와 API key를 넣지 않는다.

[package.json scripts와 Jenkins]
- package.json에 "packageManager": "pnpm@11.15.0"을 기록한다.
- scripts는 다음 명령을 정확히 제공한다.
  dev = vite --mode development
  typecheck = tsc -b --pretty false
  lint = eslint . --max-warnings=0
  test:run = vitest run
  build:dev = vite build --mode development
  build:prod = vite build --mode production
  ci:prod = pnpm run typecheck && pnpm run lint && pnpm run test:run && pnpm run build:prod
- Jenkins는 Node.js 24 LTS agent에서 다음 순서로 실행한다.
  npm install --global corepack@latest
  corepack enable
  corepack prepare pnpm@11.15.0 --activate
  pnpm --version
  pnpm install --frozen-lockfile
  pnpm run ci:prod
- pnpm --version이 11.15.0이 아니면 실패한다. 성공 후 dist/만 배포 artifact로 보관한다.
- production VITE_API_BASE_URL이 없거나 example.com이면 Jenkins build를 실패시킨다.

[공통 API 계약]
- 각 Operation의 Response JSON 전체가 실제 envelope 예시다. 문서에 없는 Response type이나 class를 추측하지 않는다.
- 성공 envelope key는 success, message, data, errorProperty다.
- 목록 data key는 items, page, size, totalCount, hasNext다.
- 공통 오류 Response JSON:
  {"success":false,"message":"요청을 처리할 수 없습니다.","data":null,"errorProperty":"characterId"}
- Bearer JWT를 사용한다.
- 로그인 성공의 {token, role}만 sessionStorage에 함께 저장·복원하고 localStorage, URL, log에는 저장하지 않는다.
- 401 또는 ADMIN이 아닌 복원 role은 sessionStorage 삭제 후 /login, 403은 권한 없음,
  400/409는 errorProperty를 form 오류에 연결한다.
- Path의 characterId가 작업 대상이다. body에 creatorId, characterId, writerId를 추가하지 않는다.
- multipart의 request part는 각 Operation에 표시된 Request JSON을 JSON.stringify한 문자열이다.
  브라우저가 boundary를 생성하도록 multipart 전체 Content-Type을 직접 지정하지 않는다.
- character-scoped query key는 ["ai-character", characterId, domain, ...]를 사용한다.
- CHAR-01, content theme, series genre, creator tag는 characterId가 없는 global query key를 사용한다.

[날짜·시간]
- 모든 *AtUtc 절대 시각 Request/Response는 ISO-8601 UTC Z 문자열이다.
- API 원문, query cache와 비교는 UTC 문자열 또는 epoch millisecond를 유지하고 표시에만 Asia/Seoul을 적용한다.
- Intl.DateTimeFormat("ko-KR", { timeZone: "Asia/Seoul", ... })을 사용하고 화면에 KST를 표시한다.
- 목록은 분 단위, 상세와 tooltip은 초 단위, hourCycle은 h23으로 표시하며 상대 시간만 단독 표시하지 않는다.
- datetime-local 입력은 KST로 해석해 명시적 +09:00 instant로 만든 뒤 toISOString() UTC Z 값으로 전송한다.
  입력 문자열에 Z만 붙이지 않는다.
- parseUtcInstant, formatUtcInKst, kstInputToUtcIso, utcIsoToKstInput 공통 utility를 만들고 경계값을 test한다.
- nullable 날짜는 조회에서 -, form에서 빈 값으로 표시한다.
- duration, previewStartTime, previewEndTime은 HH:mm:ss offset이므로 timezone 변환하지 않는다.

[화면 내부 알림]
- root에 Sonner Toaster를 하나만 두고 top-right, richColors, closeButton, 기본 4초로 표시한다.
- mutation 성공은 HTTP 성공 응답 뒤 Sonner success toast로 표시한다.
- errorProperty가 가리키는 field 오류는 field 아래, 나머지 form 오류는 FormErrorSummary에 표시하고 toast와 중복하지 않는다.
- 최초 조회 실패는 content 영역의 ErrorState와 재시도 action으로 표시한다.
- background refetch 또는 field에 귀속되지 않는 mutation 오류만 중복 제거한 Sonner error toast로 표시한다.
- 401은 session을 지우고 /login으로 이동한 뒤 세션 만료 toast를 한 번 표시한다. 403은 권한 없음 Page다.
- AlertDialog는 삭제·비활성화·고정 해제 호출 전 확인에만 사용한다.
- notification center, 읽음 상태, WebSocket/SSE, push 또는 알림 영속 저장은 만들지 않는다.

[Operation Catalog: Authentication and Character]
AUTH-01 POST /admin/member/login
  Request JSON:
  {"email":"admin@example.com","password":"password"}
  Response JSON:
  {"success":true,"message":null,"data":{"token":"<jwt>","role":"ADMIN"},"errorProperty":null}

CHAR-01 GET /admin/ai-characters
  Path: 없음
  Query: page=0&size=20&search=루나&isActive=true
  Request JSON: 없음
  Response JSON:
  {
    "success": true,
    "message": null,
    "data": {
      "items": [{
        "characterId": 101,
        "creatorId": 10001,
        "name": "루나",
        "imageUrl": "https://cdn.example.com/characters/101.webp",
        "description": "달빛 라디오 DJ",
        "gender": "FEMALE",
        "age": 24,
        "mbti": "INFP",
        "region": "KR",
        "tags": ["라디오", "힐링"],
        "isActive": true,
        "createdAtUtc": "2026-07-20T09:00:00Z",
        "updatedAtUtc": "2026-07-20T10:00:00Z"
      }],
      "page": 0,
      "size": 20,
      "totalCount": 1,
      "hasNext": false
    },
    "errorProperty": null
  }

CHAR-02 GET /admin/ai-characters/{characterId}
  Path: characterId=101
  Query: 없음
  Request JSON: 없음
  Response JSON:
  {
    "success": true,
    "message": null,
    "data": {
      "characterId": 101,
      "creatorId": 10001,
      "characterUuid": "018f4dc0-9c49-7d15-9f73-3f573e52a871",
      "name": "루나",
      "imageUrl": "https://cdn.example.com/characters/101.webp",
      "description": "달빛 라디오 DJ",
      "systemPrompt": "차분하고 다정한 말투로 대화한다.",
      "characterType": "Character",
      "age": 24,
      "gender": "FEMALE",
      "mbti": "INFP",
      "speechPattern": "문장 끝을 부드럽게 맺는다.",
      "speechStyle": "차분함",
      "appearance": "은빛 단발",
      "region": "KR",
      "isActive": true,
      "tags": ["라디오", "힐링"],
      "hobbies": ["음악 감상"],
      "values": ["공감"],
      "goals": ["팬의 편안한 밤 돕기"],
      "relationships": [{
        "personName": "별이",
        "relationshipName": "친구",
        "description": "오랜 친구",
        "importance": 5,
        "relationshipType": "FRIEND",
        "currentStatus": "CLOSE"
      }],
      "personalities": [{"trait":"다정함","description":"상대의 감정을 먼저 살핀다."}],
      "backgrounds": [{"topic":"직업","description":"심야 라디오 DJ다."}],
      "memories": [{"title":"첫 방송","content":"첫 생방송을 성공했다.","emotion":"기쁨"}],
      "originalWork": {"id":71,"imageUrl":"https://cdn.example.com/works/71.webp","title":"달빛 방송국"},
      "createdAtUtc": "2026-07-20T09:00:00Z",
      "updatedAtUtc": "2026-07-20T10:00:00Z"
    },
    "errorProperty": null
  }

CHAR-03 POST /admin/ai-characters
  Parts: image=<required File>, request=<required JSON string>
  request Part JSON:
  {
    "name": "루나",
    "systemPrompt": "차분하고 다정한 말투로 대화한다.",
    "description": "달빛 라디오 DJ",
    "age": 24,
    "gender": "FEMALE",
    "mbti": "INFP",
    "speechPattern": "문장 끝을 부드럽게 맺는다.",
    "speechStyle": "차분함",
    "appearance": "은빛 단발",
    "region": "KR",
    "originalWorkId": 71,
    "characterType": "Character",
    "tags": ["라디오", "힐링"],
    "hobbies": ["음악 감상"],
    "values": ["공감"],
    "goals": ["팬의 편안한 밤 돕기"],
    "relationships": [{
      "personName": "별이",
      "relationshipName": "친구",
      "description": "오랜 친구",
      "importance": 5,
      "relationshipType": "FRIEND",
      "currentStatus": "CLOSE"
    }],
    "personalities": [{"trait":"다정함","description":"상대의 감정을 먼저 살핀다."}],
    "backgrounds": [{"topic":"직업","description":"심야 라디오 DJ다."}],
    "memories": [{"title":"첫 방송","content":"첫 생방송을 성공했다.","emotion":"기쁨"}]
  }
  Response JSON:
  {"success":true,"message":null,"data":{"characterId":101,"creatorId":10001,"isActive":true},"errorProperty":null}

CHAR-04 PUT /admin/ai-characters/{characterId}
  Path: characterId=101
  Parts: image=<optional File>, request=<required JSON string>
  request Part JSON:
  {
    "name": "루나",
    "systemPrompt": "차분하고 다정한 말투로 대화한다.",
    "description": "달빛 라디오 DJ",
    "age": 25,
    "gender": "FEMALE",
    "mbti": "INFP",
    "speechPattern": "문장 끝을 부드럽게 맺는다.",
    "speechStyle": "차분함",
    "appearance": "은빛 단발",
    "originalWorkId": 71,
    "characterType": "Character",
    "tags": ["라디오", "힐링"],
    "hobbies": ["음악 감상"],
    "values": ["공감"],
    "goals": ["팬의 편안한 밤 돕기"],
    "relationships": [],
    "personalities": [],
    "backgrounds": [],
    "memories": []
  }
  Response JSON:
  {"success":true,"message":null,"data":{"characterId":101,"creatorId":10001,"isActive":true},"errorProperty":null}

CHAR-05 DELETE /admin/ai-characters/{characterId}
  Path: characterId=101
  Query: 없음
  Request JSON: 없음
  Response JSON:
  {"success":true,"message":null,"data":{"characterId":101,"creatorId":10001,"characterIsActive":false,"creatorIsActive":false},"errorProperty":null}

[Operation Catalog: Content]
CONTENT-01 GET /admin/ai-characters/{characterId}/contents
  Path: characterId=101
  Query: page=0&size=20&search=비&isActive=true&status=PUBLISHED
  Request JSON: 없음
  Response JSON:
  {
    "success": true,
    "message": null,
    "data": {
      "items": [{
        "contentId": 2001,
        "title": "비 오는 밤",
        "detail": "수면을 위한 빗소리",
        "coverImageUrl": "https://cdn.example.com/audio_content_cover/2001/cover.webp",
        "contentUrl": "https://audio.example.com/output/2001/audio.m4a?Expires=...",
        "contentUrlExpiresAtUtc": "2026-07-20T14:00:00Z",
        "themeId": 1,
        "theme": "ASMR",
        "price": 0,
        "purchaseOption": "BOTH",
        "limited": null,
        "totalContentCount": null,
        "remainingContentCount": null,
        "isAdult": false,
        "isActive": true,
        "isPointAvailable": false,
        "isCommentAvailable": true,
        "isGeneratePreview": false,
        "isOnlyRental": false,
        "isFullDetailVisible": true,
        "languageCode": "ko",
        "isPinned": false,
        "status": "PUBLISHED",
        "duration": "00:10:30",
        "releaseAtUtc": "2026-07-20T12:00:00Z",
        "tags": ["수면", "빗소리"],
        "createdAtUtc": "2026-07-20T11:00:00Z",
        "updatedAtUtc": "2026-07-20T12:00:00Z"
      }],
      "page": 0,
      "size": 20,
      "totalCount": 1,
      "hasNext": false
    },
    "errorProperty": null
  }

CONTENT-02 GET /admin/ai-characters/{characterId}/contents/{contentId}
  Path: characterId=101, contentId=2001
  Query: 없음
  Request JSON: 없음
  Response JSON:
  {
    "success": true,
    "message": null,
    "data": {
      "contentId": 2001,
      "title": "비 오는 밤",
      "detail": "수면을 위한 빗소리",
      "coverImageUrl": "https://cdn.example.com/audio_content_cover/2001/cover.webp",
      "contentUrl": "https://audio.example.com/output/2001/audio.m4a?Expires=...",
      "contentUrlExpiresAtUtc": "2026-07-20T14:00:00Z",
      "themeId": 1,
      "theme": "ASMR",
      "price": 0,
      "purchaseOption": "BOTH",
      "limited": null,
      "totalContentCount": null,
      "remainingContentCount": null,
      "isAdult": false,
      "isActive": true,
      "isPointAvailable": false,
      "isCommentAvailable": true,
      "isGeneratePreview": false,
      "isOnlyRental": false,
      "isFullDetailVisible": true,
      "languageCode": "ko",
      "isPinned": false,
      "status": "PUBLISHED",
      "duration": "00:10:30",
      "releaseAtUtc": "2026-07-20T12:00:00Z",
      "tags": ["수면", "빗소리"],
      "createdAtUtc": "2026-07-20T11:00:00Z",
      "updatedAtUtc": "2026-07-20T12:00:00Z"
    },
    "errorProperty": null
  }

CONTENT-03 POST /admin/ai-characters/{characterId}/contents
  Path: characterId=101
  Parts: contentFile=<required File>, coverImage=<required File>, request=<required JSON string>
  request Part JSON:
  {
    "title": "비 오는 밤",
    "detail": "수면을 위한 빗소리",
    "tags": ["수면", "빗소리"],
    "price": 0,
    "purchaseOption": "BOTH",
    "limited": null,
    "releaseAtUtc": "2026-07-20T12:00:00Z",
    "themeId": 1,
    "isAdult": false,
    "isGeneratePreview": false,
    "isOnlyRental": false,
    "isPointAvailable": false,
    "isCommentAvailable": true,
    "isFullDetailVisible": true,
    "previewStartTime": null,
    "previewEndTime": null,
    "languageCode": "ko"
  }
  Response JSON:
  {"success":true,"message":null,"data":{"contentId":2001,"isActive":false,"status":"PROCESSING"},"errorProperty":null}

CONTENT-04 PUT /admin/ai-characters/{characterId}/contents/{contentId}
  Path: characterId=101, contentId=2001
  Parts: coverImage=<optional File>, request=<required JSON string>
  request Part JSON:
  {"title":"비 오는 밤","detail":"수면을 위한 빗소리","tags":["수면","빗소리"],"price":0,"isAdult":false,"isPointAvailable":false,"isCommentAvailable":true}
  Response JSON:
  {"success":true,"message":null,"data":{"id":2001,"isActive":true},"errorProperty":null}

CONTENT-05 DELETE /admin/ai-characters/{characterId}/contents/{contentId}
  Path: characterId=101, contentId=2001
  Query: 없음
  Request JSON: 없음
  Response JSON:
  {"success":true,"message":null,"data":{"id":2001,"isActive":false},"errorProperty":null}

CONTENT-06 PUT /admin/ai-characters/{characterId}/contents/{contentId}/pin
  Path: characterId=101, contentId=2001
  Request JSON:
  {"isPinned":true}
  Response JSON:
  {"success":true,"message":null,"data":{"contentId":2001,"isPinned":true,"replacedContentId":null},"errorProperty":null}

CONTENT-07 GET /admin/ai-characters/metadata/content-themes
  Path, Query, Request JSON: 없음
  Response JSON:
  {"success":true,"message":null,"data":[{"themeId":1,"name":"ASMR","imageUrl":"https://cdn.example.com/themes/1.webp"}],"errorProperty":null}

[Operation Catalog: Content Comment]
CONTENT-COMMENT-01 GET /admin/ai-characters/{characterId}/contents/{contentId}/comments
  Path: characterId=101, contentId=2001
  Query: page=0&size=20&isActive=true
  Request JSON: 없음
  Response JSON:
  {
    "success": true,
    "message": null,
    "data": {
      "items": [{
        "commentId": 2101,
        "parentCommentId": null,
        "writerId": 9001,
        "writerNickname": "팬A",
        "writerProfileImageUrl": null,
        "content": "잘 들었습니다.",
        "languageCode": "ko",
        "donationCan": 0,
        "isSecret": false,
        "isActive": true,
        "replyCount": 1,
        "createdAtUtc": "2026-07-20T12:10:00Z",
        "updatedAtUtc": null
      }],
      "page": 0,
      "size": 20,
      "totalCount": 1,
      "hasNext": false
    },
    "errorProperty": null
  }

CONTENT-COMMENT-02 GET /admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId}/replies
  Path: characterId=101, contentId=2001, commentId=2101
  Query: page=0&size=20&isActive=true
  Request JSON: 없음
  Response JSON:
  {
    "success": true,
    "message": null,
    "data": {
      "items": [{
        "commentId": 2102,
        "parentCommentId": 2101,
        "writerId": 10001,
        "writerNickname": "루나",
        "writerProfileImageUrl": "https://cdn.example.com/characters/101.webp",
        "content": "들어주셔서 고마워요.",
        "languageCode": "ko",
        "donationCan": 0,
        "isSecret": false,
        "isActive": true,
        "replyCount": 0,
        "createdAtUtc": "2026-07-20T12:20:00Z",
        "updatedAtUtc": null
      }],
      "page": 0,
      "size": 20,
      "totalCount": 1,
      "hasNext": false
    },
    "errorProperty": null
  }

CONTENT-COMMENT-03 POST /admin/ai-characters/{characterId}/contents/{contentId}/comments
  Path: characterId=101, contentId=2001
  Request JSON:
  {"content":"들어주셔서 고마워요.","parentCommentId":2101,"isSecret":false,"languageCode":"ko"}
  Response JSON:
  {"success":true,"message":null,"data":{"id":2102,"isActive":true},"errorProperty":null}

CONTENT-COMMENT-04 PUT /admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId}
  Path: characterId=101, contentId=2001, commentId=2102
  Request JSON:
  {"content":"정말 고마워요."}
  Response JSON:
  {"success":true,"message":null,"data":{"id":2102,"isActive":true},"errorProperty":null}

CONTENT-COMMENT-05 DELETE /admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId}
  Path: characterId=101, contentId=2001, commentId=2101
  Query: 없음
  Request JSON: 없음
  Response JSON:
  {"success":true,"message":null,"data":{"id":2101,"isActive":false},"errorProperty":null}

[Operation Catalog: Series]
SERIES-01 GET /admin/ai-characters/{characterId}/series
  Path: characterId=101
  Query: page=0&size=20&search=밤&isActive=true&state=PROCEEDING
  Request JSON: 없음
  Response JSON:
  {
    "success": true,
    "message": null,
    "data": {
      "items": [{
        "seriesId": 3001,
        "title": "루나의 밤",
        "introduction": "매주 금요일 밤의 이야기",
        "coverImageUrl": "https://cdn.example.com/series/3001.webp",
        "publishedDaysOfWeek": ["FRI"],
        "genreId": 3,
        "isAdult": false,
        "state": "PROCEEDING",
        "isActive": true,
        "writer": "루나",
        "studio": null
      }],
      "page": 0,
      "size": 20,
      "totalCount": 1,
      "hasNext": false
    },
    "errorProperty": null
  }

SERIES-02 GET /admin/ai-characters/{characterId}/series/{seriesId}
  Path: characterId=101, seriesId=3001
  Query: 없음
  Request JSON: 없음
  Response JSON:
  {
    "success": true,
    "message": null,
    "data": {
      "seriesId": 3001,
      "title": "루나의 밤",
      "introduction": "매주 금요일 밤의 이야기",
      "coverImageUrl": "https://cdn.example.com/series/3001.webp",
      "publishedDaysOfWeek": ["FRI"],
      "genreId": 3,
      "isAdult": false,
      "state": "PROCEEDING",
      "isActive": true,
      "writer": "루나",
      "studio": null,
      "genre": "힐링",
      "keyword": "수면,라디오",
      "createdAtUtc": "2026-07-20T09:00:00Z",
      "updatedAtUtc": "2026-07-20T10:00:00Z"
    },
    "errorProperty": null
  }

SERIES-03 POST /admin/ai-characters/{characterId}/series
  Path: characterId=101
  Parts: image=<required File>, request=<required JSON string>
  request Part JSON:
  {"title":"루나의 밤","introduction":"매주 금요일 밤의 이야기","publishedDaysOfWeek":["FRI"],"keyword":"수면,라디오","genreId":3,"isAdult":false,"writer":"루나","studio":null}
  Response JSON:
  {"success":true,"message":null,"data":{"id":3001,"isActive":true},"errorProperty":null}

SERIES-04 PUT /admin/ai-characters/{characterId}/series/{seriesId}
  Path: characterId=101, seriesId=3001
  Parts: image=<optional File>, request=<required JSON string>
  request Part JSON:
  {"title":"루나의 밤","introduction":"매주 금요일 밤의 이야기","keyword":"수면,라디오","publishedDaysOfWeek":["FRI"],"genreId":3,"isAdult":false,"state":"PROCEEDING","writer":"루나","studio":null}
  Response JSON:
  {"success":true,"message":null,"data":{"id":3001,"isActive":true},"errorProperty":null}

SERIES-05 DELETE /admin/ai-characters/{characterId}/series/{seriesId}
  Path: characterId=101, seriesId=3001
  Query: 없음
  Request JSON: 없음
  Response JSON:
  {"success":true,"message":null,"data":{"id":3001,"isActive":false},"errorProperty":null}

SERIES-06 GET /admin/ai-characters/{characterId}/series/{seriesId}/contents
  Path: characterId=101, seriesId=3001
  Query: page=0&size=20
  Request JSON: 없음
  Response JSON:
  {"success":true,"message":null,"data":{"items":[{"contentId":2001,"title":"비 오는 밤","coverImageUrl":"https://cdn.example.com/audio_content_cover/2001/cover.webp","isActive":true,"order":0}],"page":0,"size":20,"totalCount":1,"hasNext":false},"errorProperty":null}

SERIES-07 GET /admin/ai-characters/{characterId}/series/{seriesId}/available-contents
  Path: characterId=101, seriesId=3001
  Query: page=0&size=20&search=파도
  Request JSON: 없음
  Response JSON:
  {"success":true,"message":null,"data":{"items":[{"contentId":2002,"title":"잔잔한 파도","coverImageUrl":null,"isActive":true,"order":null}],"page":0,"size":20,"totalCount":1,"hasNext":false},"errorProperty":null}

SERIES-08 POST /admin/ai-characters/{characterId}/series/{seriesId}/contents
  Path: characterId=101, seriesId=3001
  Request JSON:
  {"contentIds":[2002]}
  Response JSON:
  {"success":true,"message":null,"data":{"seriesId":3001,"affectedContentIds":[2002]},"errorProperty":null}

SERIES-09 DELETE /admin/ai-characters/{characterId}/series/{seriesId}/contents/{contentId}
  Path: characterId=101, seriesId=3001, contentId=2001
  Query: 없음
  Request JSON: 없음
  Response JSON:
  {"success":true,"message":null,"data":{"seriesId":3001,"affectedContentIds":[2001]},"errorProperty":null}

SERIES-10 PUT /admin/ai-characters/{characterId}/series/orders
  Path: characterId=101
  Request JSON:
  {"seriesIds":[3003,3001,3002]}
  Response JSON:
  {"success":true,"message":null,"data":{"seriesIds":[3003,3001,3002]},"errorProperty":null}

SERIES-11 GET /admin/ai-characters/metadata/series-genres
  Path, Query, Request JSON: 없음
  Response JSON:
  {"success":true,"message":null,"data":[{"genreId":3,"name":"힐링"}],"errorProperty":null}

[Operation Catalog: Community Post and Comment]
COMMUNITY-POST-01 GET /admin/ai-characters/{characterId}/community-posts
  Path: characterId=101
  Query: page=0&size=20&isActive=true
  Request JSON: 없음
  Response JSON:
  {
    "success": true,
    "message": null,
    "data": {
      "items": [{
        "postId": 4001,
        "creatorId": 10001,
        "creatorNickname": "루나",
        "creatorProfileImageUrl": "https://cdn.example.com/characters/101.webp",
        "imageUrl": "https://cdn.example.com/community/4001.webp",
        "audioUrl": null,
        "content": "오늘 밤 10시에 만나요.",
        "price": 0,
        "isCommentAvailable": true,
        "isAdult": false,
        "isFixed": true,
        "isActive": true,
        "likeCount": 12,
        "commentCount": 3,
        "createdAtUtc": "2026-07-20T08:00:00Z",
        "updatedAtUtc": null
      }],
      "page": 0,
      "size": 20,
      "totalCount": 1,
      "hasNext": false
    },
    "errorProperty": null
  }

COMMUNITY-POST-02 GET /admin/ai-characters/{characterId}/community-posts/{postId}
  Path: characterId=101, postId=4001
  Query: 없음
  Request JSON: 없음
  Response JSON:
  {"success":true,"message":null,"data":{"postId":4001,"creatorId":10001,"creatorNickname":"루나","creatorProfileImageUrl":"https://cdn.example.com/characters/101.webp","imageUrl":"https://cdn.example.com/community/4001.webp","audioUrl":null,"content":"오늘 밤 10시에 만나요.","price":0,"isCommentAvailable":true,"isAdult":false,"isFixed":true,"isActive":true,"likeCount":12,"commentCount":3,"createdAtUtc":"2026-07-20T08:00:00Z","updatedAtUtc":null},"errorProperty":null}

COMMUNITY-POST-03 POST /admin/ai-characters/{characterId}/community-posts
  Path: characterId=101
  Parts: audioFile=<optional File>, postImage=<optional File>, request=<required JSON string>
  request Part JSON:
  {"content":"오늘 밤 10시에 만나요.","isCommentAvailable":true,"isAdult":false,"price":0}
  Response JSON:
  {"success":true,"message":null,"data":{"id":4001,"isActive":true},"errorProperty":null}

COMMUNITY-POST-04 PUT /admin/ai-characters/{characterId}/community-posts/{postId}
  Path: characterId=101, postId=4001
  Parts: postImage=<optional File>, request=<required JSON string>
  request Part JSON:
  {"content":"오늘 밤 10시에 꼭 만나요.","isCommentAvailable":true,"isAdult":false}
  Response JSON:
  {"success":true,"message":null,"data":{"id":4001,"isActive":true},"errorProperty":null}

COMMUNITY-POST-05 DELETE /admin/ai-characters/{characterId}/community-posts/{postId}
  Path: characterId=101, postId=4001
  Query: 없음
  Request JSON: 없음
  Response JSON:
  {"success":true,"message":null,"data":{"id":4001,"isActive":false},"errorProperty":null}

COMMUNITY-POST-06 PUT /admin/ai-characters/{characterId}/community-posts/{postId}/fixed
  Path: characterId=101, postId=4001
  Request JSON:
  {"isFixed":true}
  Response JSON:
  {"success":true,"message":null,"data":{"postId":4001,"isFixed":true},"errorProperty":null}

COMMUNITY-COMMENT-01 GET /admin/ai-characters/{characterId}/community-posts/{postId}/comments
  Path: characterId=101, postId=4001
  Query: page=0&size=20&isActive=true
  Request JSON: 없음
  Response JSON:
  {
    "success": true,
    "message": null,
    "data": {
      "items": [{
        "commentId": 4101,
        "parentCommentId": null,
        "writerId": 9001,
        "writerNickname": "팬A",
        "writerProfileImageUrl": null,
        "content": "기대할게요.",
        "isSecret": false,
        "isActive": true,
        "replyCount": 1,
        "createdAtUtc": "2026-07-20T08:10:00Z",
        "updatedAtUtc": null
      }],
      "page": 0,
      "size": 20,
      "totalCount": 1,
      "hasNext": false
    },
    "errorProperty": null
  }

COMMUNITY-COMMENT-02 GET /admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}/replies
  Path: characterId=101, postId=4001, commentId=4101
  Query: page=0&size=20&isActive=true
  Request JSON: 없음
  Response JSON:
  {
    "success": true,
    "message": null,
    "data": {
      "items": [{
        "commentId": 4102,
        "parentCommentId": 4101,
        "writerId": 10001,
        "writerNickname": "루나",
        "writerProfileImageUrl": "https://cdn.example.com/characters/101.webp",
        "content": "조금 뒤에 만나요.",
        "isSecret": false,
        "isActive": true,
        "replyCount": 0,
        "createdAtUtc": "2026-07-20T08:20:00Z",
        "updatedAtUtc": null
      }],
      "page": 0,
      "size": 20,
      "totalCount": 1,
      "hasNext": false
    },
    "errorProperty": null
  }

COMMUNITY-COMMENT-03 POST /admin/ai-characters/{characterId}/community-posts/{postId}/comments
  Path: characterId=101, postId=4001
  Request JSON:
  {"content":"조금 뒤에 만나요.","parentCommentId":4101,"isSecret":false}
  Response JSON:
  {"success":true,"message":null,"data":{"id":4102,"isActive":true},"errorProperty":null}

COMMUNITY-COMMENT-04 PUT /admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}
  Path: characterId=101, postId=4001, commentId=4102
  Request JSON:
  {"content":"곧 만나요."}
  Response JSON:
  {"success":true,"message":null,"data":{"id":4102,"isActive":true},"errorProperty":null}

COMMUNITY-COMMENT-05 DELETE /admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}
  Path: characterId=101, postId=4001, commentId=4101
  Query: 없음
  Request JSON: 없음
  Response JSON:
  {"success":true,"message":null,"data":{"id":4101,"isActive":false},"errorProperty":null}

[Operation Catalog: FanTalk]
FAN-TALK-01 GET /admin/ai-characters/{characterId}/fan-talks
  Path: characterId=101
  Query: page=0&size=20
  Request JSON: 없음
  Response JSON:
  {
    "success": true,
    "message": null,
    "data": {
      "items": [{
        "fanTalkId": 5001,
        "writerId": 9001,
        "writerNickname": "팬A",
        "writerProfileImageUrl": "https://cdn.example.com/default-profile.webp",
        "content": "오늘도 힘내세요.",
        "createdAtUtc": "2026-07-20T07:00:00Z",
        "creatorReplies": [{
          "replyId": 5002,
          "fanTalkId": 5001,
          "writerId": 10001,
          "writerNickname": "루나",
          "writerProfileImageUrl": "https://cdn.example.com/characters/101.webp",
          "content": "응원 고마워요.",
          "isActive": true,
          "createdAtUtc": "2026-07-20T07:10:00Z",
          "updatedAtUtc": null
        }]
      }],
      "page": 0,
      "size": 20,
      "totalCount": 1,
      "hasNext": false
    },
    "errorProperty": null
  }

FAN-TALK-02 POST /admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies
  Path: characterId=101, fanTalkId=5001
  Request JSON:
  {"content":"응원 고마워요.","languageCode":"ko"}
  Response JSON:
  {"success":true,"message":null,"data":{"replyId":5002,"fanTalkId":5001,"writerId":10001,"writerNickname":"루나","writerProfileImageUrl":"https://cdn.example.com/characters/101.webp","content":"응원 고마워요.","isActive":true,"createdAtUtc":"2026-07-20T07:10:00Z","updatedAtUtc":null},"errorProperty":null}

FAN-TALK-03 PUT /admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}
  Path: characterId=101, fanTalkId=5001, replyId=5002
  Request JSON:
  {"content":"늘 응원해 줘서 고마워요."}
  Response JSON:
  {"success":true,"message":null,"data":{"replyId":5002,"fanTalkId":5001,"writerId":10001,"writerNickname":"루나","writerProfileImageUrl":"https://cdn.example.com/characters/101.webp","content":"늘 응원해 줘서 고마워요.","isActive":true,"createdAtUtc":"2026-07-20T07:10:00Z","updatedAtUtc":"2026-07-20T07:20:00Z"},"errorProperty":null}

FAN-TALK-04 DELETE /admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}
  Path: characterId=101, fanTalkId=5001, replyId=5002
  Query: 없음
  Request JSON: 없음
  Response JSON:
  {"success":true,"message":null,"data":{"id":5002,"isActive":false},"errorProperty":null}

FAN-TALK-05 DELETE /admin/ai-characters/{characterId}/fan-talks/{fanTalkId}
  Path: characterId=101, fanTalkId=5001
  Query: 없음
  Request JSON: 없음
  Response JSON:
  {"success":true,"message":null,"data":{"id":5001,"isActive":false},"errorProperty":null}

- 답글 조회 API를 추가하지 않는다. FAN-TALK-01 Response JSON의 creatorReplies를 사용한다.

[Operation Catalog: Content Category and Channel Settings]
CATEGORY-01 GET /admin/ai-characters/{characterId}/content-categories
  Path: characterId=101
  Query: page=0&size=20&isActive=true
  Request JSON: 없음
  Response JSON:
  {"success":true,"message":null,"data":{"items":[{"categoryId":6001,"title":"ASMR","order":0,"contentCount":2,"isActive":true}],"page":0,"size":20,"totalCount":1,"hasNext":false},"errorProperty":null}

CATEGORY-02 POST /admin/ai-characters/{characterId}/content-categories
  Path: characterId=101
  Request JSON:
  {"title":"ASMR","contentIds":[2001,2002]}
  Response JSON:
  {"success":true,"message":null,"data":{"categoryId":6001,"isActive":true},"errorProperty":null}

CATEGORY-03 PUT /admin/ai-characters/{characterId}/content-categories/{categoryId}
  Path: characterId=101, categoryId=6001
  Request JSON:
  {"title":"수면 ASMR"}
  Response JSON:
  {"success":true,"message":null,"data":{"categoryId":6001,"isActive":true},"errorProperty":null}

CATEGORY-04 DELETE /admin/ai-characters/{characterId}/content-categories/{categoryId}
  Path: characterId=101, categoryId=6001
  Query: 없음
  Request JSON: 없음
  Response JSON:
  {"success":true,"message":null,"data":{"categoryId":6001,"isActive":false},"errorProperty":null}

CATEGORY-05 PUT /admin/ai-characters/{characterId}/content-categories/orders
  Path: characterId=101
  Request JSON:
  {"categoryIds":[6003,6001,6002]}
  Response JSON:
  {"success":true,"message":null,"data":{"categoryIds":[6003,6001,6002]},"errorProperty":null}

CATEGORY-06 GET /admin/ai-characters/{characterId}/content-categories/{categoryId}/contents
  Path: characterId=101, categoryId=6001
  Query: page=0&size=20
  Request JSON: 없음
  Response JSON:
  {"success":true,"message":null,"data":{"items":[{"contentId":2001,"title":"비 오는 밤","coverImageUrl":"https://cdn.example.com/audio_content_cover/2001/cover.webp","isActive":true}],"page":0,"size":20,"totalCount":1,"hasNext":false},"errorProperty":null}

CATEGORY-07 GET /admin/ai-characters/{characterId}/content-categories/{categoryId}/available-contents
  Path: characterId=101, categoryId=6001
  Query: page=0&size=20&search=파도
  Request JSON: 없음
  Response JSON:
  {"success":true,"message":null,"data":{"items":[{"contentId":2002,"title":"잔잔한 파도","coverImageUrl":null,"isActive":true}],"page":0,"size":20,"totalCount":1,"hasNext":false},"errorProperty":null}

CATEGORY-08 POST /admin/ai-characters/{characterId}/content-categories/{categoryId}/contents
  Path: characterId=101, categoryId=6001
  Request JSON:
  {"contentIds":[2002]}
  Response JSON:
  {"success":true,"message":null,"data":{"categoryId":6001,"affectedContentIds":[2002]},"errorProperty":null}

CATEGORY-09 DELETE /admin/ai-characters/{characterId}/content-categories/{categoryId}/contents/{contentId}
  Path: characterId=101, categoryId=6001, contentId=2001
  Query: 없음
  Request JSON: 없음
  Response JSON:
  {"success":true,"message":null,"data":{"categoryId":6001,"affectedContentIds":[2001]},"errorProperty":null}

NOTICE-01 GET /admin/ai-characters/{characterId}/channel-notice
  Path: characterId=101
  Query: 없음
  Request JSON: 없음
  Response JSON:
  {"success":true,"message":null,"data":{"characterId":101,"creatorId":10001,"notice":"새 콘텐츠는 매주 금요일 공개됩니다.","updatedAtUtc":"2026-07-20T10:00:00Z"},"errorProperty":null}

NOTICE-02 PUT /admin/ai-characters/{characterId}/channel-notice
  Path: characterId=101
  Request JSON:
  {"notice":"새 콘텐츠는 매주 금요일 공개됩니다."}
  Response JSON:
  {"success":true,"message":null,"data":{"characterId":101,"creatorId":10001,"notice":"새 콘텐츠는 매주 금요일 공개됩니다.","updatedAtUtc":"2026-07-20T10:00:00Z"},"errorProperty":null}

CREATOR-TAG-01 GET /admin/ai-characters/metadata/creator-tags
  Path, Query, Request JSON: 없음
  Response JSON:
  {"success":true,"message":null,"data":[{"tagId":11,"name":"ASMR","imageUrl":"https://cdn.example.com/creator-tags/11.webp","isAdult":false}],"errorProperty":null}

CHANNEL-PROFILE-01 GET /admin/ai-characters/{characterId}/channel-profile
  Path: characterId=101
  Query: 없음
  Request JSON: 없음
  Response JSON:
  {
    "success": true,
    "message": null,
    "data": {
      "characterId": 101,
      "creatorId": 10001,
      "instagramUrl": "https://instagram.com/example",
      "fancimmUrl": "",
      "xUrl": "",
      "youtubeUrl": "https://youtube.com/@example",
      "kakaoOpenChatUrl": "",
      "creatorTags": [{
        "tagId": 11,
        "name": "ASMR",
        "imageUrl": "https://cdn.example.com/creator-tags/11.webp",
        "isAdult": false
      }],
      "isVisibleDonationRank": true,
      "donationRankingPeriod": "CUMULATIVE",
      "updatedAtUtc": "2026-07-20T10:00:00Z"
    },
    "errorProperty": null
  }

CHANNEL-PROFILE-02 PUT /admin/ai-characters/{characterId}/channel-profile
  Path: characterId=101
  Request JSON:
  {"instagramUrl":"https://instagram.com/example","fancimmUrl":"","xUrl":"","youtubeUrl":"https://youtube.com/@example","kakaoOpenChatUrl":"","tagIds":[11,14],"isVisibleDonationRank":true,"donationRankingPeriod":"CUMULATIVE"}
  Response JSON:
  {
    "success": true,
    "message": null,
    "data": {
      "characterId": 101,
      "creatorId": 10001,
      "instagramUrl": "https://instagram.com/example",
      "fancimmUrl": "",
      "xUrl": "",
      "youtubeUrl": "https://youtube.com/@example",
      "kakaoOpenChatUrl": "",
      "creatorTags": [
        {"tagId":11,"name":"ASMR","imageUrl":"https://cdn.example.com/creator-tags/11.webp","isAdult":false},
        {"tagId":14,"name":"힐링","imageUrl":null,"isAdult":false}
      ],
      "isVisibleDonationRank": true,
      "donationRankingPeriod": "CUMULATIVE",
      "updatedAtUtc": "2026-07-20T10:05:00Z"
    },
    "errorProperty": null
  }

[Signed URL]
- CONTENT-01과 CONTENT-02 Response JSON의 contentUrl은 가공 완료된 전체 오디오 CloudFront Signed URL이다.
- coverImageUrl은 Signed URL이 아닌 일반 CDN 절대 URL이다.
- 기존 DB 필드와 creator 활성 상태에서 계산한 status가 SCHEDULED/PUBLISHED이고 canonical output/{contentId}/... key와 duration이 있을 때만 URL이 있다.
- 계산 status가 PROCESSING/SUSPENDED/DELETED이면 contentUrl과 contentUrlExpiresAtUtc는 null이다.
- URL TTL은 (duration HH + 2)시간이며 URL policy 만료와 contentUrlExpiresAtUtc가 일치한다.
- 클라이언트는 duration으로 TTL을 계산하지 않고 contentUrlExpiresAtUtc만 사용한다.
- CONTENT-01/02는 응답마다 새 URL을 반환하며 API response Cache-Control은 private, no-store다.
- URL과 응답을 persistent storage에 저장하지 않는다.
- 만료 또는 만료 임박 시 CONTENT-02를 한 번 재조회한다.
- raw input path, DB path, preview path를 조합하거나 서명 실패 시 fallback하지 않는다.

[Menu and Route]
- 메뉴는 클라이언트 typed static config다. GET /menu를 호출하지 않는다.
- /ai-characters에서 캐릭터를 먼저 명시적으로 선택한다.
- 선택 characterId는 /ai-characters/:characterId/** URL Path가 source of truth다.
- 상단 CharacterContextBar에 avatar, name, characterId, active status, 전환 action을 표시한다.
- child resource 등록·수정 화면 안에서 다시 캐릭터를 선택하게 하지 않는다.
- 캐릭터 자체 등록만 /ai-characters/new에서 selection 없이 수행한다.
- production host는 /login과 /ai-characters/**의 파일이 아닌 GET을 index.html로 rewrite한다.
- 정적 asset과 VITE_API_BASE_URL로 보내는 API 요청에는 SPA fallback을 적용하지 않는다.

[Page vs Dialog]
- 캐릭터, 콘텐츠, 시리즈, 커뮤니티의 목록·상세·등록·수정은 Page다.
- 댓글 moderation, FanTalk 목록, 카테고리 구성은 Page다.
- 짧은 댓글·답글 입력, 카테고리 이름, available content 선택, creator tag 선택은 Dialog 또는 inline이다.
- 삭제·비활성화·고정 해제는 AlertDialog다.
- 선택 AI 캐릭터가 작성한 댓글에만 edit action을 표시한다.
- 선택 AI 캐릭터 소유 콘텐츠·게시글의 댓글은 writer와 관계없이 delete action을 표시할 수 있다.
- Dialog에 독립 URL, 여러 tab, 중첩 form 또는 복잡한 서버 페이징이 필요해지면 Page로 바꾼다.

[Component Reuse]
- shadcn 원시는 components/ui에 둔다.
- 두 개 이상의 실제 화면에서 반복되는 조합만 components/shared로 올린다.
- AppShell, AppSidebar, CharacterContextBar, PageHeader, SearchFilterBar, ServerDataTable,
  ServerPagination, StatusBadge, EmptyState, ErrorState, AppToaster, FormErrorSummary,
  ConfirmDeleteDialog, FormActions, ImageUploadField, UtcDateTime을 최초 공통 후보로 한다.
- 도메인별 schema, column, form은 features/{domain}에 유지한다.
- 범용 CRUD engine을 만들지 않는다.

[검색 노출 금지와 보안]
- 환경 책임자가 production HTML 앞에 조직 승인 edge 접근 제어를 적용한다.
- 승인된 host/edge와 설정 책임자가 없으면 임의 선택하거나 production 배포 완료로 판단하지 않는다.
- index.html에 noindex,nofollow,noarchive,nosnippet,noimageindex meta를 둔다.
- 환경 책임자는 HTML과 비인가 응답에 같은 X-Robots-Tag를, HTML에 Cache-Control: no-store를 적용한다.
- robots.txt Disallow: /는 crawler가 noindex를 읽지 못하게 할 수 있으므로 사용하지 않는다.
- robots.txt는 생략하거나 noindex 확인을 막지 않는 형태로만 제공하며 보안 경계로 간주하지 않는다.
- sitemap, SSR, prerender, 공개 marketing page를 만들지 않는다.
- VITE_* 환경변수에 secret이나 JWT를 넣지 않는다.
- 클라이언트 role guard는 화면 진입 UX를 위한 것이며 HTTP 401/403 처리를 생략하는 근거가 아니다.

[테스트]
- AUTH-01 성공/실패, token과 role의 sessionStorage 복원, ADMIN 외 role 차단, 401 session 정리, 403 화면을 검증한다.
- 캐릭터를 자동 선택하지 않는지, 선택 후 URL과 CharacterContextBar가 일치하는지 검증한다.
- deep link 새로고침에서 SPA rewrite 후 CHAR-02로 context가 복원되고 API/asset path가 index.html로 rewrite되지 않는지 검증한다.
- development와 production mode가 서로 다른 VITE_API_BASE_URL을 사용하고 누락·예시 값이면 실패하는지 검증한다.
- child mutation body에 creatorId/characterId/writerId가 들어가지 않는지 검증한다.
- JSON과 multipart part 이름 및 Query 직렬화를 Operation별로 검증한다.
- 서버 페이징, 빈 상태, 오류 상태, mutation 후 좁은 query invalidation을 검증한다.
- global query key와 character-scoped query key가 섞이지 않는지 검증한다.
- 타인 댓글에는 edit가 없고 선택 캐릭터 소유 부모의 댓글에는 delete가 표시되는지 검증한다.
- FanTalk 답글을 별도 GET 없이 creatorReplies로 렌더링하는지 검증한다.
- Signed URL null 상태, contentUrlExpiresAtUtc 기준 만료 임박 상세 재조회, TTL 미재계산, raw path 미사용을 검증한다.
- destructive action은 AlertDialog 확인 전 호출되지 않는지 검증한다.
- mutation success toast, inline field/form 오류, 조회 ErrorState, 401 session 만료 toast와 중복 알림 방지를 검증한다.
- UTC Z 검증, KST 표시, KST 입력의 UTC 변환, 자정·월말·연말 경계와 HH:mm:ss 미변환을 검증한다.
- meta robots, restrictive robots.txt 미사용, X-Robots-Tag와 환경별 edge 비인가 차단 동작을 배포 검증에 포함한다.
- Jenkins와 같은 pnpm install --frozen-lockfile 및 pnpm run ci:prod가 통과하고 dist/를 생성해야 완료다.
- 로그인, character scope와 주요 CRUD의 핵심 Playwright E2E가 통과해야 완료다.

[완료 산출물]
- 실행 가능한 Vite SPA
- typed route/menu config
- Operation ID 기준 typed API module
- 공통 layout/table/pagination/form/error component
- 각 도메인 Page/Dialog
- unit/UI test와 핵심 E2E
- README에 실행 방법, dev·production 환경변수, Jenkins 명령, UTC/KST 규칙,
  화면 feedback 규칙, production 접근 제어 및 noindex 검증 방법
- 구현하지 않은 API나 화면이 있으면 숨기지 말고 Operation ID와 이유를 명시한다.

27.9 Official Frontend References

28. Original Work Frontend Add-on

27.8은 이미 적용된 58개 Operation의 baseline 프롬프트이므로 내용을 수정하지 않는다. 원작 관리 8개 Operation과 캐릭터 등록·수정의 원작 검색·선택 UI는 다음 별도 delta 프롬프트를 기존 frontend 프로젝트에 추가 적용한다.

  • Prompt: docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md
  • 적용 후 브라우저용 계약: 기존 로그인 AUTH-01 + 신규 관리자 Operation 66개
  • 원작 추가 Operation: ORIGINAL-WORK-01~ORIGINAL-WORK-08
  • 기존 stack, 환경별 VITE_API_BASE_URL, Jenkins 명령, 인증·세션, noindex, UTC/KST와 feedback 규칙은 변경하지 않는다.
  • 27.8 fenced prompt의 변경 전 SHA-256은 5956ddc152c026937728381d625859bdea65b9a2f16a39b200f0d6b3660a73e1이며 문서 보강 후에도 같아야 한다.

29. PRD-Plan Synchronization and Legacy API Mapping

29.1 Synchronization Check

  • 이 PRD와 plan-task.md는 신규 관리자 Operation 66개를 같은 범위로 추적한다.
  • plan-task.md의 Task 1.1~11.4는 PRD의 인증, 메뉴, 원작, 캐릭터, 콘텐츠, 댓글, 카테고리, 시리즈, 커뮤니티, FanTalk, 채널 설정, 삭제 cascade, legacy 호환, callback 유지 및 경계 검증을 모두 포함한다.
  • 기존 AUTH-01, legacy /admin/chat/**, 기존 /audio-content/upload-complete는 신규 Operation 수에 포함하지 않는다는 점도 두 문서가 일치한다.
  • Frontend baseline 58개와 원작 8개 추가로 브라우저용 신규 관리자 Operation 66개가 된다는 설명도 두 문서가 일치한다.

29.2 Mapping Rule

  • 아래 표는 신규 v2 관리자 API가 이관·대체·참고하는 legacy HTTP API를 추적하기 위한 문서다.
  • 없음은 기존에 같은 목적의 HTTP API가 없거나, 기존 서비스 내부 기능만 있었음을 의미한다.
  • 부분 대응은 legacy API가 데이터 조회나 일부 동작의 근거만 제공하며 신규 v2 계약을 그대로 대체하지 못한다는 의미다.
  • 이 표는 구현 의존성 허용 목록이 아니다. v2 신규 비즈니스 로직은 PRD 7.6과 21장의 경계대로 legacy Controller, Service, Repository, Request/Response DTO를 호출하지 않는다.

29.3 Authentication, Character, Original Work

Operation ID 신규 API 대응 legacy API 매핑 판단
AUTH-01 POST /admin/member/login 동일 기존 로그인 재사용
CHAR-01 GET /admin/ai-characters GET /admin/chat/character/list, GET /admin/chat/character/search 목록·검색 통합
CHAR-02 GET /admin/ai-characters/{characterId} GET /admin/chat/character/{characterId} 상세 이관
CHAR-03 POST /admin/ai-characters POST /admin/chat/character/register 등록 이관, legacy 성공 응답은 유지
CHAR-04 PUT /admin/ai-characters/{characterId} PUT /admin/chat/character/update 수정 이관, legacy nullable patch 의미는 adapter에서 보존
CHAR-05 DELETE /admin/ai-characters/{characterId} PUT /admin/chat/character/update with isActive=false 명시 DELETE 없음, 비활성화 경로를 v2 delete cascade로 수렴
ORIGINAL-WORK-01 GET /admin/ai-characters/original-works GET /admin/chat/original/list, GET /admin/chat/original/search 목록·검색 통합
ORIGINAL-WORK-02 GET /admin/ai-characters/original-works/{originalWorkId} GET /admin/chat/original/{id} 상세 이관
ORIGINAL-WORK-03 POST /admin/ai-characters/original-works POST /admin/chat/original/register 등록 이관, legacy 성공 응답은 유지
ORIGINAL-WORK-04 PUT /admin/ai-characters/original-works/{originalWorkId} PUT /admin/chat/original/update 수정 이관, legacy nullable patch 의미는 adapter에서 보존
ORIGINAL-WORK-05 DELETE /admin/ai-characters/original-works/{originalWorkId} DELETE /admin/chat/original/{id} 삭제 이관, 연결 존재 시 신규 정책 적용
ORIGINAL-WORK-06 GET /admin/ai-characters/original-works/{originalWorkId}/characters GET /admin/chat/original/{id}/characters 연결 캐릭터 목록 이관
ORIGINAL-WORK-07 POST /admin/ai-characters/original-works/{originalWorkId}/characters POST /admin/chat/original/{id}/assign-characters 배정 이관, 부분 성공 금지
ORIGINAL-WORK-08 DELETE /admin/ai-characters/original-works/{originalWorkId}/characters POST /admin/chat/original/{id}/unassign-characters 해제 이관, 신규 API는 DELETE body 사용

29.4 Content and Content Comment

Operation ID 신규 API 대응 legacy API 매핑 판단
CONTENT-01 GET /admin/ai-characters/{characterId}/contents GET /creator-admin/audio-content/list, GET /creator-admin/audio-content/search 목록·검색과 관리자 projection 재구성
CONTENT-02 GET /admin/ai-characters/{characterId}/contents/{contentId} GET /audio-content/{id} 부분 대응, 관리자 상세·Signed URL 계약은 v2에서 새로 정의
CONTENT-03 POST /admin/ai-characters/{characterId}/contents POST /audio-content 생성 이관, 기존 callback 계약 유지
CONTENT-04 PUT /admin/ai-characters/{characterId}/contents/{contentId} PUT /creator-admin/audio-content, PUT /audio-content 부분 대응, 수정 가능 field를 v2에서 제한
CONTENT-05 DELETE /admin/ai-characters/{characterId}/contents/{contentId} DELETE /audio-content/{id} 논리 삭제 이관
CONTENT-06 PUT /admin/ai-characters/{characterId}/contents/{contentId}/pin POST /audio-content/pin-to-the-top/{id}, PUT /audio-content/unpin-at-the-top/{id} 고정·해제 통합
CONTENT-07 GET /admin/ai-characters/metadata/content-themes GET /audio-content/theme, GET /audio-content/theme/active 기준정보 조회 이관
CONTENT-COMMENT-01 GET /admin/ai-characters/{characterId}/contents/{contentId}/comments GET /audio-content/{id}/comment 루트 댓글 목록 이관
CONTENT-COMMENT-02 GET /admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId}/replies GET /audio-content/comment/{id} 답글 목록 이관
CONTENT-COMMENT-03 POST /admin/ai-characters/{characterId}/contents/{contentId}/comments POST /audio-content/comment 작성 이관, writer는 AI creator로 고정
CONTENT-COMMENT-04 PUT /admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId} PUT /audio-content/comment 수정 이관, AI 작성자만 허용
CONTENT-COMMENT-05 DELETE /admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId} PUT /audio-content/comment 명시 DELETE 없음, 논리 삭제 동작만 v2로 분리

29.5 Category and Series

Operation ID 신규 API 대응 legacy API 매핑 판단
CATEGORY-01 GET /admin/ai-characters/{characterId}/content-categories GET /category 목록 이관
CATEGORY-02 POST /admin/ai-characters/{characterId}/content-categories POST /category 생성 이관
CATEGORY-03 PUT /admin/ai-characters/{characterId}/content-categories/{categoryId} PUT /category 수정 이관
CATEGORY-04 DELETE /admin/ai-characters/{characterId}/content-categories/{categoryId} DELETE /category/{id} 논리 삭제 이관
CATEGORY-05 PUT /admin/ai-characters/{characterId}/content-categories/orders PUT /category/orders 순서 변경 이관, 소유자 전체 집합 검증 추가
CATEGORY-06 GET /admin/ai-characters/{characterId}/content-categories/{categoryId}/contents GET /creator-admin/content-category 포함 콘텐츠 목록 이관
CATEGORY-07 GET /admin/ai-characters/{characterId}/content-categories/{categoryId}/available-contents GET /creator-admin/content-category/search 추가 가능 콘텐츠 검색 이관
CATEGORY-08 POST /admin/ai-characters/{characterId}/content-categories/{categoryId}/contents 없음 카테고리 생성·수정 내부 구성 기능을 명시 API로 분리
CATEGORY-09 DELETE /admin/ai-characters/{characterId}/content-categories/{categoryId}/contents/{contentId} 없음 카테고리-콘텐츠 연결 해제를 명시 API로 신설
SERIES-01 GET /admin/ai-characters/{characterId}/series GET /creator-admin/audio-content/series 목록 이관
SERIES-02 GET /admin/ai-characters/{characterId}/series/{seriesId} GET /creator-admin/audio-content/series/{id} 상세 이관
SERIES-03 POST /admin/ai-characters/{characterId}/series POST /creator-admin/audio-content/series 등록 이관
SERIES-04 PUT /admin/ai-characters/{characterId}/series/{seriesId} PUT /creator-admin/audio-content/series 수정 이관
SERIES-05 DELETE /admin/ai-characters/{characterId}/series/{seriesId} 없음 명시 삭제 API 신설
SERIES-06 GET /admin/ai-characters/{characterId}/series/{seriesId}/contents GET /creator-admin/audio-content/series/{id}/content 포함 콘텐츠 목록 이관
SERIES-07 GET /admin/ai-characters/{characterId}/series/{seriesId}/available-contents GET /creator-admin/audio-content/series/content/search 추가 가능 콘텐츠 검색 이관
SERIES-08 POST /admin/ai-characters/{characterId}/series/{seriesId}/contents POST /creator-admin/audio-content/series/add/content 콘텐츠 추가 이관
SERIES-09 DELETE /admin/ai-characters/{characterId}/series/{seriesId}/contents/{contentId} PUT /creator-admin/audio-content/series/remove/content 신규 API는 DELETE로 관계 해제 표현
SERIES-10 PUT /admin/ai-characters/{characterId}/series/orders PUT /creator-admin/audio-content/series/orders 순서 변경 이관, 소유자 전체 집합 검증 추가
SERIES-11 GET /admin/ai-characters/metadata/series-genres GET /creator-admin/audio-content/series/genre 기준정보 조회 이관

29.6 Community, FanTalk, Channel Settings

Operation ID 신규 API 대응 legacy API 매핑 판단
COMMUNITY-POST-01 GET /admin/ai-characters/{characterId}/community-posts GET /creator-community 목록 이관, 관리자 projection 사용
COMMUNITY-POST-02 GET /admin/ai-characters/{characterId}/community-posts/{postId} GET /creator-community/{id} 상세 이관
COMMUNITY-POST-03 POST /admin/ai-characters/{characterId}/community-posts POST /creator-community 등록 이관
COMMUNITY-POST-04 PUT /admin/ai-characters/{characterId}/community-posts/{postId} PUT /creator-community 수정 이관
COMMUNITY-POST-05 DELETE /admin/ai-characters/{characterId}/community-posts/{postId} 없음 명시 삭제 API 신설
COMMUNITY-POST-06 PUT /admin/ai-characters/{characterId}/community-posts/{postId}/fixed PUT /creator-community/fixed 고정 상태 변경 이관
COMMUNITY-COMMENT-01 GET /admin/ai-characters/{characterId}/community-posts/{postId}/comments GET /creator-community/{id}/comment 루트 댓글 목록 이관
COMMUNITY-COMMENT-02 GET /admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}/replies GET /creator-community/comment/{id} 답글 목록 이관
COMMUNITY-COMMENT-03 POST /admin/ai-characters/{characterId}/community-posts/{postId}/comments POST /creator-community/comment 작성 이관, writer는 AI creator로 고정
COMMUNITY-COMMENT-04 PUT /admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId} PUT /creator-community/comment 수정 이관, AI 작성자만 허용
COMMUNITY-COMMENT-05 DELETE /admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId} PUT /creator-community/comment 명시 DELETE 없음, 논리 삭제 동작만 v2로 분리
FAN-TALK-01 GET /admin/ai-characters/{characterId}/fan-talks GET /api/v2/creator-channels/{creatorId}/fan-talks, GET /explorer/profile/{id}/cheers 기존 v2 query와 legacy FanTalk 조회를 관리자 projection으로 확장
FAN-TALK-02 POST /admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies POST /explorer/profile/cheers 부분 대응, AI 답글 생성 정책을 v2에서 분리
FAN-TALK-03 PUT /admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId} PUT /explorer/profile/cheers 부분 대응, AI 답글 수정만 허용
FAN-TALK-04 DELETE /admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId} PUT /explorer/profile/cheers 명시 DELETE 없음, AI 답글 논리 삭제를 v2로 분리
FAN-TALK-05 DELETE /admin/ai-characters/{characterId}/fan-talks/{fanTalkId} PUT /explorer/profile/cheers 명시 DELETE 없음, 루트 moderation을 v2로 분리
NOTICE-01 GET /admin/ai-characters/{characterId}/channel-notice GET /explorer/profile/{id}/detail 부분 대응, 공지 전용 조회 API 신설
NOTICE-02 PUT /admin/ai-characters/{characterId}/channel-notice POST /explorer/profile/notice 공지 저장 이관, 신규 API는 PUT upsert
CREATOR-TAG-01 GET /admin/ai-characters/metadata/creator-tags GET /member/tag 기준정보 조회 이관
CHANNEL-PROFILE-01 GET /admin/ai-characters/{characterId}/channel-profile GET /member/info, GET /explorer/profile/{id}/detail 부분 대응, 관리자 설정 projection 신설
CHANNEL-PROFILE-02 PUT /admin/ai-characters/{characterId}/channel-profile PUT /member 채널 SNS·태그·후원 랭킹 설정 이관

29.7 Existing API Kept Outside New Operation Count

Existing API 신규 Operation 포함 여부 처리
GET /menu No v2 AI 캐릭터 관리자는 호출하지 않음, 메뉴는 클라이언트 정적 설정
PUT /audio-content/upload-complete No 기존 AWS worker callback 계약 유지, v2 콘텐츠도 같은 row·S3 metadata 계약으로 처리
GET /api/chat/original/** No 일반 사용자용 원작 조회 계약 유지