4514 lines
246 KiB
Markdown
4514 lines
246 KiB
Markdown
# PRD: AI 캐릭터 관리자 기능
|
|
|
|
## 1. Overview
|
|
|
|
AI 캐릭터는 `ChatCharacter`와 `Member(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 현재 관리자 인증
|
|
|
|
현재 관리자 로그인은 이미 존재한다.
|
|
|
|
```http
|
|
POST /admin/member/login
|
|
Content-Type: application/json
|
|
```
|
|
|
|
`ADMIN`과 `CONTENT_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 현재 메뉴
|
|
|
|
현재 관리자 메뉴는 다음 흐름으로 서버가 결정한다.
|
|
|
|
```text
|
|
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·컴포넌트와 메뉴를 한 곳에서 함께 변경할 수 있음 |
|
|
|
|
- 최소 전역 진입 메뉴는 다음과 같다.
|
|
|
|
```json
|
|
[
|
|
{
|
|
"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=null`인 `DELETED` 콘텐츠도 그대로 유지한다. 활성 시리즈·커뮤니티 게시글·콘텐츠 카테고리도 비활성 상태로 전환하며 데이터와 관계는 보존한다.
|
|
- 예약 콘텐츠와 처리 중 콘텐츠는 삭제된 캐릭터 명의로 공개되지 않는다.
|
|
- 기존 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-03`의 `originalWorkId=null`은 미연결 생성이고, 신규 `CHAR-04`의 명시적 `originalWorkId=null`은 기존 연결 해제다. 신규 계약은 legacy의 `originalWorkId=0` sentinel을 사용하지 않으며 0 이하는 400이다.
|
|
- 양수 `originalWorkId`는 `OriginalWork.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-04`의 `null` 해제와 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가 필요하다.
|
|
|
|
```http
|
|
Authorization: Bearer <admin-jwt>
|
|
```
|
|
|
|
### 9.2 Response Envelope
|
|
|
|
모든 응답은 기존 `ApiResponse<T>` 형식을 사용한다.
|
|
|
|
```json
|
|
{
|
|
"success": true,
|
|
"message": null,
|
|
"data": {},
|
|
"errorProperty": null
|
|
}
|
|
```
|
|
|
|
오류 응답은 다음 형식을 사용한다.
|
|
|
|
```json
|
|
{
|
|
"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 |
|
|
|
|
공통 목록 응답은 다음 형식이다.
|
|
|
|
```json
|
|
{
|
|
"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`, `previewEndTime`은 `HH: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
|
|
|
|
```http
|
|
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 비밀번호 |
|
|
|
|
```json
|
|
{
|
|
"email": "admin@example.com",
|
|
"password": "password"
|
|
}
|
|
```
|
|
|
|
#### Response Data
|
|
|
|
| Field | Type | Nullable | Description |
|
|
|---|---|---:|---|
|
|
| `token` | `String` | No | Bearer JWT |
|
|
| `role` | `String` | No | 로그인 Member 역할 |
|
|
|
|
```json
|
|
{
|
|
"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
|
|
|
|
```http
|
|
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 | `[]` |
|
|
|
|
- `characterType`은 `Character`, `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 | 변경 후 캐릭터 활성 상태 |
|
|
|
|
```json
|
|
{
|
|
"characterId": 101,
|
|
"creatorId": 10001,
|
|
"isActive": true
|
|
}
|
|
```
|
|
|
|
등록 성공 시 같은 작업 흐름에서 연결 `creatorMember`를 생성하고 그 ID를 응답의 `creatorId`로 반환한다.
|
|
|
|
### 11.5 CHAR-04 · Character Update
|
|
|
|
#### Request
|
|
|
|
```http
|
|
Content-Type: multipart/form-data
|
|
```
|
|
|
|
| Part | Type | Required | Description |
|
|
|---|---|---:|---|
|
|
| `image` | File | No | 새 대표 이미지 |
|
|
| `request` | JSON string | Yes | `AdminAiCharacterUpdateRequest` |
|
|
|
|
`AdminAiCharacterUpdateRequest`는 `region`을 제외한 `AdminAiCharacterCreateRequest`의 모든 field key를 필수로 받는다. `age`, `gender`, `mbti`, `speechPattern`, `speechStyle`, `appearance`, `originalWorkId`는 명시적 `null`로 값을 지울 수 있고, 목록은 빈 배열로 전체 삭제할 수 있다. `characterType`은 non-null이며 `Character`, `Clone` 중 하나여야 한다. `characterId`, `isActive`는 body에서 받지 않는다.
|
|
|
|
`region`은 캐릭터 생성 후 변경할 수 없는 값으로 유지한다. 이미지 File part를 생략한 경우에만 기존 이미지를 유지한다.
|
|
양수 `originalWorkId`는 `OriginalWork.isDeleted=false`인지 검증하고, 명시적 `null`은 기존 원작 연결을 해제한다. key 누락과 `0` 이하는 400, 미존재 양수 ID는 404, 삭제된 원작 ID는 409이며 모두 `errorProperty="originalWorkId"`다.
|
|
|
|
#### Response Data
|
|
|
|
`AdminAiCharacterMutationResponse`
|
|
|
|
```json
|
|
{
|
|
"characterId": 101,
|
|
"creatorId": 10001,
|
|
"isActive": true
|
|
}
|
|
```
|
|
|
|
이름, 이미지 또는 소개가 바뀌면 연결 Member의 `nickname`, `profileImage`, `introduce`도 같은 작업 안에서 동기화한다.
|
|
|
|
### 11.6 CHAR-05 · Character Delete
|
|
|
|
#### Request
|
|
|
|
- Path `characterId: Long`
|
|
- Body 없음
|
|
|
|
#### Response Data
|
|
|
|
`AdminAiCharacterDeleteResponse`
|
|
|
|
```json
|
|
{
|
|
"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-01`의 `search`가 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`로 정규화한다.
|
|
- `originalLink`와 `originalLinks`의 값은 `http` 또는 `https` 절대 URL이어야 한다.
|
|
- `originalLinks`와 `tags`는 trim 후 빈 값을 제거하고 첫 등장 순서를 유지한 채 중복을 제거한다.
|
|
- 신규 v2 저장은 정규화된 `originalLinks`와 `tags`를 요청 순서대로 다시 만들고, 조회 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 수이며 처리 건수가 아니다.
|
|
|
|
#### 11.7.3 ORIGINAL-WORK-01 · Original Work List and Search
|
|
|
|
Request:
|
|
|
|
- Path 없음
|
|
- Request JSON 없음
|
|
- Query `page: Int?`, `size: Int?`, `search: String?`
|
|
- `search`는 trim 후 빈 값이면 적용하지 않고, 값이 있으면 `title`, `contentType`, `category`의 대소문자 무시 부분 일치를 적용한다.
|
|
- 기본 정렬은 `createdAtUtc DESC, originalWorkId DESC`다.
|
|
|
|
Response JSON:
|
|
|
|
```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:
|
|
|
|
```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:
|
|
|
|
```http
|
|
Content-Type: multipart/form-data
|
|
```
|
|
|
|
| Part | Type | Required | Description |
|
|
|---|---|---:|---|
|
|
| `image` | File | Yes | 원작 대표 이미지 |
|
|
| `request` | JSON string | Yes | 아래 Request JSON을 `JSON.stringify`한 값 |
|
|
|
|
Request JSON:
|
|
|
|
```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:
|
|
|
|
```json
|
|
{
|
|
"success": true,
|
|
"message": null,
|
|
"data": {
|
|
"originalWorkId": 71,
|
|
"isDeleted": false
|
|
},
|
|
"errorProperty": null
|
|
}
|
|
```
|
|
|
|
#### 11.7.6 ORIGINAL-WORK-04 · Original Work Update
|
|
|
|
Request:
|
|
|
|
```http
|
|
Content-Type: multipart/form-data
|
|
```
|
|
|
|
| Part | Type | Required | Description |
|
|
|---|---|---:|---|
|
|
| `image` | File | No | 새 원작 대표 이미지, 생략 시 기존 이미지 유지 |
|
|
| `request` | JSON string | Yes | 아래 11개 key를 모두 가진 Request JSON |
|
|
|
|
Request JSON:
|
|
|
|
```json
|
|
{
|
|
"title": "달빛 도서관 개정판",
|
|
"contentType": "WEB_NOVEL",
|
|
"category": "FANTASY",
|
|
"isAdult": false,
|
|
"description": "개정된 작품 소개",
|
|
"originalWork": null,
|
|
"originalLink": null,
|
|
"writer": "김작가",
|
|
"studio": "소다 스튜디오",
|
|
"originalLinks": [],
|
|
"tags": ["판타지"]
|
|
}
|
|
```
|
|
|
|
Response JSON:
|
|
|
|
```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:
|
|
|
|
```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:
|
|
|
|
```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:
|
|
|
|
```json
|
|
{
|
|
"characterIds": [101, 102]
|
|
}
|
|
```
|
|
|
|
- `characterIds`는 중복 없는 양수 ID를 하나 이상 포함해야 한다.
|
|
- 모든 ID가 존재하고 활성 AI 캐릭터인지 먼저 검증한다.
|
|
- 하나라도 잘못되면 아무 캐릭터도 이동하지 않는다.
|
|
|
|
Response JSON:
|
|
|
|
```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:
|
|
|
|
```json
|
|
{
|
|
"characterIds": [101, 102]
|
|
}
|
|
```
|
|
|
|
- `characterIds`는 중복 없는 양수 ID를 하나 이상 포함해야 한다.
|
|
- 활성·비활성 및 연결 creator 상태와 관계없이 기존 `ChatCharacter`를 해제할 수 있지만, 모든 캐릭터가 Path의 원작에 실제 연결되어 있어야 한다.
|
|
- 미존재, 미연결 또는 다른 원작 소속 ID가 하나라도 있으면 400과 `errorProperty="characterIds"`를 반환하고 전체를 rollback한다.
|
|
|
|
Response JSON:
|
|
|
|
```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` 예시는 다음과 같다.
|
|
|
|
```json
|
|
{
|
|
"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
|
|
|
|
```http
|
|
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`다.
|
|
- `purchaseOption`은 `BOTH`, `BUY_ONLY`, `RENT_ONLY`만 허용한다.
|
|
- `price`는 0 이상이며 1~4는 허용하지 않는다.
|
|
- `title`과 `detail`은 trim 후 빈 값일 수 없다.
|
|
- `limited`는 `null` 또는 1 이상이다.
|
|
- 테마 ID 12, 13, 14는 `price >= 5`여야 하고 `purchaseOption=BUY_ONLY`로 저장한다.
|
|
- 미리듣기 구간은 종료가 시작보다 늦고 길이가 15초 이상이어야 한다.
|
|
- `limited`가 설정되었거나 최종 `purchaseOption=BUY_ONLY`이면 `isOnlyRental=false`로 저장한다. 이 규칙이 우선하므로 `limited`와 `RENT_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` |
|
|
|
|
```json
|
|
{
|
|
"contentId": 2001,
|
|
"isActive": false,
|
|
"status": "PROCESSING"
|
|
}
|
|
```
|
|
|
|
원본 파일 저장 성공은 콘텐츠 공개 완료를 의미하지 않는다. 비동기 가공 완료 전까지 콘텐츠는 비활성 상태다.
|
|
응답의 `PROCESSING`은 저장된 status 값이 아니라 생성 직후의 `isActive=false`, `releaseDate!=null`, `duration=null`에서 계산한다.
|
|
|
|
### 12.5 CONTENT-04 · Content Update
|
|
|
|
#### Request
|
|
|
|
```http
|
|
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 |
|
|
|
|
`contentId`와 `isActive`는 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`, `isFullDetailVisible` 및 `languageCode`는 생성 후 직접 변경할 수 없다. `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`
|
|
|
|
```json
|
|
{
|
|
"isPinned": true
|
|
}
|
|
```
|
|
|
|
| Field | Type | Required |
|
|
|---|---|---:|
|
|
| `isPinned` | `Boolean` | Yes |
|
|
|
|
#### Response Data
|
|
|
|
`AdminAiContentPinResponse`
|
|
|
|
```json
|
|
{
|
|
"contentId": 2001,
|
|
"isPinned": true,
|
|
"replacedContentId": null
|
|
}
|
|
```
|
|
|
|
고정은 계산 상태가 `PUBLISHED`이고 공개 시각이 지난 콘텐츠에만 허용한다. 캐릭터별 최대 3개를 유지하며 네 번째 콘텐츠를 고정하면 가장 오래된 고정을 해제하고 그 ID를 `replacedContentId`로 반환한다. 고정 해제 응답의 `replacedContentId`는 `null`이다.
|
|
|
|
### 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는 기존 계약을 그대로 사용한다.
|
|
|
|
```http
|
|
PUT /audio-content/upload-complete
|
|
Authorization: Bearer <bot-or-admin-jwt>
|
|
Content-Type: application/json
|
|
```
|
|
|
|
```json
|
|
{
|
|
"contentId": 2001,
|
|
"contentPath": "output/2001/2001-content.m4a",
|
|
"duration": "00:10:30"
|
|
}
|
|
```
|
|
|
|
기존 성공 Response JSON은 다음과 같다.
|
|
|
|
```json
|
|
{
|
|
"success": true,
|
|
"message": null,
|
|
"data": {},
|
|
"errorProperty": null
|
|
}
|
|
```
|
|
|
|
- 위 Method, Path, 인증, Request와 Response 계약은 기존 AWS 연동 계약이며 이번 범위에서 변경하지 않는다.
|
|
- 이 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은 기존과 같이 가공 완료 `contentPath`와 `duration`을 기록한다. 공개 활성화는 `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`
|
|
|
|
```json
|
|
{
|
|
"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`
|
|
|
|
```json
|
|
{
|
|
"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
|
|
|
|
```http
|
|
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`
|
|
|
|
```json
|
|
{
|
|
"id": 3001,
|
|
"isActive": true
|
|
}
|
|
```
|
|
|
|
### 14.5 SERIES-04 · Series Update
|
|
|
|
#### Request
|
|
|
|
```http
|
|
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 |
|
|
|
|
`seriesId`와 `isActive`는 body에서 받지 않는다.
|
|
|
|
등록과 같은 제목·소개·키워드·장르·요일 검증을 적용한다. `state`는 `PROCEEDING`, `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`:
|
|
|
|
```json
|
|
{
|
|
"contentIds": [2001, 2002]
|
|
}
|
|
```
|
|
|
|
| Field | Type | Required |
|
|
|---|---|---:|
|
|
| `contentIds` | `List<Long>` | Yes |
|
|
|
|
Response Data `AdminSeriesContentsMutationResponse`:
|
|
|
|
```json
|
|
{
|
|
"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`
|
|
|
|
```json
|
|
{
|
|
"seriesIds": [3003, 3001, 3002]
|
|
}
|
|
```
|
|
|
|
#### Response Data
|
|
|
|
`AdminSeriesOrderResponse`
|
|
|
|
```json
|
|
{
|
|
"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
|
|
|
|
```http
|
|
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/*`여야 하며 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`
|
|
|
|
```json
|
|
{
|
|
"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 |
|
|
|
|
`postId`와 `isActive`는 body에서 받지 않는다.
|
|
|
|
`price`와 `audioFile`은 등록 후 변경하거나 제거할 수 없다. 선택 `postImage`를 보내면 이미지를 교체하고, 생략하면 기존 이미지를 유지한다. 이미지 제거는 지원하지 않는다.
|
|
수정 시에도 `content`는 trim 후 빈 값일 수 없다. 교체 이미지는 실제 MIME type이 `image/*`여야 하며, 기존 게시글의 `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`
|
|
|
|
```json
|
|
{
|
|
"isFixed": true
|
|
}
|
|
```
|
|
|
|
| Field | Type | Required |
|
|
|---|---|---:|
|
|
| `isFixed` | `Boolean` | Yes |
|
|
|
|
#### Response Data
|
|
|
|
`AdminCommunityPostFixedResponse`
|
|
|
|
```json
|
|
{
|
|
"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`
|
|
|
|
```json
|
|
{
|
|
"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`
|
|
|
|
```json
|
|
{
|
|
"content": "수정한 댓글"
|
|
}
|
|
```
|
|
|
|
| Field | Type | Required |
|
|
|---|---|---:|
|
|
| `content` | `String` | Yes |
|
|
|
|
AI 캐릭터의 `creatorMember`가 작성한 댓글만 본문을 수정할 수 있다.
|
|
|
|
`parentCommentId`와 `isSecret`은 등록 후 변경할 수 없다.
|
|
|
|
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`
|
|
|
|
```json
|
|
{
|
|
"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`
|
|
|
|
```json
|
|
{
|
|
"content": "수정한 AI 캐릭터 답글"
|
|
}
|
|
```
|
|
|
|
| Field | Type | Required |
|
|
|---|---|---:|
|
|
| `content` | `String` | Yes |
|
|
|
|
선택 AI 캐릭터의 `creatorMember`가 작성했고 해당 루트 FanTalk의 답글인 경우에만 수정한다.
|
|
|
|
`fanTalkId`와 `languageCode`는 등록 후 변경할 수 없다.
|
|
|
|
#### 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`
|
|
|
|
```json
|
|
{
|
|
"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 |
|
|
|
|
```json
|
|
{
|
|
"categoryId": 6001,
|
|
"isActive": true
|
|
}
|
|
```
|
|
|
|
모든 `contentIds`는 중복이 없어야 하며 선택 AI 캐릭터가 소유한 활성 콘텐츠여야 한다. 하나라도 조건을 충족하지 않으면 카테고리를 생성하지 않는다.
|
|
`title`은 trim 후 2자 이상이어야 하며 같은 캐릭터의 활성 카테고리 제목과 중복될 수 없다.
|
|
|
|
### 18.4 CATEGORY-03 · Content Category Update
|
|
|
|
#### Request
|
|
|
|
`AdminContentCategoryUpdateRequest`
|
|
|
|
```json
|
|
{
|
|
"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`
|
|
|
|
```json
|
|
{
|
|
"categoryIds": [6003, 6001, 6002]
|
|
}
|
|
```
|
|
|
|
| Field | Type | Required |
|
|
|---|---|---:|
|
|
| `categoryIds` | `List<Long>` | Yes |
|
|
|
|
#### Response Data
|
|
|
|
`AdminContentCategoryOrderResponse`
|
|
|
|
```json
|
|
{
|
|
"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`:
|
|
|
|
```json
|
|
{
|
|
"contentIds": [2001, 2002]
|
|
}
|
|
```
|
|
|
|
| Field | Type | Required |
|
|
|---|---|---:|
|
|
| `contentIds` | `List<Long>` | Yes |
|
|
|
|
Response Data `AdminCategoryContentsMutationResponse`:
|
|
|
|
```json
|
|
{
|
|
"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`
|
|
|
|
```json
|
|
{
|
|
"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 |
|
|
|
|
```json
|
|
{
|
|
"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이어야 한다.
|
|
- `donationRankingPeriod`는 `WEEKLY`, `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로 교체하지 않는다.
|
|
|
|
```text
|
|
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`의 인증·오류 응답 계약은 이번 범위에서 변경하지 않는다. 관리자 콘텐츠 조회에서 수행하는 canonical output key 검증은 기존 callback의 Request·Response 또는 오류 envelope를 변경하지 않는다.
|
|
|
|
## 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가 아닌 다음 값 객체를 반환한다.
|
|
|
|
```text
|
|
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 `isActive`만 `false`로 전환하고 `releaseDate`, `duration`, `content`와 구매 이력을 보존한다. 연결 creator 비활성 상태를 포함해 계산한 유효 `isActive`는 `false`이고 기존 미삭제 콘텐츠의 `status`는 `SUSPENDED`다. 이 상태 변경은 기존 컬럼을 사용하며 schema나 JPA mapping 변경을 요구하지 않는다.
|
|
- 기존 비활성 캐릭터 row를 일괄 보정하는 데이터 migration도 현재 근거 없이 선제 수행하지 않는다. 실제 운영 불일치가 확인되면 대상·영향 건수를 먼저 조사하고 별도 승인과 migration 계획을 작성한다.
|
|
|
|
### 21.5 Transactions and External Systems
|
|
|
|
- DB 안에서 끝나는 변경은 하나의 transaction으로 처리한다.
|
|
- 캐릭터 등록·수정은 외부 캐릭터 API, 이미지 저장, DB 변경의 실패 지점을 구분해 오류를 반환한다.
|
|
- 원작 생성·이미지 교체 command도 서버에서 `requestId`를 생성한다. 이는 파일 key·보상·구조화 로그 상관관계용이며 클라이언트 HTTP 재시도 멱등성 key는 아니다.
|
|
- `OriginalWorkImageStoragePort`는 `store(originalWorkId, validatedImage, requestId, attemptNumber)`와 새로 저장한 object의 `delete(objectKey)`를 제공한다. `store`는 `originals/{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하거나 `REQUIRED`와 `SERIALIZABLE` 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/Response가 바뀌지 않는지 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`, 배정에서는 캐릭터별 값, legacy 불일치 캐릭터 해제에서는 `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`이다.
|
|
|
|
영속 audit table 도입은 이번 범위가 아니며 구조화 application log를 요구한다. 비밀번호, JWT, system prompt 전체, 댓글 본문 또는 업로드 파일 내용은 로그에 기록하지 않는다.
|
|
|
|
### 21.7 Security
|
|
|
|
- `/admin/ai-characters/**` Controller는 class level에서 `hasRole('ADMIN')`을 선언한다.
|
|
- 기존 `/audio-content/upload-complete`의 `hasAnyRole('BOT', 'ADMIN')`과 외부 응답 계약은 변경하지 않는다.
|
|
- `/admin/ai-characters/**` RequestMatcher에만 적용되는 authentication entry point, access-denied handler 및 exception response 경계를 두어 legacy API의 HTTP status를 변경하지 않고 20장의 status와 `ApiResponse` 계약을 보장한다.
|
|
- 클라이언트 메뉴·route guard 테스트와 Backend API 인가 테스트를 별도로 작성한다.
|
|
- Multipart 요청은 애플리케이션의 `max-file-size=1024MB`, `max-request-size=1024MB` 상한을 적용한다. 이미지 part는 공통 이미지 검증기를 v2 web adapter에서 사용해 실제 MIME type을 검증한다. 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-complete`의 `BOT` 또는 `ADMIN` 인가와 외부 계약이 변경되지 않는다.
|
|
- [ ] Backend는 v2 AI 캐릭터 관리자용 menu Endpoint를 추가하지 않는다.
|
|
- [ ] 클라이언트 메뉴 또는 route guard와 관계없이 Backend API 인가가 독립적으로 동작한다.
|
|
|
|
### Character
|
|
|
|
- [ ] 활성·비활성 상태 및 이름으로 AI 캐릭터를 조회할 수 있다.
|
|
- [ ] 캐릭터 등록 시 연결 AI `creatorMember`가 생성된다.
|
|
- [ ] 캐릭터 표시 정보 수정 시 연결 Member가 동기화된다.
|
|
- [ ] 캐릭터 삭제 시 캐릭터와 연결 Member가 비활성화되고 공개 리소스가 노출되지 않으며 어떤 데이터도 물리 삭제되지 않는다.
|
|
- [ ] 캐릭터 삭제 시 소유 콘텐츠 row의 raw `isActive`만 `false`로 전환되고 `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`의 양수 `originalWorkId`는 `isDeleted=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/Response가 그대로 유지된다.
|
|
- [ ] 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`으로 응답하고 목록·상세의 `status`와 `isActive` 필터도 기존 필드와 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을 하드코딩하지 않는다.
|
|
- [ ] `packageManager`를 `pnpm@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-04`의 `null`과 legacy 수정의 `0` 해제는 캐릭터 row만 잠근다. 원작 생성과 제목이 실제 바뀌는 수정은 MySQL `SERIALIZABLE` transaction과 1회 재시도로 동시성 불변식을 보장한다.
|
|
- 신규 `CHAR-03`의 `originalWorkId=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 `isActive`만 `false`로 전환하고 나머지 콘텐츠 필드와 구매 이력을 보존한다. 일반 공개 탐색과 미구매 상세는 차단하고 기존 구매 재생은 유지한다.
|
|
- 콘텐츠·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 배포를 차단 상태로 보고한다.
|
|
|
|
## 26. Related Documents and Code Evidence
|
|
|
|
### 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 build`의 `dist`를 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으로 관리한다.
|
|
|
|
```ts
|
|
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의 `characterId`로 `CHAR-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 경계는 다음과 같다.
|
|
|
|
```text
|
|
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.txt`나 `noindex`만으로 “절대 검색 노출 금지”를 보장할 수 없다. 접근 제어를 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.txt`에 `Disallow: /`를 두면 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`
|
|
|
|
```dotenv
|
|
VITE_API_BASE_URL=https://dev-api.example.com
|
|
```
|
|
|
|
`.env.production`
|
|
|
|
```dotenv
|
|
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는 다음과 같다.
|
|
|
|
```json
|
|
{
|
|
"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 명령은 다음 순서를 기준으로 한다.
|
|
|
|
```sh
|
|
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>`을 추가한다. 복원된 `role`이 `ADMIN`이 아니거나 값이 손상되면 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-02`의 `contentUrl`과 전체 응답을 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장이다.
|
|
|
|
```text
|
|
당신은 운영용 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
|
|
|
|
- [React versions](https://react.dev/versions)
|
|
- [Vite 8.1 release](https://vite.dev/blog/announcing-vite8-1)
|
|
- [Vite env files and modes](https://vite.dev/guide/env-and-mode)
|
|
- [Vite static deployment](https://vite.dev/guide/static-deploy.html)
|
|
- [Node.js release status](https://nodejs.org/en/about/previous-releases)
|
|
- [pnpm package versions](https://www.npmjs.com/package/pnpm?activeTab=versions)
|
|
- [pnpm continuous integration with Jenkins](https://pnpm.io/continuous-integration#jenkins)
|
|
- [Jenkins Pipeline](https://www.jenkins.io/doc/book/pipeline/)
|
|
- [TypeScript 6.0 release notes](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-6-0.html)
|
|
- [React Router current changelog](https://reactrouter.com/start/start/changelog)
|
|
- [React Router mode selection](https://reactrouter.com/start/modes)
|
|
- [React Router SPA deployment](https://reactrouter.com/how-to/spa)
|
|
- [Tailwind CSS with Vite](https://tailwindcss.com/docs/installation/using-vite)
|
|
- [shadcn/ui Vite installation](https://ui.shadcn.com/docs/installation/vite)
|
|
- [shadcn/ui Base UI decision](https://ui.shadcn.com/docs/changelog)
|
|
- [shadcn/ui TanStack Form guide](https://ui.shadcn.com/docs/forms/tanstack-form)
|
|
- [shadcn/ui Sonner](https://ui.shadcn.com/docs/components/radix/sonner)
|
|
- [TanStack Query v5](https://tanstack.com/query/v5/docs/framework/react/overview)
|
|
- [TanStack Table v8](https://tanstack.com/table/v8/docs/introduction)
|
|
- [Zod 4](https://zod.dev/)
|
|
- [Vitest](https://vitest.dev/guide/)
|
|
- [Playwright](https://playwright.dev/docs/intro)
|
|
- [MDN Intl.DateTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat)
|
|
- [MDN Date.prototype.toISOString](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString)
|
|
- [Google noindex and X-Robots-Tag](https://developers.google.com/search/docs/crawling-indexing/block-indexing)
|
|
- [Google robots.txt limitations](https://developers.google.com/search/docs/crawling-indexing/robots/intro)
|
|
|
|
## 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`이며 문서 보강 후에도 같아야 한다.
|