# AI 캐릭터 관리자 API Contract ## 1. 문서 목적 클라이언트 개발과 서버 계약 테스트가 같은 스키마를 사용하도록 AI 캐릭터 관리자 API의 전체 request/response를 OpenAPI 3.1 JSON으로 고정한다. - 정식 계약: `api-contract.openapi.json` - endpoint: 37개 - 현재 route 구현: 37개 - 현재 계약과 일치하는 구현 완료: 37개 - 계약 정합화 필요: 0개 - 구현 예정: 0개 - 공통 envelope: `ApiResponse` - 인증: JWT `ROLE_ADMIN`과 현재 DB `Member.role == ADMIN` 동시 충족 `x-implementation-status`의 의미는 다음과 같다. - `implemented`: route가 구현되어 있고 확정 계약과 후속 리뷰 Gate를 통과했다. - `alignment-required`: route는 구현되어 있으나 승인된 최신 계약에 맞춘 request/response 정합화가 필요하다. - `planned`: 계약과 구현 Task는 확정됐으나 route가 아직 구현되지 않았다. ## 2. 계약 결정 1. 신규 endpoint와 `characterId` 기반 관리자 target 경계는 유지한다. 2. JSON 필드명, 타입, optional/nullable, 기본값과 성공 `data` 형태는 레거시 API를 유지한다. 3. 신규 path에 포함된 `characterId`, `contentId`, `seriesId`, `postId`, `fanTalkId`, `replyId`만 request body에서 중복 제거한다. 4. 레거시 mutation이 `ApiResponse.ok(null)`이면 신규 endpoint도 `data: null`을 반환한다. 5. 오디오 콘텐츠 생성은 레거시 `CreateAudioContentResponse(contentId)`를 반환한다. 6. FanTalk 답변 작성만 신규 계획의 축약 응답 `fanTalkId`, `replyId`, `creatorMemberId`, `content`, `createdAtUtc`를 사용한다. 7. FanTalk 목록은 공개 v2 `CreatorChannelFanTalkTabResponse`의 필드 형태를 유지하는 관리자 전용 endpoint로 추가한다. 8. 시리즈 연결 콘텐츠 목록과 미연결 콘텐츠 검색은 response shape가 달라 별도 endpoint로 분리한다. 9. 시리즈 상세 `data`는 배열 wrapper 없이 시리즈 목록 `items`의 단일 객체와 동일한 필드·타입을 사용한다. 10. 오디오 콘텐츠와 커뮤니티 게시글 댓글의 원댓글/답글 조회는 레거시 응답을 유지하고, 작성은 target AI 캐릭터 명의로 수행한다. 11. 댓글 수정은 target AI 캐릭터가 작성한 댓글/답글만 허용한다. 삭제는 target 소유 리소스에 달린 댓글/답글이면 작성자와 관계없이 해당 row만 soft delete하며, 이미 비활성인 row의 삭제는 성공 no-op이다. 12. 댓글 생성의 optional `parentId`가 없으면 원댓글, 있으면 같은 리소스의 활성 원댓글에 대한 답글이다. 13. 팬이 작성한 FanTalk 원글 삭제는 target 채널의 root만 soft delete하고 이미 비활성이면 성공 no-op이며 연결된 creator reply row는 유지한다. 14. FanTalk 답변 수정은 레거시 `PutWriteCheersRequest`에서 path `replyId`로 이동한 `cheersId`만 제거하고 optional/nullable `content`, `isActive`, 빈 객체 no-op과 `CreatorChannelFanTalkResponse` 성공 `data`를 유지한다. 15. 캐릭터에 직접 달리는 레거시 댓글 삭제 API는 v2 전환 후 사용하지 않으므로 계약과 구현 범위에서 제외한다. 16. 캐릭터 등록용 원작 검색과 시리즈 장르 목록은 기존 관리자 조회 규칙과 전체 response 필드를 재사용한다. 17. AI 캐릭터 관리자 오디오·커뮤니티 API는 `timezone` query/body를 받지 않는다. 오디오 생성의 nullable `releaseDate`는 클라이언트가 ISO-8601 UTC(`Z`)로 변환해 보내며, 오디오 상세 `releaseDate`와 오디오·커뮤니티 댓글 `date`도 기존 필드명과 null/노출 조건을 유지한 채 ISO-8601 UTC(`Z`)로 반환한다. ## 3. endpoint와 레거시 근거 | 상태 | Method | Endpoint | request 근거 | response `data` 근거 | |---|---|---|---|---| | 구현 완료 | GET | `/api/v2/admin/ai-characters` | `searchTerm?`, `page`, `size` | `ChatCharacterListPageResponse` / `ChatCharacterSearchListPageResponse` | | 구현 완료 | POST | `/api/v2/admin/ai-characters` | `ChatCharacterRegisterRequest`, 필수 `image` | `null` | | 구현 완료 | GET | `/api/v2/admin/ai-characters/original-works/search` | 필수 `searchTerm` | `List` | | 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}` | path only | `ChatCharacterDetailResponse` | | 구현 완료 | PUT | `/api/v2/admin/ai-characters/{characterId}` | `ChatCharacterUpdateRequest`에서 `id` 제외 | `null` | | 구현 완료 | GET | `/api/v2/admin/ai-characters/audio-content-themes` | body 없음 | `List` | | 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}/audio-contents` | `search_word?`, `page`, `size` | `GetCreatorAdminContentListResponse` | | 구현 완료 | POST | `/api/v2/admin/ai-characters/{characterId}/audio-contents` | `timezone`을 제외하고 nullable UTC `releaseDate`를 받는 `AudioContentCreateRequest`, `contentFile`, `coverImage` | `CreateAudioContentResponse` | | 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` | path only | `releaseDate`가 nullable UTC인 `GetAudioContentDetailResponse` | | 구현 완료 | PUT | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` | `UpdateCreatorAdminContentRequest`에서 `id` 제외 | `null` | | 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments` | `page`, `size` | 댓글 `date`가 UTC인 `GetAudioContentCommentListResponse` | | 구현 완료 | POST | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments` | `comment`, `parentId?`, `isSecret?`, `languageCode?` | `null` | | 구현 완료 | PUT | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments/{commentId}` | `comment` | `null` | | 구현 완료 | DELETE | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments/{commentId}` | body 없음 | `null` | | 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments/{commentId}/replies` | `page`, `size` | 댓글 `date`가 UTC인 `GetAudioContentCommentListResponse` | | 구현 완료 | GET | `/api/v2/admin/ai-characters/series-genres` | body 없음 | `List` | | 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}/series` | `page`, `size` | `GetCreatorAdminContentSeriesListResponse` | | 구현 완료 | POST | `/api/v2/admin/ai-characters/{characterId}/series` | `CreateSeriesRequest`, 필수 `image` | `null` | | 구현 완료 | PUT | `/api/v2/admin/ai-characters/{characterId}/series/orders` | `UpdateOrdersRequest` | `null` | | 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}/series/{seriesId}` | path only | 시리즈 목록 `items` 단일 객체 | | 구현 완료 | PUT | `/api/v2/admin/ai-characters/{characterId}/series/{seriesId}` | `ModifySeriesRequest`에서 `seriesId` 제외 | `null` | | 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents` | `page`, `size` | `GetCreatorAdminContentSeriesContentResponse` | | 구현 완료 | POST | `/api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents` | `AddingContentToTheSeriesRequest`에서 `seriesId` 제외 | `null` | | 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents/search` | 필수 `search_word` | `List` | | 구현 완료 | DELETE | `/api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents/{contentId}` | body 없음 | `null` | | 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}/community-posts` | `page`, `size` | `AiCharacterAdminCommunityPostListResponse(totalCount, page, size, hasNext, items)` | | 구현 완료 | POST | `/api/v2/admin/ai-characters/{characterId}/community-posts` | `CreateCommunityPostRequest`, `audioFile?`, `postImage?` | `null` | | 구현 완료 | PUT | `/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}` | 두 레거시 update request에서 ID 제외 | `null` | | 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments` | `page`, `size` | 댓글 `date`가 UTC인 `GetCommunityPostCommentListResponse` | | 구현 완료 | POST | `/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments` | `comment`, `parentId?`, `isSecret?` | `null` | | 구현 완료 | PUT | `/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}` | `comment` | `null` | | 구현 완료 | DELETE | `/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}` | body 없음 | `null` | | 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}/replies` | `page`, `size` | 댓글 `date`가 UTC인 `GetCommunityPostCommentListResponse` | | 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}/fan-talks` | 공개 v2 `page?`, `size?` | `CreatorChannelFanTalkTabResponse` | | 구현 완료 | DELETE | `/api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}` | body 없음 | `null` | | 구현 완료 | POST | `/api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies` | `content` | 신규 축약 응답 | | 구현 완료 | PUT | `/api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}` | `PutWriteCheersRequest`에서 `cheersId` 제외: `content?`, `isActive?` | `CreatorChannelFanTalkResponse` | ## 4. JSON Schema 해석 - 객체의 `required` 배열에 포함된 필드는 JSON key가 필수다. - `required`에 없으면 optional이며 key를 생략할 수 있다. - `type: ["string", "null"]`, 다른 union의 `type: "null"`은 명시적 `null`을 허용한다. - response DTO의 nullable 생성자 필드는 key는 존재하고 값이 `null`일 수 있으므로 `required`와 nullable을 함께 사용한다. - request의 optional nullable 필드는 key 생략과 명시적 `null`을 모두 허용한다. - request는 `additionalProperties: false`이므로 정의되지 않은 이름은 계약 위반이다. - 모든 DB ID는 JSON integer, OpenAPI `int64`다. - 승인된 UTC 예외인 오디오 생성·상세 `releaseDate`, 오디오·커뮤니티 댓글 `date`, FanTalk `createdAtUtc`는 OpenAPI `date-time`이다. 그 밖의 레거시 날짜 문자열은 기존 format을 유지한다. - enum은 대소문자를 구분하며 Kotlin enum 이름을 그대로 사용한다. - `Accept-Language`는 `ko`, `en`, `ja` 외 문자열도 전송할 수 있고 서버가 KO로 fallback하므로 enum으로 제한하지 않는다. ### multipart multipart API의 `request` part는 schema상 JSON 객체다. - part name: `request` - part Content-Type: `application/json` - Character 생성: `image`, `request` 필수 - Character 수정: `image` optional, `request` 필수 - AudioContent 생성: `contentFile`, `coverImage`, `request` 필수 - AudioContent 수정: `coverImage` optional, `request` 필수 - Series 생성: `image`, `request` 필수 - Series 수정: `image` optional, `request` 필수 - Community 생성: `audioFile`, `postImage` optional, `request` 필수 - Community 수정/고정: `postImage` optional, `request` 필수 ## 5. domain별 전체 필드 근거 ### Character - request: `ChatCharacterRegisterRequest`, `ChatCharacterUpdateRequest`, `ChatCharacterRelationshipRequest`, `ChatCharacterPersonalityRequest`, `ChatCharacterBackgroundRequest`, `ChatCharacterMemoryRequest` - response: `ChatCharacterListResponse`, `ChatCharacterListPageResponse`, `ChatCharacterSearchListPageResponse`, `ChatCharacterDetailResponse`, `RelationshipResponse`, `PersonalityResponse`, `BackgroundResponse`, `MemoryResponse`, `OriginalWorkBriefResponse`, `OriginalWorkResponse` - 목록 response는 `totalCount`, `content`이며 `items/page/size/hasNext`로 바꾸지 않는다. - 상세에는 `systemPrompt`, 캐릭터 속성 배열과 `originalWork`를 포함한다. - 생성과 수정의 성공 `data`는 상세가 아니라 `null`이다. - 캐릭터 등록용 원작 검색은 필수 `searchTerm`으로 제목·콘텐츠 타입·카테고리를 부분 검색하고, soft delete 원작을 제외한 `OriginalWorkResponse` 전체 필드의 직접 배열을 반환한다. 별도 pagination은 두지 않는다. - 수정은 `isActive=false`와 다른 optional field의 동시 입력도 레거시 request처럼 허용한다. 이 경우 레거시 service 의미대로 비활성화만 반영하고 나머지 JSON field는 적용하지 않는다. ### AudioContent - request: `AudioContentCreateRequest`, `UpdateCreatorAdminContentRequest`, `RegisterCommentRequest`, `ModifyCommentRequest` - response: `GetAudioContentThemeResponse`, `GetCreatorAdminContentListResponse`, `GetCreatorAdminContentListItem`, `CreateAudioContentResponse`, `GetAudioContentDetailResponse`, `OtherContentResponse`, `AudioContentCreator`, `ContentBuyer`, `GetAudioContentCommentListItem`, `TranslatedContent` - `detail`, `releaseDate`, `contentFile`, `id/theme/image`, `audioContentId`, `contentUrl`, `tags`를 레거시 이름 그대로 사용한다. - 현재 v2 전용 `description`, `releaseDateUtc`, `audioSignedUrl`, `status`, `seriesIds`, `themeName`, `imageUrl` 별칭은 정식 계약에 포함하지 않는다. - 생성 request는 `timezone`을 받지 않으며 nullable `releaseDate`를 ISO-8601 UTC(`Z`)로 받는다. 로컬 시각과 timezone을 함께 받는 레거시 생성 형식은 신규 관리자 endpoint에서 지원하지 않는다. - 상세 response의 nullable `releaseDate`는 기존 null/노출 조건을 유지하고, 값이 있으면 ISO-8601 UTC(`Z`)로 반환한다. - 댓글/답글 목록은 `timezone` 없이 `page`, `size`와 레거시 `totalCount`, `items`를 유지하고, 각 `date`를 ISO-8601 UTC(`Z`)로 반환한다. - 댓글 생성은 target AI 캐릭터 명의로 수행한다. `parentId`는 optional/nullable, `isSecret` 기본값은 `false`, `languageCode`는 optional/nullable이다. - 댓글 수정 request는 `comment`만 받으며 target AI 캐릭터가 작성한 활성 댓글/답글만 수정한다. - 삭제는 target 소유 활성 오디오 콘텐츠의 댓글/답글 row 하나만 `isActive=false`로 변경한다. 하위 답글을 cascade 삭제하지 않으며 mutation 성공 `data`는 `null`이다. ### Series - request: `CreateSeriesRequest`, `ModifySeriesRequest`, `AddingContentToTheSeriesRequest`, `RemoveContentToTheSeriesRequest`, `UpdateOrdersRequest` - response: `GetCreatorAdminContentSeriesListResponse`, `GetCreatorAdminContentSeriesContentResponse`, `SearchContentNotInSeriesResponse`, `GetSeriesGenreListResponse` - `publishedDaysOfWeek` enum은 `SUN..SAT`, `RANDOM`이며 state는 `PROCEEDING`, `SUSPEND`, `COMPLETE`다. - 상세 `data`는 목록 `items`의 단일 객체와 동일하게 `seriesId`, `title`, `introduction`, `coverImageUrl`, `publishedDaysOfWeek`, `genreId`, `isAdult`, `state`, `isActive`, `writer`, `studio`를 반환한다. 별도 `genre`, `keywords`는 반환하지 않는다. - 등록용 장르 목록은 활성 장르를 `orders` 오름차순으로 조회하고 `id`, `genre`, `isAdult`의 직접 배열을 반환한다. - 연결 request는 `contentIdList`, 순서 request는 `ids`다. ### Community - request: `CreateCommunityPostRequest`, `ModifyCommunityPostRequest`, `UpdateCommunityPostFixedRequest`, `CreateCommunityPostCommentRequest`, `ModifyCommunityPostCommentRequest` - response: `GetCommunityPostListResponse`, `GetCommunityPostCommentListResponse`, `GetCommunityPostCommentListItem` - 2026-07-29 사용자 확정에 따라 목록은 레거시 직접 배열의 예외다. `timezone` query를 제거하고 `data`를 `AiCharacterAdminCommunityPostListResponse(totalCount, page, size, hasNext, items)`로 반환한다. - `totalCount`는 target creatorMember 소유 active 게시글 전체 개수이고, `items`는 기존 `GetCommunityPostListResponse` 필드·고정 우선 정렬을 유지한다. `hasNext`는 현재 page 뒤에 active owner 게시글이 더 있는지를 나타낸다. - 생성 part 이름은 `postImage`, `audioFile`이다. - 수정 endpoint는 기존 본문 수정과 고정/해제를 합치므로 ID를 제외한 `content`, `isCommentAvailable`, `isAdult`, `isActive`, `isFixed`를 받는다. - 레거시에 없는 수정 `price`, `audioFile`은 포함하지 않는다. - 댓글/답글 목록은 `timezone` 없이 `page`, `size`와 레거시 `totalCount`, `items`를 유지하고, 각 `date`를 ISO-8601 UTC(`Z`)로 반환한다. - 댓글 생성은 target AI 캐릭터 명의로 수행하고 optional/nullable `parentId`와 기본값 `false`인 `isSecret`을 받는다. - 댓글 수정 request는 `comment`만 받으며 target AI 캐릭터가 작성한 활성 댓글/답글만 수정한다. - 삭제는 target 소유 활성 게시글의 댓글/답글 row 하나만 `isActive=false`로 변경한다. 하위 답글을 cascade 삭제하지 않으며 mutation 성공 `data`는 `null`이다. ### FanTalk - 목록 response: `CreatorChannelFanTalkTabResponse`, `CreatorChannelFanTalkResponse`, `CreatorChannelFanTalkReplyResponse` - 공개 v2 endpoint를 직접 사용하지 않는다. 공개 v2는 `creatorId`, viewer 인증과 block filter, 다른 CORS 경계를 사용하기 때문이다. - 신규 관리자 목록은 `characterId`로 creator를 해석하고 공개 v2 response field만 유지한다. - 답변 작성 request는 path로 이동한 `creatorId`, `parentId`를 제외하고 `content`만 받는다. - 답변 작성 response는 사용자 승인 예외인 신규 축약 형태다. - 답변 수정 request는 레거시 `PutWriteCheersRequest`에서 path로 이동한 `cheersId`만 제외하고 optional/nullable `content`, `isActive`를 받는다. 두 필드를 함께 입력할 수 있고, 모두 생략하거나 `null`이면 성공 no-op이다. - 답변 수정 대상은 target AI가 writer이자 creator이고 path의 활성 root에 직접 연결된 reply로 한정한다. reply 자체가 비활성이어도 `isActive=true`로 재활성화할 수 있으며, 다른 target/root·팬 작성 row·nested mismatch는 400이다. - 답변 수정은 non-null field만 반영하고 기존 `languageCode`를 변경하거나 언어 감지·이벤트를 발생시키지 않는다. - 답변 수정 response `data`는 레거시 `CreatorChannelFanTalkResponse` 필드 형태다. `fanTalkId`는 수정한 reply row ID이고 `creatorReplies`는 빈 배열이다. - 삭제는 target 채널에 속한 팬 작성 원글만 허용하고 해당 root row만 `isActive=false`로 변경한다. 연결된 creator reply row는 유지되며 목록에서 root가 제외되므로 함께 노출되지 않는다. - 이미 비활성인 원글 삭제는 성공 no-op이고 성공 `data`는 `null`이다. ## 6. 공통 응답과 오류 일반 조회 성공: ```json { "success": true, "message": null, "data": {}, "errorProperty": null } ``` 레거시 mutation 성공: ```json { "success": true, "message": null, "data": null, "errorProperty": null } ``` 오류: ```json { "success": false, "message": "현지화된 오류 메시지", "data": null, "errorProperty": null } ``` 주요 status는 400, 401, 403, 404, 405, 406, 415, 500이다. 405는 `Allow`, 415는 `Accept` header를 유지한다. Spring CORS 계층이 차단한 미허용 Origin의 403 body는 이 envelope 계약 대상이 아니다. ## 7. 기계 검증과 클라이언트 생성 ```bash jq empty docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json npx --yes @redocly/cli lint --skip-rule info-license \ docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json npx --yes @openapitools/openapi-generator-cli validate \ -i docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json npx --yes @openapitools/openapi-generator-cli generate \ -i docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json \ -g typescript-fetch \ -o /tmp/ai-character-admin-typescript-client tsc --noEmit --target ES2020 --module commonjs --lib ES2020,DOM \ /tmp/ai-character-admin-typescript-client/index.ts ``` `info-license`만 제외하는 이유는 저장소 라이선스 값을 추측해 계약에 추가하지 않기 위해서다. 생성 클라이언트에는 계약의 37개 operation이 모두 포함된다. 37개 모두 실제 route가 구현되어 있고 `implemented` 상태다. OpenAPI의 `/` server URL은 현재 host를 의미하지만 OpenAPI Generator 7.24.0의 `typescript-fetch` runtime 기본값은 `http://localhost`다. 실제 클라이언트는 배포 환경의 API origin을 `new Configuration({ basePath: "..." })`로 반드시 지정한다.