From 2f93e2c9c3705f17fd4568b413cd2e78baf2033e Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 28 Jul 2026 02:19:26 +0900 Subject: [PATCH] =?UTF-8?q?docs(ai-character):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20API=20=EA=B3=84=EC=95=BD=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=EB=A5=BC=20=EA=B3=A0=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api-contract.md | 229 +++ .../api-contract.openapi.json | 1260 +++++++++++++++++ .../20260724_AI캐릭터_관리자_API/plan-task.md | 401 +++++- docs/20260724_AI캐릭터_관리자_API/prd.md | 33 +- 4 files changed, 1843 insertions(+), 80 deletions(-) create mode 100644 docs/20260724_AI캐릭터_관리자_API/api-contract.md create mode 100644 docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json diff --git a/docs/20260724_AI캐릭터_관리자_API/api-contract.md b/docs/20260724_AI캐릭터_관리자_API/api-contract.md new file mode 100644 index 00000000..389d4f54 --- /dev/null +++ b/docs/20260724_AI캐릭터_관리자_API/api-contract.md @@ -0,0 +1,229 @@ +# AI 캐릭터 관리자 API Contract + +## 1. 문서 목적 + +클라이언트 개발과 서버 계약 테스트가 같은 스키마를 사용하도록 AI 캐릭터 관리자 API의 전체 request/response를 +OpenAPI 3.1 JSON으로 고정한다. + +- 정식 계약: `api-contract.openapi.json` +- endpoint: 23개 +- 현재 route 구현: 9개 +- 현재 구현 중 계약 정합화 필요: 9개 +- 구현 예정: 14개 +- 공통 envelope: `ApiResponse` +- 인증: JWT `ROLE_ADMIN`과 현재 DB `Member.role == ADMIN` 동시 충족 + +`x-implementation-status`의 의미는 다음과 같다. + +- `implemented-contract-alignment-required`: route는 구현되어 있지만 2026-07-28 확정 레거시 JSON 계약과 runtime DTO가 + 아직 달라 `P23-CONTRACT-2`, `P23-CONTRACT-3`을 완료하기 전 호출 호환을 보장하지 않는다. +- `planned`: 계약만 고정되었고 route는 아직 구현되지 않았다. + +## 2. 계약 결정 + +1. 신규 endpoint와 `characterId` 기반 관리자 target 경계는 유지한다. +2. JSON 필드명, 타입, optional/nullable, 기본값과 성공 `data` 형태는 레거시 API를 유지한다. +3. 신규 path에 포함된 `characterId`, `contentId`, `seriesId`, `postId`, `fanTalkId`만 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로 분리한다. + +## 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/{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` | `CreateAudioContentRequest`, `contentFile`, `coverImage` | `CreateAudioContentResponse` | +| 정합화 필요 | GET | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` | 필수 `timezone` | `GetAudioContentDetailResponse` | +| 정합화 필요 | PUT | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` | `UpdateCreatorAdminContentRequest`에서 `id` 제외 | `null` | +| 계획 | 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 | `GetCreatorAdminContentSeriesDetailResponse` | +| 계획 | 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` | 필수 `timezone`, `page`, `size` | `List` | +| 계획 | 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}/fan-talks` | 공개 v2 `page?`, `size?` | `CreatorChannelFanTalkTabResponse` | +| 계획 | POST | `/api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies` | `content` | 신규 축약 응답 | + +## 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`다. +- 레거시 날짜 문자열은 원래 format을 유지한다. UTC ISO-8601이 보장되는 FanTalk `createdAtUtc`만 `date-time`이다. +- 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` +- 목록 response는 `totalCount`, `content`이며 `items/page/size/hasNext`로 바꾸지 않는다. +- 상세에는 `systemPrompt`, 캐릭터 속성 배열과 `originalWork`를 포함한다. +- 생성과 수정의 성공 `data`는 상세가 아니라 `null`이다. +- 수정은 `isActive=false`와 다른 optional field의 동시 입력도 레거시 request처럼 허용한다. 이 경우 레거시 service 의미대로 + 비활성화만 반영하고 나머지 JSON field는 적용하지 않는다. + +### AudioContent + +- request: + `CreateAudioContentRequest`, `UpdateCreatorAdminContentRequest` +- 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` 별칭은 정식 계약에 포함하지 않는다. + +### Series + +- request: + `CreateSeriesRequest`, `ModifySeriesRequest`, + `AddingContentToTheSeriesRequest`, `RemoveContentToTheSeriesRequest`, + `UpdateOrdersRequest` +- response: + `GetCreatorAdminContentSeriesListResponse`, + `GetCreatorAdminContentSeriesDetailResponse`, + `GetCreatorAdminContentSeriesContentResponse`, + `SearchContentNotInSeriesResponse` +- `publishedDaysOfWeek` enum은 `SUN..SAT`, `RANDOM`이며 state는 + `PROCEEDING`, `SUSPEND`, `COMPLETE`다. +- 상세의 `publishedDaysOfWeek`, `genre`, `keywords`, `state`는 레거시처럼 문자열이다. +- 상세 `state`의 현재 관찰 값은 `연재중`, `휴재중`, `완결`이지만 DTO 타입이 `String`이므로 enum으로 제한하지 않는다. +- 연결 request는 `contentIdList`, 순서 request는 `ids`다. + +### Community + +- request: + `CreateCommunityPostRequest`, `ModifyCommunityPostRequest`, + `UpdateCommunityPostFixedRequest` +- response: + `GetCommunityPostListResponse`, `GetCommunityPostCommentListItem` +- 목록 `data`는 pagination wrapper가 아니라 직접 배열이다. +- 생성 part 이름은 `postImage`, `audioFile`이다. +- 수정 endpoint는 기존 본문 수정과 고정/해제를 합치므로 ID를 제외한 + `content`, `isCommentAvailable`, `isAdult`, `isActive`, `isFixed`를 받는다. +- 레거시에 없는 수정 `price`, `audioFile`은 포함하지 않는다. + +### FanTalk + +- 목록 response: + `CreatorChannelFanTalkTabResponse`, `CreatorChannelFanTalkResponse`, + `CreatorChannelFanTalkReplyResponse` +- 공개 v2 endpoint를 직접 사용하지 않는다. 공개 v2는 `creatorId`, viewer 인증과 block filter, 다른 CORS 경계를 사용하기 + 때문이다. +- 신규 관리자 목록은 `characterId`로 creator를 해석하고 공개 v2 response field만 유지한다. +- 답변 request는 path로 이동한 `creatorId`, `parentId`를 제외하고 `content`만 받는다. +- 답변 response는 사용자 승인 예외인 신규 축약 형태다. + +## 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`만 제외하는 이유는 저장소 라이선스 값을 추측해 계약에 추가하지 않기 위해서다. +생성 클라이언트에는 23개 operation이 모두 포함된다. `implemented-contract-alignment-required` operation은 해당 runtime +정합화 Gate가 끝나기 전 production 호출 대상으로 간주하지 않는다. + +OpenAPI의 `/` server URL은 현재 host를 의미하지만 OpenAPI Generator 7.24.0의 `typescript-fetch` runtime 기본값은 +`http://localhost`다. 실제 클라이언트는 배포 환경의 API origin을 `new Configuration({ basePath: "..." })`로 반드시 +지정한다. diff --git a/docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json b/docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json new file mode 100644 index 00000000..46385e04 --- /dev/null +++ b/docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json @@ -0,0 +1,1260 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "AI 캐릭터 관리자 API", + "version": "2.0.0", + "description": "클라이언트 개발용 정식 계약. 신규 관리자 endpoint와 path target을 사용하되 JSON 필드명, 타입, optional/nullable, 기본값과 성공 data 형태는 레거시 API를 유지한다. path로 이동한 ID만 body에서 제거한다." + }, + "servers": [{"url": "/", "description": "현재 호스트"}], + "security": [{"bearerAuth": []}], + "tags": [ + {"name": "Character", "description": "AI 캐릭터 조회·생성·수정"}, + {"name": "AudioContent", "description": "오디오 콘텐츠 테마·조회·생성·수정"}, + {"name": "Series", "description": "시리즈와 연결 콘텐츠 관리"}, + {"name": "Community", "description": "커뮤니티 게시글 관리"}, + {"name": "FanTalk", "description": "FanTalk 관리자 목록과 creator reply"} + ], + "paths": { + "/api/v2/admin/ai-characters": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}], + "get": { + "tags": ["Character"], + "summary": "AI 캐릭터 목록/검색", + "operationId": "listAiCharacters", + "x-implementation-status": "implemented-contract-alignment-required", + "x-legacy-sources": ["AdminChatCharacterController.getCharacterList", "AdminChatCharacterController.searchCharacters", "ChatCharacterListPageResponse", "ChatCharacterSearchListPageResponse"], + "parameters": [ + {"name": "searchTerm", "in": "query", "required": false, "description": "생략하면 활성 목록, 지정하면 레거시 검색을 수행한다.", "schema": {"type": "string"}}, + {"$ref": "#/components/parameters/Page"}, + {"$ref": "#/components/parameters/Size"} + ], + "responses": { + "200": {"$ref": "#/components/responses/CharacterListSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + }, + "post": { + "tags": ["Character"], + "summary": "AI 캐릭터 생성", + "operationId": "createAiCharacter", + "x-implementation-status": "implemented-contract-alignment-required", + "x-legacy-sources": ["AdminChatCharacterController.registerCharacter", "ChatCharacterRegisterRequest"], + "requestBody": {"$ref": "#/components/requestBodies/CharacterCreateMultipart"}, + "responses": { + "200": {"$ref": "#/components/responses/NullSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, + "/api/v2/admin/ai-characters/{characterId}": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}], + "get": { + "tags": ["Character"], + "summary": "AI 캐릭터 상세", + "operationId": "getAiCharacter", + "x-implementation-status": "implemented-contract-alignment-required", + "x-legacy-sources": ["AdminChatCharacterController.getCharacterDetail", "ChatCharacterDetailResponse"], + "responses": { + "200": {"$ref": "#/components/responses/CharacterDetailSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + }, + "put": { + "tags": ["Character"], + "summary": "AI 캐릭터 수정/soft delete", + "description": "레거시 ChatCharacterUpdateRequest의 id만 path characterId로 이동한다.", + "operationId": "updateAiCharacter", + "x-implementation-status": "implemented-contract-alignment-required", + "x-legacy-sources": ["AdminChatCharacterController.updateCharacter", "ChatCharacterUpdateRequest"], + "requestBody": {"$ref": "#/components/requestBodies/CharacterUpdateMultipart"}, + "responses": { + "200": {"$ref": "#/components/responses/NullSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, + "/api/v2/admin/ai-characters/audio-content-themes": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}], + "get": { + "tags": ["AudioContent"], + "summary": "활성 오디오 콘텐츠 테마 목록", + "operationId": "listAiCharacterAudioContentThemes", + "x-implementation-status": "implemented-contract-alignment-required", + "x-legacy-sources": ["GetAudioContentThemeResponse"], + "responses": { + "200": {"$ref": "#/components/responses/AudioThemeListSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, + "/api/v2/admin/ai-characters/{characterId}/audio-contents": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}], + "get": { + "tags": ["AudioContent"], + "summary": "오디오 콘텐츠 목록/검색", + "operationId": "listAiCharacterAudioContents", + "x-implementation-status": "implemented-contract-alignment-required", + "x-legacy-sources": ["CreatorAdminContentController.getAudioContentList", "CreatorAdminContentController.searchAudioContent", "GetCreatorAdminContentListResponse"], + "parameters": [ + {"name": "search_word", "in": "query", "required": false, "description": "지정하면 레거시 검색을 수행하며 2자 이상이어야 한다.", "schema": {"type": "string"}}, + {"$ref": "#/components/parameters/Page"}, + {"$ref": "#/components/parameters/Size"} + ], + "responses": { + "200": {"$ref": "#/components/responses/AudioContentListSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + }, + "post": { + "tags": ["AudioContent"], + "summary": "오디오 콘텐츠 생성", + "operationId": "createAiCharacterAudioContent", + "x-implementation-status": "implemented-contract-alignment-required", + "x-legacy-sources": ["AudioContentController.createAudioContent", "CreateAudioContentRequest", "CreateAudioContentResponse"], + "requestBody": {"$ref": "#/components/requestBodies/AudioContentCreateMultipart"}, + "responses": { + "200": {"$ref": "#/components/responses/AudioContentCreateSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, + "/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}, {"$ref": "#/components/parameters/ContentId"}], + "get": { + "tags": ["AudioContent"], + "summary": "오디오 콘텐츠 상세", + "operationId": "getAiCharacterAudioContent", + "x-implementation-status": "implemented-contract-alignment-required", + "x-legacy-sources": ["AudioContentController.getDetail", "GetAudioContentDetailResponse"], + "parameters": [{"$ref": "#/components/parameters/Timezone"}], + "responses": { + "200": {"$ref": "#/components/responses/AudioContentDetailSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + }, + "put": { + "tags": ["AudioContent"], + "summary": "오디오 콘텐츠 수정/soft delete", + "description": "레거시 UpdateCreatorAdminContentRequest의 id만 path contentId로 이동한다.", + "operationId": "updateAiCharacterAudioContent", + "x-implementation-status": "implemented-contract-alignment-required", + "x-legacy-sources": ["CreatorAdminContentController.modifyAudioContent", "UpdateCreatorAdminContentRequest"], + "requestBody": {"$ref": "#/components/requestBodies/AudioContentUpdateMultipart"}, + "responses": { + "200": {"$ref": "#/components/responses/NullSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, + "/api/v2/admin/ai-characters/{characterId}/series": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}], + "get": { + "tags": ["Series"], + "summary": "시리즈 목록", + "operationId": "listAiCharacterSeries", + "x-implementation-status": "planned", + "x-legacy-sources": ["CreatorAdminContentSeriesController.getSeriesList", "GetCreatorAdminContentSeriesListResponse"], + "parameters": [{"$ref": "#/components/parameters/Page"}, {"$ref": "#/components/parameters/Size"}], + "responses": { + "200": {"$ref": "#/components/responses/SeriesListSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + }, + "post": { + "tags": ["Series"], + "summary": "시리즈 생성", + "operationId": "createAiCharacterSeries", + "x-implementation-status": "planned", + "x-legacy-sources": ["CreatorAdminContentSeriesController.createSeries", "CreateSeriesRequest"], + "requestBody": {"$ref": "#/components/requestBodies/SeriesCreateMultipart"}, + "responses": { + "200": {"$ref": "#/components/responses/NullSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, + "/api/v2/admin/ai-characters/{characterId}/series/orders": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}], + "put": { + "tags": ["Series"], + "summary": "시리즈 순서 변경", + "operationId": "reorderAiCharacterSeries", + "x-implementation-status": "planned", + "x-legacy-sources": ["CreatorAdminContentSeriesController.updateSeriesOrders", "UpdateOrdersRequest"], + "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SeriesOrderUpdateRequest"}}}}, + "responses": { + "200": {"$ref": "#/components/responses/NullSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, + "/api/v2/admin/ai-characters/{characterId}/series/{seriesId}": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}, {"$ref": "#/components/parameters/SeriesId"}], + "get": { + "tags": ["Series"], + "summary": "시리즈 상세", + "operationId": "getAiCharacterSeries", + "x-implementation-status": "planned", + "x-legacy-sources": ["CreatorAdminContentSeriesController.getDetail", "GetCreatorAdminContentSeriesDetailResponse"], + "responses": { + "200": {"$ref": "#/components/responses/SeriesDetailSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + }, + "put": { + "tags": ["Series"], + "summary": "시리즈 수정/soft delete", + "description": "레거시 ModifySeriesRequest의 seriesId만 path로 이동한다.", + "operationId": "updateAiCharacterSeries", + "x-implementation-status": "planned", + "x-legacy-sources": ["CreatorAdminContentSeriesController.modifySeries", "ModifySeriesRequest"], + "requestBody": {"$ref": "#/components/requestBodies/SeriesUpdateMultipart"}, + "responses": { + "200": {"$ref": "#/components/responses/NullSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, + "/api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}, {"$ref": "#/components/parameters/SeriesId"}], + "get": { + "tags": ["Series"], + "summary": "시리즈 연결 콘텐츠 목록", + "operationId": "listAiCharacterSeriesContents", + "x-implementation-status": "planned", + "x-legacy-sources": ["CreatorAdminContentSeriesController.getSeriesContent", "GetCreatorAdminContentSeriesContentResponse"], + "parameters": [{"$ref": "#/components/parameters/Page"}, {"$ref": "#/components/parameters/Size"}], + "responses": { + "200": {"$ref": "#/components/responses/SeriesContentListSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + }, + "post": { + "tags": ["Series"], + "summary": "시리즈 콘텐츠 연결", + "description": "레거시 AddingContentToTheSeriesRequest의 seriesId만 path로 이동한다.", + "operationId": "addAiCharacterSeriesContents", + "x-implementation-status": "planned", + "x-legacy-sources": ["CreatorAdminContentSeriesController.addingContentToTheSeries", "AddingContentToTheSeriesRequest"], + "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SeriesContentAddRequest"}}}}, + "responses": { + "200": {"$ref": "#/components/responses/NullSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, + "/api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents/search": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}, {"$ref": "#/components/parameters/SeriesId"}], + "get": { + "tags": ["Series"], + "summary": "시리즈 미연결 콘텐츠 검색", + "operationId": "searchAiCharacterContentsNotInSeries", + "x-implementation-status": "planned", + "x-legacy-sources": ["CreatorAdminContentSeriesController.searchContentNotInSeries", "SearchContentNotInSeriesResponse"], + "parameters": [{"name": "search_word", "in": "query", "required": true, "schema": {"type": "string"}}], + "responses": { + "200": {"$ref": "#/components/responses/SeriesContentSearchSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, + "/api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents/{contentId}": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}, {"$ref": "#/components/parameters/SeriesId"}, {"$ref": "#/components/parameters/ContentId"}], + "delete": { + "tags": ["Series"], + "summary": "시리즈 콘텐츠 연결 해제", + "description": "레거시 RemoveContentToTheSeriesRequest의 seriesId와 contentId를 path로 이동해 body가 없다.", + "operationId": "removeAiCharacterSeriesContent", + "x-implementation-status": "planned", + "x-legacy-sources": ["CreatorAdminContentSeriesController.removeContentInTheSeries", "RemoveContentToTheSeriesRequest"], + "responses": { + "200": {"$ref": "#/components/responses/NullSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, + "/api/v2/admin/ai-characters/{characterId}/community-posts": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}], + "get": { + "tags": ["Community"], + "summary": "커뮤니티 게시글 목록", + "operationId": "listAiCharacterCommunityPosts", + "x-implementation-status": "planned", + "x-legacy-sources": ["CreatorCommunityController.getCommunityPostList", "GetCommunityPostListResponse"], + "parameters": [{"$ref": "#/components/parameters/Timezone"}, {"$ref": "#/components/parameters/Page"}, {"$ref": "#/components/parameters/Size"}], + "responses": { + "200": {"$ref": "#/components/responses/CommunityPostListSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + }, + "post": { + "tags": ["Community"], + "summary": "커뮤니티 게시글 등록", + "operationId": "createAiCharacterCommunityPost", + "x-implementation-status": "planned", + "x-legacy-sources": ["CreatorCommunityController.createCommunityPost", "CreateCommunityPostRequest"], + "requestBody": {"$ref": "#/components/requestBodies/CommunityPostCreateMultipart"}, + "responses": { + "200": {"$ref": "#/components/responses/NullSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, + "/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}, {"$ref": "#/components/parameters/PostId"}], + "put": { + "tags": ["Community"], + "summary": "커뮤니티 게시글 수정/고정/soft delete", + "description": "ModifyCommunityPostRequest의 creatorCommunityId와 UpdateCommunityPostFixedRequest의 postId를 path로 이동하고 나머지 레거시 필드를 하나의 request에 합친다.", + "operationId": "updateAiCharacterCommunityPost", + "x-implementation-status": "planned", + "x-legacy-sources": ["CreatorCommunityController.modifyCommunityPost", "CreatorCommunityController.updateCommunityPostFixed", "ModifyCommunityPostRequest", "UpdateCommunityPostFixedRequest"], + "requestBody": {"$ref": "#/components/requestBodies/CommunityPostUpdateMultipart"}, + "responses": { + "200": {"$ref": "#/components/responses/NullSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, + "/api/v2/admin/ai-characters/{characterId}/fan-talks": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}], + "get": { + "tags": ["FanTalk"], + "summary": "FanTalk 관리자 목록", + "description": "공개 v2 응답 필드 형태를 유지하되 관리자 target/ownership 정책을 사용하고 viewer/block 필터를 적용하지 않는다.", + "operationId": "listAiCharacterFanTalks", + "x-implementation-status": "planned", + "x-legacy-sources": ["CreatorChannelFanTalkController.getFanTalkTab", "CreatorChannelFanTalkTabResponse"], + "parameters": [{"$ref": "#/components/parameters/FanTalkPage"}, {"$ref": "#/components/parameters/FanTalkSize"}], + "responses": { + "200": {"$ref": "#/components/responses/FanTalkListSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, + "/api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}, {"$ref": "#/components/parameters/FanTalkId"}], + "post": { + "tags": ["FanTalk"], + "summary": "FanTalk 답변 작성", + "description": "사용자가 승인한 예외로 신규 관리자 축약 응답을 반환한다.", + "operationId": "createAiCharacterFanTalkReply", + "x-implementation-status": "planned", + "x-legacy-sources": ["ExplorerController.writeCheers", "PostWriteCheersRequest", "CreatorChannelFanTalkReplyResponse"], + "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FanTalkReplyCreateRequest"}}}}, + "responses": { + "200": {"$ref": "#/components/responses/FanTalkReplySuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"} + }, + "parameters": { + "AcceptLanguage": { + "name": "Accept-Language", + "in": "header", + "required": false, + "description": "ko, en, ja는 해당 언어를 사용한다. header가 없거나 그 밖의 문자열이면 KO로 fallback한다.", + "schema": {"type": "string", "default": "ko", "examples": ["ko", "en", "ja"]} + }, + "CharacterId": {"name": "characterId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, + "ContentId": {"name": "contentId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, + "SeriesId": {"name": "seriesId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, + "PostId": {"name": "postId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, + "FanTalkId": {"name": "fanTalkId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, + "Page": {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0, "minimum": 0}}, + "Size": {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20, "minimum": 1}}, + "FanTalkPage": {"name": "page", "in": "query", "required": false, "description": "공개 v2 정책에서 0 이상으로 보정한다.", "schema": {"type": "integer", "format": "int32", "default": 0}}, + "FanTalkSize": {"name": "size", "in": "query", "required": false, "description": "공개 v2 정책에서 20..50으로 보정한다.", "schema": {"type": "integer", "format": "int32", "default": 20}}, + "Timezone": {"name": "timezone", "in": "query", "required": true, "example": "Asia/Seoul", "schema": {"type": "string"}} + }, + "requestBodies": { + "CharacterCreateMultipart": { + "required": true, + "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/CharacterCreateMultipart"}, "encoding": {"request": {"contentType": "application/json"}}}} + }, + "CharacterUpdateMultipart": { + "required": true, + "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/CharacterUpdateMultipart"}, "encoding": {"request": {"contentType": "application/json"}}}} + }, + "AudioContentCreateMultipart": { + "required": true, + "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/AudioContentCreateMultipart"}, "encoding": {"request": {"contentType": "application/json"}}}} + }, + "AudioContentUpdateMultipart": { + "required": true, + "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/AudioContentUpdateMultipart"}, "encoding": {"request": {"contentType": "application/json"}}}} + }, + "SeriesCreateMultipart": { + "required": true, + "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/SeriesCreateMultipart"}, "encoding": {"request": {"contentType": "application/json"}}}} + }, + "SeriesUpdateMultipart": { + "required": true, + "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/SeriesUpdateMultipart"}, "encoding": {"request": {"contentType": "application/json"}}}} + }, + "CommunityPostCreateMultipart": { + "required": true, + "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/CommunityPostCreateMultipart"}, "encoding": {"request": {"contentType": "application/json"}}}} + }, + "CommunityPostUpdateMultipart": { + "required": true, + "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/CommunityPostUpdateMultipart"}, "encoding": {"request": {"contentType": "application/json"}}}} + } + }, + "responses": { + "CharacterListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CharacterListApiResponse"}}}}, + "CharacterDetailSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CharacterDetailApiResponse"}}}}, + "AudioThemeListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AudioThemeListApiResponse"}}}}, + "AudioContentListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AudioContentListApiResponse"}}}}, + "AudioContentCreateSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AudioContentCreateApiResponse"}}}}, + "AudioContentDetailSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AudioContentDetailApiResponse"}}}}, + "SeriesListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SeriesListApiResponse"}}}}, + "SeriesDetailSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SeriesDetailApiResponse"}}}}, + "SeriesContentListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SeriesContentListApiResponse"}}}}, + "SeriesContentSearchSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SeriesContentSearchApiResponse"}}}}, + "CommunityPostListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommunityPostListApiResponse"}}}}, + "FanTalkListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FanTalkListApiResponse"}}}}, + "FanTalkReplySuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FanTalkReplyApiResponse"}}}}, + "NullSuccess": {"description": "레거시 mutation 성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/NullSuccessResponse"}}}}, + "BadRequest": {"description": "잘못된 요청/target/domain 오류", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiErrorResponse"}}}}, + "Unauthorized": {"description": "인증 실패", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiErrorResponse"}}}}, + "Forbidden": {"description": "인가 실패", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiErrorResponse"}}}}, + "NotFound": {"description": "미매핑 경로", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiErrorResponse"}}}}, + "MethodNotAllowed": {"description": "지원하지 않는 method", "headers": {"Allow": {"schema": {"type": "string"}}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiErrorResponse"}}}}, + "NotAcceptable": {"description": "지원하지 않는 응답 media type", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiErrorResponse"}}}}, + "UnsupportedMediaType": {"description": "지원하지 않는 요청 media type", "headers": {"Accept": {"schema": {"type": "string"}}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiErrorResponse"}}}}, + "InternalServerError": {"description": "서버 오류", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiErrorResponse"}}}} + }, + "schemas": { + "NullableString": {"type": ["string", "null"]}, + "NullableInt32": {"type": ["integer", "null"], "format": "int32"}, + "NullableInt64": {"type": ["integer", "null"], "format": "int64"}, + "NullableBoolean": {"type": ["boolean", "null"]}, + "BinaryFile": {"type": "string", "format": "binary"}, + "PurchaseOption": {"type": "string", "enum": ["BOTH", "BUY_ONLY", "RENT_ONLY"]}, + "OrderType": {"type": "string", "enum": ["RENTAL", "KEEP"]}, + "CharacterType": {"type": "string", "enum": ["Clone", "Character"]}, + "SeriesPublishedDaysOfWeek": {"type": "string", "enum": ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT", "RANDOM"]}, + "SeriesState": {"type": "string", "enum": ["PROCEEDING", "SUSPEND", "COMPLETE"]}, + + "ApiSuccessBase": { + "type": "object", + "required": ["success"], + "properties": { + "success": {"type": "boolean", "const": true} + } + }, + "ApiErrorResponse": { + "type": "object", + "required": ["success", "message", "data", "errorProperty"], + "properties": { + "success": {"type": "boolean", "const": false}, + "message": {"type": "string"}, + "data": {"type": ["object", "null"], "const": null}, + "errorProperty": {"type": ["string", "null"]} + } + }, + "NullSuccessResponse": { + "allOf": [ + {"$ref": "#/components/schemas/ApiSuccessBase"}, + { + "type": "object", + "required": ["message", "data", "errorProperty"], + "properties": { + "message": {"type": ["string", "null"]}, + "data": {"type": ["object", "null"], "const": null}, + "errorProperty": {"type": ["string", "null"], "const": null} + } + } + ] + }, + + "CharacterRelationship": { + "type": "object", + "additionalProperties": false, + "required": ["personName", "relationshipName", "description", "importance", "relationshipType", "currentStatus"], + "properties": { + "personName": {"type": "string"}, + "relationshipName": {"type": "string"}, + "description": {"type": "string"}, + "importance": {"type": "integer", "format": "int32"}, + "relationshipType": {"type": "string"}, + "currentStatus": {"type": "string"} + } + }, + "CharacterPersonality": { + "type": "object", + "additionalProperties": false, + "required": ["trait", "description"], + "properties": {"trait": {"type": "string"}, "description": {"type": "string"}} + }, + "CharacterBackground": { + "type": "object", + "additionalProperties": false, + "required": ["topic", "description"], + "properties": {"topic": {"type": "string"}, "description": {"type": "string"}} + }, + "CharacterMemory": { + "type": "object", + "additionalProperties": false, + "required": ["title", "content", "emotion"], + "properties": {"title": {"type": "string"}, "content": {"type": "string"}, "emotion": {"type": "string"}} + }, + "OriginalWorkBrief": { + "type": "object", + "additionalProperties": false, + "required": ["id", "imageUrl", "title"], + "properties": { + "id": {"type": "integer", "format": "int64"}, + "imageUrl": {"$ref": "#/components/schemas/NullableString"}, + "title": {"type": "string"} + } + }, + "CharacterCreateRequest": { + "type": "object", + "additionalProperties": false, + "required": ["name", "systemPrompt", "description"], + "properties": { + "name": {"type": "string"}, + "systemPrompt": {"type": "string"}, + "description": {"type": "string"}, + "age": {"$ref": "#/components/schemas/NullableString"}, + "gender": {"$ref": "#/components/schemas/NullableString"}, + "mbti": {"$ref": "#/components/schemas/NullableString"}, + "speechPattern": {"$ref": "#/components/schemas/NullableString"}, + "speechStyle": {"$ref": "#/components/schemas/NullableString"}, + "appearance": {"$ref": "#/components/schemas/NullableString"}, + "region": {"type": "string", "default": "KR"}, + "originalTitle": {"$ref": "#/components/schemas/NullableString"}, + "originalLink": {"$ref": "#/components/schemas/NullableString"}, + "originalWorkId": {"$ref": "#/components/schemas/NullableInt64"}, + "characterType": {"$ref": "#/components/schemas/NullableString"}, + "tags": {"type": "array", "items": {"type": "string"}, "default": []}, + "hobbies": {"type": "array", "items": {"type": "string"}, "default": []}, + "values": {"type": "array", "items": {"type": "string"}, "default": []}, + "goals": {"type": "array", "items": {"type": "string"}, "default": []}, + "relationships": {"type": "array", "items": {"$ref": "#/components/schemas/CharacterRelationship"}, "default": []}, + "personalities": {"type": "array", "items": {"$ref": "#/components/schemas/CharacterPersonality"}, "default": []}, + "backgrounds": {"type": "array", "items": {"$ref": "#/components/schemas/CharacterBackground"}, "default": []}, + "memories": {"type": "array", "items": {"$ref": "#/components/schemas/CharacterMemory"}, "default": []} + } + }, + "CharacterUpdateRequest": { + "type": "object", + "additionalProperties": false, + "description": "ChatCharacterUpdateRequest에서 path로 이동한 id만 제거한 형태다. isActive=false와 다른 필드를 함께 보내는 것도 레거시 request 계약상 허용되며, 이 경우 레거시 service는 비활성화만 반영하고 나머지 JSON 필드는 적용하지 않는다.", + "properties": { + "name": {"$ref": "#/components/schemas/NullableString"}, + "systemPrompt": {"$ref": "#/components/schemas/NullableString"}, + "description": {"$ref": "#/components/schemas/NullableString"}, + "age": {"$ref": "#/components/schemas/NullableString"}, + "gender": {"$ref": "#/components/schemas/NullableString"}, + "mbti": {"$ref": "#/components/schemas/NullableString"}, + "speechPattern": {"$ref": "#/components/schemas/NullableString"}, + "speechStyle": {"$ref": "#/components/schemas/NullableString"}, + "appearance": {"$ref": "#/components/schemas/NullableString"}, + "originalTitle": {"$ref": "#/components/schemas/NullableString"}, + "originalLink": {"$ref": "#/components/schemas/NullableString"}, + "originalWorkId": {"$ref": "#/components/schemas/NullableInt64"}, + "characterType": {"$ref": "#/components/schemas/NullableString"}, + "isActive": {"$ref": "#/components/schemas/NullableBoolean"}, + "tags": {"type": ["array", "null"], "items": {"type": "string"}}, + "hobbies": {"type": ["array", "null"], "items": {"type": "string"}}, + "values": {"type": ["array", "null"], "items": {"type": "string"}}, + "goals": {"type": ["array", "null"], "items": {"type": "string"}}, + "relationships": {"type": ["array", "null"], "items": {"$ref": "#/components/schemas/CharacterRelationship"}}, + "personalities": {"type": ["array", "null"], "items": {"$ref": "#/components/schemas/CharacterPersonality"}}, + "backgrounds": {"type": ["array", "null"], "items": {"$ref": "#/components/schemas/CharacterBackground"}}, + "memories": {"type": ["array", "null"], "items": {"$ref": "#/components/schemas/CharacterMemory"}} + } + }, + "CharacterCreateMultipart": { + "type": "object", + "additionalProperties": false, + "required": ["image", "request"], + "properties": {"image": {"$ref": "#/components/schemas/BinaryFile"}, "request": {"$ref": "#/components/schemas/CharacterCreateRequest"}} + }, + "CharacterUpdateMultipart": { + "type": "object", + "additionalProperties": false, + "required": ["request"], + "properties": {"image": {"$ref": "#/components/schemas/BinaryFile"}, "request": {"$ref": "#/components/schemas/CharacterUpdateRequest"}} + }, + "CharacterListItem": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "imageUrl", "description", "gender", "age", "mbti", "speechStyle", "speechPattern", "region", "tags", "createdAt", "updatedAt"], + "properties": { + "id": {"type": "integer", "format": "int64"}, + "name": {"type": "string"}, + "imageUrl": {"$ref": "#/components/schemas/NullableString"}, + "description": {"type": "string"}, + "gender": {"$ref": "#/components/schemas/NullableString"}, + "age": {"$ref": "#/components/schemas/NullableInt32"}, + "mbti": {"$ref": "#/components/schemas/NullableString"}, + "speechStyle": {"$ref": "#/components/schemas/NullableString"}, + "speechPattern": {"$ref": "#/components/schemas/NullableString"}, + "region": {"type": "string"}, + "tags": {"type": "array", "items": {"type": "string"}}, + "createdAt": {"type": ["string", "null"], "description": "Asia/Seoul yyyy-MM-dd HH:mm:ss"}, + "updatedAt": {"type": ["string", "null"], "description": "Asia/Seoul yyyy-MM-dd HH:mm:ss"} + } + }, + "CharacterListResponse": { + "type": "object", + "additionalProperties": false, + "required": ["totalCount", "content"], + "properties": { + "totalCount": {"type": "integer", "format": "int64"}, + "content": {"type": "array", "items": {"$ref": "#/components/schemas/CharacterListItem"}} + } + }, + "CharacterDetailResponse": { + "type": "object", + "additionalProperties": false, + "required": ["id", "characterUUID", "name", "imageUrl", "description", "systemPrompt", "characterType", "age", "gender", "mbti", "speechPattern", "speechStyle", "appearance", "region", "isActive", "tags", "hobbies", "values", "goals", "relationships", "personalities", "backgrounds", "memories", "originalWork"], + "properties": { + "id": {"type": "integer", "format": "int64"}, + "characterUUID": {"type": "string"}, + "name": {"type": "string"}, + "imageUrl": {"$ref": "#/components/schemas/NullableString"}, + "description": {"type": "string"}, + "systemPrompt": {"type": "string"}, + "characterType": {"$ref": "#/components/schemas/CharacterType"}, + "age": {"$ref": "#/components/schemas/NullableInt32"}, + "gender": {"$ref": "#/components/schemas/NullableString"}, + "mbti": {"$ref": "#/components/schemas/NullableString"}, + "speechPattern": {"$ref": "#/components/schemas/NullableString"}, + "speechStyle": {"$ref": "#/components/schemas/NullableString"}, + "appearance": {"$ref": "#/components/schemas/NullableString"}, + "region": {"type": "string"}, + "isActive": {"type": "boolean"}, + "tags": {"type": "array", "items": {"type": "string"}}, + "hobbies": {"type": "array", "items": {"type": "string"}}, + "values": {"type": "array", "items": {"type": "string"}}, + "goals": {"type": "array", "items": {"type": "string"}}, + "relationships": {"type": "array", "items": {"$ref": "#/components/schemas/CharacterRelationship"}}, + "personalities": {"type": "array", "items": {"$ref": "#/components/schemas/CharacterPersonality"}}, + "backgrounds": {"type": "array", "items": {"$ref": "#/components/schemas/CharacterBackground"}}, + "memories": {"type": "array", "items": {"$ref": "#/components/schemas/CharacterMemory"}}, + "originalWork": {"oneOf": [{"$ref": "#/components/schemas/OriginalWorkBrief"}, {"type": "null"}]} + } + }, + "CharacterListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/CharacterListResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + "CharacterDetailApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/CharacterDetailResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + + "AudioContentTheme": { + "type": "object", + "additionalProperties": false, + "required": ["id", "theme", "image"], + "properties": {"id": {"type": "integer", "format": "int64"}, "theme": {"type": "string"}, "image": {"type": "string"}} + }, + "AudioThemeListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"type": "array", "items": {"$ref": "#/components/schemas/AudioContentTheme"}}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + "AudioContentCreateRequest": { + "type": "object", + "additionalProperties": false, + "required": ["title", "detail", "tags", "price"], + "properties": { + "title": {"type": "string"}, + "detail": {"type": "string"}, + "tags": {"type": "string"}, + "price": {"type": "integer", "format": "int32"}, + "purchaseOption": {"$ref": "#/components/schemas/PurchaseOption", "default": "BOTH"}, + "limited": {"$ref": "#/components/schemas/NullableInt32"}, + "timezone": {"type": "string", "default": "Asia/Seoul"}, + "releaseDate": {"type": ["string", "null"], "description": "yyyy-MM-dd HH:mm"}, + "themeId": {"type": "integer", "format": "int64", "default": 0, "description": "0은 binding 기본값이며 domain validation에서 유효하지 않다."}, + "isAdult": {"type": "boolean", "default": false}, + "isGeneratePreview": {"type": "boolean", "default": false}, + "isOnlyRental": {"type": "boolean", "default": false}, + "isPointAvailable": {"type": "boolean", "default": false}, + "isCommentAvailable": {"type": "boolean", "default": false}, + "isFullDetailVisible": {"type": "boolean", "default": true}, + "previewStartTime": {"type": ["string", "null"], "description": "HH:mm:ss"}, + "previewEndTime": {"type": ["string", "null"], "description": "HH:mm:ss"}, + "languageCode": {"$ref": "#/components/schemas/NullableString"} + } + }, + "AudioContentUpdateRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": {"$ref": "#/components/schemas/NullableString"}, + "detail": {"$ref": "#/components/schemas/NullableString"}, + "tags": {"$ref": "#/components/schemas/NullableString"}, + "price": {"$ref": "#/components/schemas/NullableInt32"}, + "isAdult": {"$ref": "#/components/schemas/NullableBoolean"}, + "isActive": {"$ref": "#/components/schemas/NullableBoolean"}, + "isPointAvailable": {"$ref": "#/components/schemas/NullableBoolean"}, + "isCommentAvailable": {"$ref": "#/components/schemas/NullableBoolean"} + } + }, + "AudioContentCreateMultipart": { + "type": "object", + "additionalProperties": false, + "required": ["contentFile", "coverImage", "request"], + "properties": { + "contentFile": {"$ref": "#/components/schemas/BinaryFile"}, + "coverImage": {"$ref": "#/components/schemas/BinaryFile"}, + "request": {"$ref": "#/components/schemas/AudioContentCreateRequest"} + } + }, + "AudioContentUpdateMultipart": { + "type": "object", + "additionalProperties": false, + "required": ["request"], + "properties": { + "coverImage": {"$ref": "#/components/schemas/BinaryFile"}, + "request": {"$ref": "#/components/schemas/AudioContentUpdateRequest"} + } + }, + "AudioContentListItem": { + "type": "object", + "additionalProperties": false, + "required": ["audioContentId", "title", "detail", "coverImageUrl", "creatorNickname", "theme", "price", "totalContentCount", "remainingContentCount", "isAdult", "isPointAvailable", "isCommentAvailable", "remainingTime", "contentUrl", "date", "releaseDate", "tags"], + "properties": { + "audioContentId": {"type": "integer", "format": "int64"}, + "title": {"type": "string"}, + "detail": {"type": "string"}, + "coverImageUrl": {"type": "string"}, + "creatorNickname": {"type": "string"}, + "theme": {"type": "string"}, + "price": {"type": "integer", "format": "int32"}, + "totalContentCount": {"$ref": "#/components/schemas/NullableInt32"}, + "remainingContentCount": {"$ref": "#/components/schemas/NullableInt32"}, + "isAdult": {"type": "boolean"}, + "isPointAvailable": {"type": "boolean"}, + "isCommentAvailable": {"type": "boolean"}, + "remainingTime": {"type": "string"}, + "contentUrl": {"type": "string", "description": "signed URL"}, + "date": {"type": "string"}, + "releaseDate": {"$ref": "#/components/schemas/NullableString"}, + "tags": {"type": "string"} + } + }, + "AudioContentListResponse": { + "type": "object", + "additionalProperties": false, + "required": ["totalCount", "items"], + "properties": {"totalCount": {"type": "integer", "format": "int32"}, "items": {"type": "array", "items": {"$ref": "#/components/schemas/AudioContentListItem"}}} + }, + "AudioContentListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/AudioContentListResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + "AudioContentCreateResponse": { + "type": "object", + "additionalProperties": false, + "required": ["contentId"], + "properties": {"contentId": {"type": "integer", "format": "int64"}} + }, + "AudioContentCreateApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/AudioContentCreateResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + "OtherContentResponse": { + "type": "object", + "additionalProperties": false, + "required": ["contentId", "title", "coverUrl"], + "properties": {"contentId": {"type": "integer", "format": "int64"}, "title": {"type": "string"}, "coverUrl": {"type": "string"}} + }, + "AudioContentCreator": { + "type": "object", + "additionalProperties": false, + "required": ["creatorId", "nickname", "profileImageUrl", "isFollowing", "isFollow", "isNotify"], + "properties": { + "creatorId": {"type": "integer", "format": "int64"}, + "nickname": {"type": "string"}, + "profileImageUrl": {"type": "string"}, + "isFollowing": {"type": "boolean"}, + "isFollow": {"type": "boolean"}, + "isNotify": {"type": "boolean"} + } + }, + "ContentBuyer": { + "type": "object", + "additionalProperties": false, + "required": ["nickname", "profileImageUrl"], + "properties": {"nickname": {"type": "string"}, "profileImageUrl": {"type": "string"}} + }, + "AudioContentComment": { + "type": "object", + "additionalProperties": false, + "required": ["id", "writerId", "nickname", "profileUrl", "comment", "languageCode", "isSecret", "donationCan", "date", "replyCount"], + "properties": { + "id": {"type": "integer", "format": "int64"}, + "writerId": {"type": "integer", "format": "int64"}, + "nickname": {"type": "string"}, + "profileUrl": {"type": "string"}, + "comment": {"type": "string"}, + "languageCode": {"$ref": "#/components/schemas/NullableString"}, + "isSecret": {"type": "boolean"}, + "donationCan": {"type": "integer", "format": "int32"}, + "date": {"type": "string"}, + "replyCount": {"type": "integer", "format": "int32"} + } + }, + "TranslatedContent": { + "type": "object", + "additionalProperties": false, + "required": ["title", "detail", "tags"], + "properties": {"title": {"$ref": "#/components/schemas/NullableString"}, "detail": {"$ref": "#/components/schemas/NullableString"}, "tags": {"$ref": "#/components/schemas/NullableString"}} + }, + "AudioContentDetailResponse": { + "type": "object", + "additionalProperties": false, + "required": ["contentId", "title", "detail", "languageCode", "coverImageUrl", "contentUrl", "themeStr", "tag", "price", "duration", "releaseDate", "totalContentCount", "remainingContentCount", "orderSequence", "isActivePreview", "isAdult", "isMosaic", "isOnlyRental", "existOrdered", "purchaseOption", "orderType", "remainingTime", "creatorOtherContentList", "sameThemeOtherContentList", "isCommentAvailable", "isLike", "likeCount", "commentList", "commentCount", "isPin", "isAvailablePin", "creator", "previousContent", "nextContent", "buyerList", "isAvailableUsePoint", "translated"], + "properties": { + "contentId": {"type": "integer", "format": "int64"}, + "title": {"type": "string"}, + "detail": {"type": "string"}, + "languageCode": {"$ref": "#/components/schemas/NullableString"}, + "coverImageUrl": {"type": "string"}, + "contentUrl": {"type": "string"}, + "themeStr": {"type": "string"}, + "tag": {"type": "string"}, + "price": {"type": "integer", "format": "int32"}, + "duration": {"type": "string"}, + "releaseDate": {"$ref": "#/components/schemas/NullableString"}, + "totalContentCount": {"$ref": "#/components/schemas/NullableInt32"}, + "remainingContentCount": {"$ref": "#/components/schemas/NullableInt32"}, + "orderSequence": {"$ref": "#/components/schemas/NullableInt32"}, + "isActivePreview": {"type": "boolean"}, + "isAdult": {"type": "boolean"}, + "isMosaic": {"type": "boolean"}, + "isOnlyRental": {"type": "boolean"}, + "existOrdered": {"type": "boolean"}, + "purchaseOption": {"$ref": "#/components/schemas/PurchaseOption"}, + "orderType": {"oneOf": [{"$ref": "#/components/schemas/OrderType"}, {"type": "null"}]}, + "remainingTime": {"$ref": "#/components/schemas/NullableString"}, + "creatorOtherContentList": {"type": "array", "items": {"$ref": "#/components/schemas/OtherContentResponse"}}, + "sameThemeOtherContentList": {"type": "array", "items": {"$ref": "#/components/schemas/OtherContentResponse"}}, + "isCommentAvailable": {"type": "boolean"}, + "isLike": {"type": "boolean"}, + "likeCount": {"type": "integer", "format": "int32"}, + "commentList": {"type": "array", "items": {"$ref": "#/components/schemas/AudioContentComment"}}, + "commentCount": {"type": "integer", "format": "int32"}, + "isPin": {"type": "boolean"}, + "isAvailablePin": {"type": "boolean"}, + "creator": {"$ref": "#/components/schemas/AudioContentCreator"}, + "previousContent": {"oneOf": [{"$ref": "#/components/schemas/OtherContentResponse"}, {"type": "null"}]}, + "nextContent": {"oneOf": [{"$ref": "#/components/schemas/OtherContentResponse"}, {"type": "null"}]}, + "buyerList": {"type": "array", "items": {"$ref": "#/components/schemas/ContentBuyer"}}, + "isAvailableUsePoint": {"type": "boolean"}, + "translated": {"oneOf": [{"$ref": "#/components/schemas/TranslatedContent"}, {"type": "null"}]} + } + }, + "AudioContentDetailApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/AudioContentDetailResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + + "SeriesCreateRequest": { + "type": "object", + "additionalProperties": false, + "required": ["title", "introduction", "publishedDaysOfWeek", "keyword"], + "properties": { + "title": {"type": "string"}, + "introduction": {"type": "string"}, + "publishedDaysOfWeek": {"type": "array", "uniqueItems": true, "items": {"$ref": "#/components/schemas/SeriesPublishedDaysOfWeek"}}, + "keyword": {"type": "string"}, + "genreId": {"type": "integer", "format": "int64", "default": 0, "description": "0은 binding 기본값이며 domain validation에서 유효하지 않다."}, + "isAdult": {"type": "boolean", "default": false}, + "writer": {"$ref": "#/components/schemas/NullableString"}, + "studio": {"$ref": "#/components/schemas/NullableString"} + } + }, + "SeriesUpdateRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": {"$ref": "#/components/schemas/NullableString"}, + "introduction": {"$ref": "#/components/schemas/NullableString"}, + "publishedDaysOfWeek": {"type": ["array", "null"], "uniqueItems": true, "items": {"$ref": "#/components/schemas/SeriesPublishedDaysOfWeek"}}, + "genreId": {"$ref": "#/components/schemas/NullableInt64"}, + "isAdult": {"$ref": "#/components/schemas/NullableBoolean"}, + "state": {"oneOf": [{"$ref": "#/components/schemas/SeriesState"}, {"type": "null"}]}, + "isActive": {"$ref": "#/components/schemas/NullableBoolean"}, + "writer": {"$ref": "#/components/schemas/NullableString"}, + "studio": {"$ref": "#/components/schemas/NullableString"} + } + }, + "SeriesCreateMultipart": { + "type": "object", + "additionalProperties": false, + "required": ["image", "request"], + "properties": {"image": {"$ref": "#/components/schemas/BinaryFile"}, "request": {"$ref": "#/components/schemas/SeriesCreateRequest"}} + }, + "SeriesUpdateMultipart": { + "type": "object", + "additionalProperties": false, + "required": ["request"], + "properties": {"image": {"$ref": "#/components/schemas/BinaryFile"}, "request": {"$ref": "#/components/schemas/SeriesUpdateRequest"}} + }, + "SeriesListItem": { + "type": "object", + "additionalProperties": false, + "required": ["seriesId", "title", "introduction", "coverImageUrl", "publishedDaysOfWeek", "genreId", "isAdult", "state", "isActive", "writer", "studio"], + "properties": { + "seriesId": {"type": "integer", "format": "int64"}, + "title": {"type": "string"}, + "introduction": {"type": "string"}, + "coverImageUrl": {"type": "string"}, + "publishedDaysOfWeek": {"type": "array", "items": {"$ref": "#/components/schemas/SeriesPublishedDaysOfWeek"}}, + "genreId": {"type": "integer", "format": "int64"}, + "isAdult": {"type": "boolean"}, + "state": {"$ref": "#/components/schemas/SeriesState"}, + "isActive": {"type": "boolean"}, + "writer": {"$ref": "#/components/schemas/NullableString"}, + "studio": {"$ref": "#/components/schemas/NullableString"} + } + }, + "SeriesListResponse": { + "type": "object", + "additionalProperties": false, + "required": ["totalCount", "items"], + "properties": {"totalCount": {"type": "integer", "format": "int32"}, "items": {"type": "array", "items": {"$ref": "#/components/schemas/SeriesListItem"}}} + }, + "SeriesDetailResponse": { + "type": "object", + "additionalProperties": false, + "required": ["seriesId", "title", "introduction", "coverImageUrl", "publishedDaysOfWeek", "genre", "keywords", "isAdult", "state", "writer", "studio"], + "properties": { + "seriesId": {"type": "integer", "format": "int64"}, + "title": {"type": "string"}, + "introduction": {"type": "string"}, + "coverImageUrl": {"type": "string"}, + "publishedDaysOfWeek": {"type": "string", "description": "예: 월, 수"}, + "genre": {"type": "string"}, + "keywords": {"type": "string"}, + "isAdult": {"type": "boolean"}, + "state": {"type": "string", "description": "레거시 String 필드. 현재 관찰 값: 연재중, 휴재중, 완결"}, + "writer": {"$ref": "#/components/schemas/NullableString"}, + "studio": {"$ref": "#/components/schemas/NullableString"} + } + }, + "SeriesContentListItem": { + "type": "object", + "additionalProperties": false, + "required": ["contentId", "coverImage", "title", "isAdult"], + "properties": {"contentId": {"type": "integer", "format": "int64"}, "coverImage": {"type": "string"}, "title": {"type": "string"}, "isAdult": {"type": "boolean"}} + }, + "SeriesContentListResponse": { + "type": "object", + "additionalProperties": false, + "required": ["totalCount", "items"], + "properties": {"totalCount": {"type": "integer", "format": "int32"}, "items": {"type": "array", "items": {"$ref": "#/components/schemas/SeriesContentListItem"}}} + }, + "SeriesContentSearchItem": { + "type": "object", + "additionalProperties": false, + "required": ["contentId", "title", "coverImage"], + "properties": {"contentId": {"type": "integer", "format": "int64"}, "title": {"type": "string"}, "coverImage": {"type": "string"}} + }, + "SeriesContentAddRequest": { + "type": "object", + "additionalProperties": false, + "required": ["contentIdList"], + "properties": {"contentIdList": {"type": "array", "items": {"type": "integer", "format": "int64"}}} + }, + "SeriesOrderUpdateRequest": { + "type": "object", + "additionalProperties": false, + "required": ["ids"], + "properties": {"ids": {"type": "array", "items": {"type": "integer", "format": "int64"}}} + }, + "SeriesListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/SeriesListResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + "SeriesDetailApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/SeriesDetailResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + "SeriesContentListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/SeriesContentListResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + "SeriesContentSearchApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"type": "array", "items": {"$ref": "#/components/schemas/SeriesContentSearchItem"}}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + + "CommunityPostComment": { + "type": "object", + "additionalProperties": false, + "required": ["id", "writerId", "nickname", "profileUrl", "comment", "isSecret", "date", "replyCount"], + "properties": { + "id": {"type": "integer", "format": "int64"}, + "writerId": {"type": "integer", "format": "int64"}, + "nickname": {"type": "string"}, + "profileUrl": {"type": "string"}, + "comment": {"type": "string"}, + "isSecret": {"type": "boolean"}, + "date": {"type": "string"}, + "replyCount": {"type": "integer", "format": "int32"} + } + }, + "CommunityPostListItem": { + "type": "object", + "additionalProperties": false, + "required": ["postId", "creatorId", "creatorNickname", "creatorProfileUrl", "imageUrl", "audioUrl", "content", "price", "date", "dateUtc", "isCommentAvailable", "isAdult", "isFixed", "isLike", "existOrdered", "likeCount", "commentCount", "firstComment"], + "properties": { + "postId": {"type": "integer", "format": "int64"}, + "creatorId": {"type": "integer", "format": "int64"}, + "creatorNickname": {"type": "string"}, + "creatorProfileUrl": {"type": "string"}, + "imageUrl": {"$ref": "#/components/schemas/NullableString"}, + "audioUrl": {"$ref": "#/components/schemas/NullableString"}, + "content": {"type": "string"}, + "price": {"type": "integer", "format": "int32"}, + "date": {"type": "string"}, + "dateUtc": {"type": "string"}, + "isCommentAvailable": {"type": "boolean"}, + "isAdult": {"type": "boolean"}, + "isFixed": {"type": "boolean"}, + "isLike": {"type": "boolean"}, + "existOrdered": {"type": "boolean"}, + "likeCount": {"type": "integer", "format": "int32"}, + "commentCount": {"type": "integer", "format": "int32"}, + "firstComment": {"oneOf": [{"$ref": "#/components/schemas/CommunityPostComment"}, {"type": "null"}]} + } + }, + "CommunityPostCreateRequest": { + "type": "object", + "additionalProperties": false, + "required": ["content", "isCommentAvailable", "isAdult"], + "properties": { + "content": {"type": "string"}, + "isCommentAvailable": {"type": "boolean"}, + "isAdult": {"type": "boolean"}, + "price": {"type": "integer", "format": "int32", "default": 0} + } + }, + "CommunityPostUpdateRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": {"$ref": "#/components/schemas/NullableString"}, + "isCommentAvailable": {"$ref": "#/components/schemas/NullableBoolean"}, + "isAdult": {"$ref": "#/components/schemas/NullableBoolean"}, + "isActive": {"$ref": "#/components/schemas/NullableBoolean"}, + "isFixed": {"type": "boolean", "description": "레거시 UpdateCommunityPostFixedRequest에서 ID만 path로 이동한 필드"} + } + }, + "CommunityPostCreateMultipart": { + "type": "object", + "additionalProperties": false, + "required": ["request"], + "properties": { + "audioFile": {"$ref": "#/components/schemas/BinaryFile"}, + "postImage": {"$ref": "#/components/schemas/BinaryFile"}, + "request": {"$ref": "#/components/schemas/CommunityPostCreateRequest"} + } + }, + "CommunityPostUpdateMultipart": { + "type": "object", + "additionalProperties": false, + "required": ["request"], + "properties": { + "postImage": {"$ref": "#/components/schemas/BinaryFile"}, + "request": {"$ref": "#/components/schemas/CommunityPostUpdateRequest"} + } + }, + "CommunityPostListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"type": "array", "items": {"$ref": "#/components/schemas/CommunityPostListItem"}}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + + "FanTalkCreatorReply": { + "type": "object", + "additionalProperties": false, + "required": ["fanTalkId", "writerId", "writerNickname", "writerProfileImageUrl", "content", "createdAtUtc"], + "properties": { + "fanTalkId": {"type": "integer", "format": "int64"}, + "writerId": {"type": "integer", "format": "int64"}, + "writerNickname": {"type": "string"}, + "writerProfileImageUrl": {"type": "string"}, + "content": {"type": "string"}, + "createdAtUtc": {"type": "string", "format": "date-time"} + } + }, + "FanTalkListItem": { + "type": "object", + "additionalProperties": false, + "required": ["fanTalkId", "writerId", "writerNickname", "writerProfileImageUrl", "content", "createdAtUtc", "creatorReplies"], + "properties": { + "fanTalkId": {"type": "integer", "format": "int64"}, + "writerId": {"type": "integer", "format": "int64"}, + "writerNickname": {"type": "string"}, + "writerProfileImageUrl": {"type": "string"}, + "content": {"type": "string"}, + "createdAtUtc": {"type": "string", "format": "date-time"}, + "creatorReplies": {"type": "array", "items": {"$ref": "#/components/schemas/FanTalkCreatorReply"}} + } + }, + "FanTalkListResponse": { + "type": "object", + "additionalProperties": false, + "required": ["fanTalkCount", "fanTalks", "page", "size", "hasNext"], + "properties": { + "fanTalkCount": {"type": "integer", "format": "int32"}, + "fanTalks": {"type": "array", "items": {"$ref": "#/components/schemas/FanTalkListItem"}}, + "page": {"type": "integer", "format": "int32"}, + "size": {"type": "integer", "format": "int32"}, + "hasNext": {"type": "boolean"} + } + }, + "FanTalkReplyCreateRequest": { + "type": "object", + "additionalProperties": false, + "required": ["content"], + "properties": {"content": {"type": "string"}} + }, + "FanTalkReplyResponse": { + "type": "object", + "additionalProperties": false, + "required": ["fanTalkId", "replyId", "creatorMemberId", "content", "createdAtUtc"], + "properties": { + "fanTalkId": {"type": "integer", "format": "int64"}, + "replyId": {"type": "integer", "format": "int64"}, + "creatorMemberId": {"type": "integer", "format": "int64"}, + "content": {"type": "string"}, + "createdAtUtc": {"type": "string", "format": "date-time"} + } + }, + "FanTalkListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/FanTalkListResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + "FanTalkReplyApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/FanTalkReplyResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]} + } + } +} diff --git a/docs/20260724_AI캐릭터_관리자_API/plan-task.md b/docs/20260724_AI캐릭터_관리자_API/plan-task.md index c133de8b..2e9b15ad 100644 --- a/docs/20260724_AI캐릭터_관리자_API/plan-task.md +++ b/docs/20260724_AI캐릭터_관리자_API/plan-task.md @@ -2,7 +2,8 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** `ADMIN`이 AI 캐릭터용 Member로 로그인하지 않고 `characterId` 기준으로 캐릭터, 콘텐츠, 시리즈, 커뮤니티, FanTalk 답변을 안전하게 대리 관리하는 신규 v2 관리자 API를 구현한다. +**Goal:** `ADMIN`이 AI 캐릭터용 Member로 로그인하지 않고 `characterId` 기준으로 캐릭터, 콘텐츠, 시리즈, 커뮤니티, +FanTalk 목록·답변을 안전하게 대리 관리하는 신규 v2 관리자 API를 구현한다. **Architecture:** 신규 외부 경계는 `/api/v2/admin/ai-characters` 하위 controller/facade/application에 둔다. 공통 target resolver가 `characterId -> ChatCharacter.creatorMember`를 해석하고 `CREATOR + AI_CHARACTER` 불변식과 ownership을 먼저 검증한 뒤, 각 domain vertical slice가 기존 entity/repository/S3/CloudFront/event 컴포넌트를 테스트로 고정해 선택적으로 재사용한다. @@ -13,20 +14,20 @@ | 상태 | 구현 중 | | 작성일 | 2026-07-24 | | 요구사항 기준 | `docs/20260724_AI캐릭터_관리자_API/prd.md` | -| API 기준 | 이 문서의 `Endpoint Contract Summary` | -| 현재 Phase | Phase 3 6차 리뷰 완료 | -| 현재 활성 Goal | `P4-T1` 대기 | +| API 기준 | `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` | +| 현재 Phase | Phase 2·3 레거시 계약 정합화 | +| 현재 활성 Goal | `P23-CONTRACT-2` 대기 | ## 현재 상태 | Phase | 상태 | 기존 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 | |---:|---|---:|---|---| | 1 | 완료 | 7/7 | 완료 | 없음 | -| 2 | 완료 | 11/11 | 완료 | 없음 | -| 3 | 완료 | 15/15 | 완료 | 없음 | -| 4 | 대기 | 0/6 | `P4-T1` | `P3-R5-GATE`, 사용자 진행 지시 | +| 2 | 후속 보완 대기 | 11/11 | `P23-CONTRACT-2` | 레거시 JSON 계약과 현재 v2 구현 정합화 | +| 3 | 후속 보완 대기 | 15/15 | `P23-CONTRACT-3` | `P23-CONTRACT-2` | +| 4 | 대기 | 0/6 | `P4-T1` | `P23-CONTRACT-GATE` | | 5 | 대기 | 0/6 | `P5-T1` | `P4-GATE` | -| 6 | 대기 | 0/4 | `P6-T1` | `P5-GATE` | +| 6 | 대기 | 0/5 | `P6-T1` | `P5-GATE` | | 7 | 대기 | 0/2 | `P7-T1` | Phase 1~6 Gate 완료 | - Phase는 결과와 의존성을 묶는 문서 단위다. `create_goal`에는 Task 또는 Phase Gate 하나만 등록한다. @@ -56,6 +57,15 @@ 415 `Accept` 표준 header를 유지한다. - 문서 작성 규칙: `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`, `docs/agent-guides/테스트스타일.md` - 기존 AI 캐릭터 연결 문서: `docs/20260611_AI캐릭터_크리에이터기능_최소연결/{prd.md,plan-task.md}` +- 2026-07-28 후속 확정 정책: 신규 endpoint의 JSON request/response는 레거시 필드명·타입·optional/nullable·기본값과 + 성공 `data` 형태를 그대로 유지한다. path로 이동한 ID만 body에서 제거한다. FanTalk 답변 작성만 계획의 축약 응답을 + 유지하고, FanTalk 목록은 공개 v2 `CreatorChannelFanTalkTabResponse` 필드 형태의 관리자 전용 endpoint로 제공한다. + 따라서 `DEC-P2-T5-001`의 캐릭터 `isActive=false` 단독 입력 제한은 계약 차원에서 폐기하고, 레거시처럼 다른 optional + field와 동시 입력을 허용하되 비활성화만 반영한다. +- 기계 검증 가능한 API 계약 원본: + `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` +- 계약 근거와 예외 설명: + `docs/20260724_AI캐릭터_관리자_API/api-contract.md` ## Endpoint Contract Summary @@ -67,7 +77,37 @@ `characterId`는 target resource endpoint의 외부 대상 식별자다. 캐릭터 목록/검색은 선택된 target이 없어 `characterId`를 받지 않고, 캐릭터 생성은 새 `ChatCharacter`를 만드는 endpoint라 path `characterId`를 받지 않는다. -모든 목록/검색 endpoint는 `page` 기본값 0, `size` 기본값 20, 최소 20, 최대 50 보정을 적용하고 경계값 테스트를 둔다. +목록/검색 endpoint의 query와 pagination은 `api-contract.openapi.json`에 명시한 각 레거시 API를 따른다. FanTalk 관리자 +목록만 공개 v2 query policy의 `page` 기본값 0, `size` 기본값 20, 최소 20, 최대 50 보정을 적용한다. + +정식 endpoint, request/response schema, 타입, required/optional/nullable, 기본값과 multipart encoding은 +`api-contract.openapi.json`만 기준으로 사용한다. 사람이 읽는 레거시 근거와 생성 방법은 `api-contract.md`를 참고한다. + +| Domain | operation | 구현 상태와 소유 Goal | +|---|---:|---| +| Character | 4 | runtime 정합화 필요, `P23-CONTRACT-2` | +| AudioContent | 5 | runtime 정합화 필요, `P23-CONTRACT-3` | +| Series | 9 | 구현 예정, Phase 4 | +| Community | 3 | 구현 예정, Phase 5 | +| FanTalk | 2 | 구현 예정, Phase 6 | + +- 신규 path에 포함된 `characterId`, `contentId`, `seriesId`, `postId`, `fanTalkId`만 레거시 request body에서 제거한다. +- 그 밖의 JSON 필드명·타입·optional/nullable·기본값과 성공 `data` 형태는 레거시 API를 유지한다. +- 레거시 mutation의 성공 `data`는 `null`이고 오디오 콘텐츠 생성만 `CreateAudioContentResponse(contentId)`를 반환한다. +- FanTalk 답변 작성만 승인된 축약 응답 `fanTalkId`, `replyId`, `creatorMemberId`, `content`, `createdAtUtc`를 사용한다. +- 캐릭터 수정의 `isActive=false`는 다른 optional JSON field와 함께 받을 수 있으며 레거시 의미대로 비활성화만 반영한다. +- multipart의 `request` part는 `application/json`이고 각 파일 part의 이름과 required 여부는 OpenAPI encoding을 따른다. +- 공통 오류는 400/401/403/404/405/406/415/500과 `ApiResponse.error`를 사용한다. 405의 `Allow`, 415의 `Accept`, + 미지원 `Accept-Language`의 KO fallback과 Spring CORS 정책 거부 403 예외를 유지한다. +- Character·AudioContent 9개 operation은 `P23-CONTRACT-GATE` 완료 전 production 호출 호환을 보장하지 않는다. + +--- + +## 과거 구현 계약 이력 (비규범) + +아래 축약 예시는 2026-07-28 레거시 계약 확정 전 Phase 2·3 구현과 계획 변경 이력을 보존하기 위한 자료다. +클라이언트 개발, 신규 구현, 테스트의 계약으로 사용하지 않으며 위 Endpoint Contract Summary와 +`api-contract.openapi.json`이 항상 우선한다. ```json { @@ -652,6 +692,8 @@ Response `data`: --- +## 구현 Phase + ### Phase 1: 공통 ADMIN 인증과 AI 캐릭터 target resolver 기반 #### 공통 Task 실행 규칙 @@ -956,11 +998,14 @@ AI 캐릭터 목록/검색/상세/생성/수정/비활성화를 신규 ADMIN v2 - 기존 `ChatCharacterService`, `ChatCharacterCreatorMemberService`, image/S3/event 관련 컴포넌트 동작을 특성화해야 한다. #### API endpoint와 request/response contract -- `GET /api/v2/admin/ai-characters?search=&page=&size=` -> `AiCharacterAdminListResponse(totalCount, items, page, size, hasNext)` -- `GET /api/v2/admin/ai-characters/{characterId}` -> `AiCharacterAdminDetailResponse` -- `POST /api/v2/admin/ai-characters` multipart `image?`, `request: CreateAiCharacterAdminRequest` -> detail -- `PUT /api/v2/admin/ai-characters/{characterId}` multipart `image?`, `request: UpdateAiCharacterAdminRequest` -> detail -- `UpdateAiCharacterAdminRequest.isActive=false`는 soft delete 의미다. +- 정식 schema는 `api-contract.openapi.json`의 Character operation을 따른다. +- `GET /api/v2/admin/ai-characters?searchTerm=&page=&size=` -> + `ChatCharacterListPageResponse(totalCount, content)` 또는 동일 필드의 검색 response. +- `GET /api/v2/admin/ai-characters/{characterId}` -> nested 필드 전체를 포함한 `ChatCharacterDetailResponse`. +- `POST /api/v2/admin/ai-characters` multipart 필수 `image`, 필수 `request: ChatCharacterRegisterRequest` -> `data: null`. +- `PUT /api/v2/admin/ai-characters/{characterId}` multipart optional `image`, 필수 + `request: ChatCharacterUpdateRequest`에서 `id` 제외 -> `data: null`. +- update request의 `isActive=false`는 레거시 soft delete 의미다. #### entity, repository, service 변경 - Entity: 변경 없음. @@ -1397,29 +1442,21 @@ git diff --check #### API endpoint와 request/response contract - `GET /api/v2/admin/ai-characters/audio-content-themes` - Request: query/body 없음. - - Response `data`: - - ```json - [ - { - "themeId": 11, - "themeName": "ASMR", - "imageUrl": "https://cdn.example.com/audio-content-theme/asmr.png" - } - ] - ``` - - - 기존 크리에이터 관리자 콘텐츠 등록 화면의 콘텐츠 테마(카테고리) 조회와 같은 기능이다. - - 기존 내부/legacy DTO의 `id`, `theme`, `image` 필드명은 frontend 계약으로 노출하지 않고, 신규 v2 DTO의 `themeId`, `themeName`, `imageUrl`만 사용한다. -- `GET /api/v2/admin/ai-characters/{characterId}/audio-contents` -- `GET /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` + - Response: `List`. +- `GET /api/v2/admin/ai-characters/{characterId}/audio-contents?search_word=&page=&size=` -> + `GetCreatorAdminContentListResponse` 전체 필드. +- `GET /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}?timezone=Asia/Seoul` -> + `GetAudioContentDetailResponse` 전체 nested 필드. - `POST /api/v2/admin/ai-characters/{characterId}/audio-contents` - - multipart `coverImage`, `audioFile`, `request` JSON string part를 사용한다. - - `request` JSON은 `title`, `description`, `tags`, `price`, `purchaseOption`, `limited`, `isAdult`, `isActive`, `themeId`, `releaseDateUtc?`, `seriesIds`, `isGeneratePreview`, `isOnlyRental`, `isPointAvailable`, `isCommentAvailable`, `isFullDetailVisible`, `previewStartTime`, `previewEndTime`, `languageCode`를 포함한다. - - legacy `CreateAudioContentRequest`의 `detail`은 v2 `description`, `releaseDate`는 UTC ISO-8601 `releaseDateUtc`로 받으며 facade에서 기존 pipeline 입력으로 변환한다. + - multipart 필수 `contentFile`, `coverImage`, `request: CreateAudioContentRequest`. + - Response: `CreateAudioContentResponse(contentId)`. - `PUT /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` -- `audioFile` 교체는 기존 creator/admin 수정 pipeline에 없는 동작이므로 Phase 3 범위에서는 제공하지 않는다. 오디오 파일 교체가 필요하면 별도 upload/processing parity 설계 후 추가한다. -- response item은 현 v2 목록 계약을 유지한다. response detail에는 기존 `GetAudioContentDetailResponse`의 필드 전체를 포함하고, `description`, `audioSignedUrl`, `releaseDateUtc`, `seriesIds`, `createdAtUtc`, `updatedAtUtc` 같은 v2 관리자 필드도 유지한다. 구매·좋아요·핀·추천·댓글 목록처럼 viewer 상태가 필요한 legacy 상세 필드는 관리자 상세에서 안전한 기본값을 반환한다. + - multipart optional `coverImage`, 필수 `request: UpdateCreatorAdminContentRequest`에서 `id` 제외. + - Response: `data: null`. +- 수정 `audioFile` 교체는 레거시 creator admin 수정 pipeline에 없어 제공하지 않는다. +- 정식 전체 schema와 optional/nullable은 `api-contract.openapi.json`의 AudioContent operation을 따른다. 현재 구현의 + `description`, `releaseDateUtc`, `audioSignedUrl`, `status`, `seriesIds`, `themeName`, `imageUrl` 별칭은 + `P23-CONTRACT-3`에서 레거시 계약으로 정합화한다. #### entity, repository, service 변경 - Entity: 변경 없음. @@ -1964,6 +2001,135 @@ git diff --check --- +### Phase 2·3 후속: 레거시 JSON 계약 정합화 + +기존 Phase 2·3 완료 이력은 보존한다. 2026-07-28 확정된 레거시 필드명·전체 payload 이관 정책에 따라 문서 계약을 먼저 +고정하고, 현재 구현된 캐릭터·오디오 콘텐츠 v2 DTO와 endpoint를 별도 후속 Goal에서 정합화한다. + +- [x] **Task 3.16: 전체 API OpenAPI 계약 고정** + +**Goal 실행 `P23-CONTRACT-1`:** 23개 endpoint의 request/response를 레거시 DTO 전체 필드와 직접 대조해 OpenAPI 3.1 +JSON과 설명 문서로 고정한다. + +- **시작 조건:** 사용자 확정 정책과 시리즈 미연결 콘텐츠 검색 endpoint 분리 결정. +- **완료 증거:** JSON 문법·OpenAPI lint/validate·TypeScript client 생성 및 `tsc --noEmit` 성공, 23개 operation과 누락 `$ref` 0건, + `./gradlew tasks --all` 성공 및 검증 기록. +- **범위 밖:** production DTO/controller/test 수정, legacy/public endpoint 변경. +- **TDD 예외 사유:** 실행 코드를 변경하지 않는 계약 문서 작성 Task다. +- **대체 검증 방법:** 레거시 Kotlin DTO의 생성자 필드와 OpenAPI schema를 대조하고 두 validator와 client generator로 + 기계 검증한다. + +**Files:** + +- Modify: `docs/20260724_AI캐릭터_관리자_API/prd.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/plan-task.md` +- Create: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` +- Create: `docs/20260724_AI캐릭터_관리자_API/api-contract.md` + +- [x] 캐릭터, 테마·오디오 콘텐츠, 시리즈·연결 콘텐츠 검색, 커뮤니티, FanTalk의 레거시 DTO 전체 필드를 schema로 + 옮긴다. +- [x] path로 이동한 ID만 body에서 제거하고 나머지 query/body/response 필드명은 레거시와 동일하게 유지한다. +- [x] 레거시 mutation의 `data: null`, 오디오 생성의 `data.contentId`, FanTalk 답변 축약 응답 예외를 operation별로 + 고정한다. +- [x] FanTalk 관리자 목록과 시리즈 미연결 콘텐츠 검색을 별도 operation으로 포함해 총 23개 endpoint를 검증한다. +- [x] OpenAPI lint/validate, TypeScript Fetch client 생성과 `tsc --noEmit`을 실행하고 결과를 기록한다. + +- [ ] **Task 3.17: Phase 2 캐릭터 runtime 계약 정합화** + +**Goal 실행 `P23-CONTRACT-2`:** 현재 구현된 캐릭터 4개 endpoint를 +`api-contract.openapi.json`의 레거시 필드명·전체 request/response·mutation 응답에 맞춘다. + +- **시작 조건:** `P23-CONTRACT-1` 완료. +- **완료 증거:** 4개 actual endpoint의 exact JSON field/required/nullable/multipart/`data: null` RED/GREEN, + Phase 2 focused·legacy 회귀와 Progress 기록. +- **범위 안:** `isActive=false`와 다른 optional JSON field의 동시 입력 허용 및 나머지 JSON field 미반영이라는 레거시 + request 의미 복구. +- **범위 밖:** 외부 API·ownership·soft delete persistence 결과 변경, 신규 character business behavior. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterController.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterDto.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterFacade.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterMapper.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerMutationTest.kt` +- Confirm: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/LegacyChatCharacterAdminCharacterizationTest.kt` + +- [ ] **RED:** 목록 `content`, 상세 전체 nested 필드, 생성 전체 request와 필수 `image`, 수정 optional 필드, + `isActive=false`와 다른 optional JSON field의 동시 입력·미반영, 생성·수정 `data: null` exact JSON 테스트를 작성해 + 현재 v2 축약/변환 DTO와의 불일치를 확인한다. +- [ ] **GREEN:** business pipeline을 바꾸지 않는 최소 DTO/controller/facade/mapper 변경으로 OpenAPI 계약을 통과시킨다. +- [ ] **REFACTOR:** Phase 2 actual endpoint와 legacy characterization, 공통 오류 계약을 회귀하고 결과를 기록한다. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +``` + +- [ ] **Task 3.18: Phase 3 오디오 콘텐츠 runtime 계약 정합화** + +**Goal 실행 `P23-CONTRACT-3`:** 현재 구현된 테마·오디오 콘텐츠 5개 endpoint를 +`api-contract.openapi.json`의 레거시 필드명·전체 request/response·성공 응답에 맞춘다. + +- **시작 조건:** `P23-CONTRACT-2` 완료. +- **완료 증거:** 5개 actual endpoint의 exact query/multipart/JSON schema RED/GREEN, Phase 3 focused·legacy 회귀와 + Progress 기록. +- **범위 밖:** upload/processing pipeline, signed URL 정책, series 연결 behavior 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentController.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentDto.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentMapper.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentQueryTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentThemeControllerTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentUpdateTest.kt` +- Confirm: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentOwnershipTest.kt` +- Confirm: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/LegacyCreatorAdminAudioContentCharacterizationTest.kt` + +- [ ] **RED:** 테마 `id/theme/image`, 목록 전체 item, 상세 전체 nested DTO, 생성 `contentFile`과 + `CreateAudioContentRequest`, 수정 `UpdateCreatorAdminContentRequest` 및 각 성공 `data` 형태를 exact JSON으로 고정한다. +- [ ] **GREEN:** 기존 business pipeline과 signed URL 계산을 유지하는 최소 DTO/controller/facade/mapper adapter를 구현한다. +- [ ] **REFACTOR:** Phase 3 actual endpoint·legacy characterization·ownership/error 계약을 회귀하고 결과를 기록한다. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +``` + +#### Phase 2·3 계약 정합화 Gate + +**Goal 실행 `P23-CONTRACT-GATE`:** 문서 계약과 구현된 9개 endpoint의 runtime 응답이 일치하고 Phase 4가 같은 계약을 +소비할 수 있는지 판정한다. + +- [ ] **`P23-CONTRACT-GATE` 완료:** `P23-CONTRACT-1`~`P23-CONTRACT-3` 완료 후 character/content focused·legacy + 회귀, OpenAPI validate/client 생성과 `ktlintCheck`를 fresh 실행한다. +- **범위 밖:** Gate에서 직접 production code 수정, Phase 4 이후 기능 구현. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*' \ + --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +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 +``` + +--- + ### Phase 4: 시리즈 관리 vertical slice #### 목표 @@ -1979,11 +2145,15 @@ git diff --check - 기존 creator series 생성/수정/soft delete, 콘텐츠 연결/해제, 조회/검색, 순서 변경 behavior를 통과하는 특성화 테스트로 먼저 고정해야 한다. #### API endpoint와 request/response contract +- 정식 전체 schema는 `api-contract.openapi.json`의 Series operation 9개를 따른다. - `GET/POST/PUT /api/v2/admin/ai-characters/{characterId}/series...` -- `GET /series/{seriesId}/contents` query: `search?`, `page`, `size` -- `POST /series/{seriesId}/contents` request: `AddAiCharacterAdminSeriesContentsRequest(contentIds: List)` +- `GET /series/{seriesId}/contents` query: `page`, `size`; response: + `GetCreatorAdminContentSeriesContentResponse(totalCount, items)` +- `GET /series/{seriesId}/contents/search` query: 필수 `search_word`; response: + `List` +- `POST /series/{seriesId}/contents` request: `AddingContentToTheSeriesRequest(contentIdList: List)` - `DELETE /series/{seriesId}/contents/{contentId}` -- `PUT /series/orders` request: `UpdateAiCharacterAdminSeriesOrdersRequest(seriesIds: List)` +- `PUT /series/orders` request: `UpdateOrdersRequest(ids: List)` #### entity, repository, service 변경 - Entity: 변경 없음. @@ -2028,7 +2198,7 @@ git diff --check **Goal 실행 `P4-T1`:** 기존 creator series의 CRUD·연결·검색·순서 동작을 신규 v2 구현의 비교 기준으로 고정한다. -- **시작 조건:** 최신 Phase 3 후속 Gate인 `P3-R5-GATE` 완료와 사용자 진행 지시. +- **시작 조건:** Phase 2·3 runtime 계약 정합화의 `P23-CONTRACT-GATE` 완료와 사용자 진행 지시. - **완료 증거:** production 변경 전 특성화 테스트 통과, 관찰된 오류·side-effect 정책과 Progress 기록. - **범위 밖:** 신규 v2 series production code 구현. @@ -2044,10 +2214,10 @@ git diff --check - [ ] **Task 4.2: 시리즈 목록·상세 조회 구현** -**Goal 실행 `P4-T2`:** target owner의 활성 시리즈 목록·상세를 pagination 계약으로 제공한다. +**Goal 실행 `P4-T2`:** target owner의 활성 시리즈 목록·상세를 레거시 response 계약으로 제공한다. - **시작 조건:** `P4-T1` 완료와 Phase 4 오류 계약의 계획 반영. -- **완료 증거:** 목록·상세·inactive·cross-owner·pagination RED/GREEN과 Progress 기록. +- **완료 증거:** 목록·상세 전체 필드, inactive·cross-owner·pagination RED/GREEN과 Progress 기록. - **범위 밖:** 시리즈 mutation, 콘텐츠 연결, 순서 변경. **Files:** @@ -2060,7 +2230,8 @@ git diff --check - [ ] 미구현 목록·상세, inactive 제외, cross-owner 거부와 pagination 경계 실패 test를 작성한다. - [ ] owner-scoped 목록·상세 최소 구현으로 focused test를 통과시킨다. -- [ ] `page` 기본 0, `size` 기본·최소 20·최대 50과 legacy DTO 비노출을 검증한다. +- [ ] 목록 `totalCount/items`와 item 전체 필드, 상세의 문자열 `publishedDaysOfWeek/genre/keywords/state`를 + `api-contract.openapi.json`과 exact JSON으로 검증한다. - [ ] focused test와 `ktlintCheck` 결과를 Progress에 기록한다. - [ ] **Task 4.3: 시리즈 생성·수정·soft delete 구현** @@ -2088,7 +2259,8 @@ git diff --check **Goal 실행 `P4-T4`:** 동일 owner의 시리즈와 콘텐츠만 검색·연결·해제할 수 있도록 한다. - **시작 조건:** `P4-T2`, `P4-T3`와 Phase 3 owner query 계약 완료. -- **완료 증거:** 검색·pagination·전체 ID 사전 검증·원자적 연결/해제 RED/GREEN과 Progress 기록. +- **완료 증거:** 연결 목록·미연결 검색의 분리된 응답, pagination·전체 ID 사전 검증·원자적 연결/해제 RED/GREEN과 + Progress 기록. - **범위 밖:** 콘텐츠 자체 수정, 시리즈 순서 변경. **Files:** @@ -2098,9 +2270,10 @@ git diff --check - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesContentTest.kt` -- [ ] 콘텐츠 조회·검색·연결·해제와 cross-owner ID 실패 test를 작성한다. +- [ ] `GET .../contents`와 `GET .../contents/search?search_word=...`의 별도 응답, 연결·해제와 cross-owner ID 실패 + test를 작성한다. - [ ] 모든 series/content ID를 mutation 전에 검증하는 최소 구현을 통과시킨다. -- [ ] 일부 연결 성공이 남지 않는 원자성과 pagination 경계를 검증한다. +- [ ] `contentIdList` 필드와 mutation `data: null`, 일부 연결 성공이 남지 않는 원자성과 pagination 경계를 검증한다. - [ ] focused/Phase 3 owner query 회귀와 `ktlintCheck` 결과를 Progress에 기록한다. - [ ] **Task 4.5: owner-scoped 시리즈 순서 변경 구현** @@ -2168,10 +2341,14 @@ git diff --check - 기존 community write behavior 특성화 테스트. #### API endpoint와 request/response contract -- `GET /api/v2/admin/ai-characters/{characterId}/community-posts` -- `POST /api/v2/admin/ai-characters/{characterId}/community-posts` -- `PUT /api/v2/admin/ai-characters/{characterId}/community-posts/{postId}` -- update request는 `isFixed`, `isActive`, 본문/이미지/오디오/가격 필드를 포함한다. +- 정식 전체 schema는 `api-contract.openapi.json`의 Community operation 3개를 따른다. +- `GET /api/v2/admin/ai-characters/{characterId}/community-posts?timezone=&page=&size=` -> + `List`. +- `POST /api/v2/admin/ai-characters/{characterId}/community-posts`는 optional `audioFile`, optional `postImage`, 필수 + `request: CreateCommunityPostRequest`를 받고 `data: null`을 반환한다. +- `PUT /api/v2/admin/ai-characters/{characterId}/community-posts/{postId}`는 optional `postImage`, 필수 request를 받는다. +- update request는 두 레거시 update DTO에서 ID를 제외한 `content`, `isCommentAvailable`, `isAdult`, `isActive`, + `isFixed`만 포함하고 `data: null`을 반환한다. 수정 `audioFile`, `price`는 레거시 계약에 없어 포함하지 않는다. #### entity, repository, service 변경 - Entity: 변경 없음. @@ -2340,13 +2517,15 @@ git diff --check --- -### Phase 6: FanTalk 답변 vertical slice +### Phase 6: FanTalk 목록·답변 vertical slice #### 목표 -선택한 AI 캐릭터가 자신의 활성 root FanTalk에만 creator reply를 작성하는 v2 관리자 API를 제공한다. +선택한 AI 캐릭터의 FanTalk 목록을 관리자 전용 경계에서 조회하고 자신의 활성 root FanTalk에만 creator reply를 작성하는 +v2 관리자 API를 제공한다. #### 범위와 비범위 -- 포함: root FanTalk 존재/활성/owner 검증, creator reply 저장, 언어 감지와 기존 응답 의미 parity. +- 포함: 관리자용 root FanTalk/creator reply 목록, root FanTalk 존재/활성/owner 검증, creator reply 저장, 언어 감지와 + 기존 응답 의미 parity. - 제외: FanTalk 원글 작성, nested reply, 구매/댓글형 기능, AI 캐릭터 일반 사용자 활동. #### 선행 Phase 및 의존성 @@ -2354,14 +2533,19 @@ git diff --check - 기존 FanTalk 저장 엔티티와 응답 DTO 의미 특성화. #### API endpoint와 request/response contract +- 정식 전체 schema는 `api-contract.openapi.json`의 FanTalk operation 2개를 따른다. +- `GET /api/v2/admin/ai-characters/{characterId}/fan-talks?page=0&size=20` +- Response: 공개 v2 `CreatorChannelFanTalkTabResponse`와 동일한 + `fanTalkCount`, `fanTalks`, `page`, `size`, `hasNext` 및 nested root/reply 필드 - `POST /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies` - Request: `CreateAiCharacterAdminFanTalkReplyRequest(content: String)` - Response: `AiCharacterAdminFanTalkReplyResponse(fanTalkId, replyId, creatorMemberId, content, createdAtUtc)` #### entity, repository, service 변경 - Entity: 변경 없음. -- Repository: `CreatorCheers` 또는 FanTalk repository에 root/active/creator owner 조회 adapter 추가 가능. -- Service: 신규 FanTalk reply application service 구현. target 검증 후 기존 저장/언어 감지 로직을 필요한 만큼 재사용한다. +- Repository: 관리자 목록용 owner-scoped root/reply 조회와 root/active/creator owner 검증 adapter를 추가한다. +- Service: 관리자 목록은 공개 v2 DTO 형태로 조립하되 viewer/block 필터를 적용하지 않는다. reply는 target 검증 후 기존 + 저장/언어 감지 로직을 필요한 만큼 재사용한다. #### DB migration - 없음. @@ -2376,23 +2560,24 @@ git diff --check #### acceptance criteria - target AI character는 자신의 활성 root FanTalk에만 답변할 수 있다. +- 관리자는 공개 v2와 동일한 필드 형태로 target AI character의 root FanTalk와 creator reply를 조회할 수 있다. - cross-character, nested parent, inactive/missing FanTalk는 4xx이며 reply 저장과 이벤트 발행이 없다. - 저장된 답변의 writer/creator는 해석된 creatorMember와 일관된다. #### targeted test - Characterization: `LegacyFanTalkReplyCharacterizationTest`. -- V2 RED/GREEN: `AiCharacterAdminFanTalkReplyServiceTest`. +- V2 RED/GREEN: `AiCharacterAdminFanTalkQueryTest`, `AiCharacterAdminFanTalkReplyServiceTest`. - Run: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.*'` #### 전체 회귀 테스트 영향 - 기존 FanTalk 조회/작성 관련 테스트가 통과해야 한다. #### rollback 전략 -- 신규 FanTalk reply v2 admin route/facade를 제거한다. +- 신규 FanTalk 목록·reply v2 admin route/facade를 제거한다. - 신규 DDL이 없으므로 schema rollback은 없다. #### 권장 commit 경계 -- `feat: add ai character admin fan talk reply slice` +- `feat: add ai character admin fan talk slice` - [ ] **Task 6.1: 기존 FanTalk reply 의미 특성화 baseline 고정** @@ -2412,11 +2597,32 @@ git diff --check - [ ] production code 변경 없이 기존 구현 대상 특성화 테스트 통과를 확인한다. - [ ] fixture만 정리하고 test와 `ktlintCheck` 결과를 Progress에 기록한다. -- [ ] **Task 6.2: FanTalk root reply 저장 구현** +- [ ] **Task 6.2: FanTalk 관리자 목록 조회 구현** -**Goal 실행 `P6-T2`:** 선택한 AI 캐릭터의 활성 root FanTalk에 creator reply를 저장하고 전용 응답을 반환한다. +**Goal 실행 `P6-T2`:** 선택한 AI 캐릭터의 root FanTalk와 creator reply를 공개 v2 응답 필드 형태로 조회한다. - **시작 조건:** `P6-T1` 완료와 Phase 6 오류 계약의 계획 반영. +- **완료 증거:** owner-scoped 목록·nested reply·pagination exact JSON RED/GREEN과 Progress 기록. +- **범위 밖:** 공개 v2 endpoint 변경, viewer/block 필터 재사용, FanTalk 원글 작성. + +**Files:** + +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkController.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkDto.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkFacade.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkRepository.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkQueryTest.kt` + +- [ ] **RED:** `fanTalkCount/fanTalks/page/size/hasNext`와 root/reply 전체 필드, target owner, pagination 실패 + test를 작성한다. +- [ ] **GREEN:** 공개 v2 DTO 필드 형태를 유지하면서 관리자 target 정책으로 조회하는 최소 구현을 통과시킨다. +- [ ] **REFACTOR:** 공개 v2 endpoint 계약 불변, viewer/block 필터 비적용과 ADMIN 인가를 회귀하고 기록한다. + +- [ ] **Task 6.3: FanTalk root reply 저장 구현** + +**Goal 실행 `P6-T3`:** 선택한 AI 캐릭터의 활성 root FanTalk에 creator reply를 저장하고 전용 응답을 반환한다. + +- **시작 조건:** `P6-T1`, `P6-T2` 완료와 Phase 6 오류 계약의 계획 반영. - **완료 증거:** 정상 저장·언어 감지·DTO·writer/creator RED/GREEN과 Progress 기록. - **범위 밖:** FanTalk 원글, nested reply, 일반 사용자 대리 작성. @@ -2433,11 +2639,11 @@ git diff --check - [ ] writer/creator가 resolver의 creatorMember와 일치하고 principal impersonation이 없음을 검증한다. - [ ] focused/legacy characterization test와 `ktlintCheck` 결과를 Progress에 기록한다. -- [ ] **Task 6.3: FanTalk target·root·ownership 거부 구현** +- [ ] **Task 6.4: FanTalk target·root·ownership 거부 구현** -**Goal 실행 `P6-T3`:** cross-character, nested, inactive, missing FanTalk를 저장·이벤트 없이 거부한다. +**Goal 실행 `P6-T4`:** cross-character, nested, inactive, missing FanTalk를 저장·이벤트 없이 거부한다. -- **시작 조건:** `P6-T1`, `P6-T2` 완료. +- **시작 조건:** `P6-T1`~`P6-T3` 완료. - **완료 증거:** 네 거부 분기의 정확한 오류 계약, DB/event 0건과 Progress 기록. - **범위 밖:** 새로운 중복 답변 차단 정책. @@ -2452,11 +2658,11 @@ git diff --check - [ ] 각 실패의 정확한 status/message key/KO·EN·JA와 reply insert/event 0건을 검증한다. - [ ] focused test와 `ktlintCheck` 결과를 Progress에 기록한다. -- [ ] **Task 6.4: Phase 6 보안·오류·회귀 검증** +- [ ] **Task 6.5: Phase 6 보안·오류·회귀 검증** -**Goal 실행 `P6-T4`:** FanTalk reply endpoint의 ADMIN·오류 계약과 기존 FanTalk 회귀를 고정한다. +**Goal 실행 `P6-T5`:** FanTalk 목록·reply endpoint의 ADMIN·오류 계약과 기존 FanTalk 회귀를 고정한다. -- **시작 조건:** `P6-T2`, `P6-T3` 완료. +- **시작 조건:** `P6-T2`~`P6-T4` 완료. - **완료 증거:** 권한 매트릭스, request binding/domain 오류, legacy 회귀와 Progress 기록. - **범위 밖:** Phase 7 외 전체 기능 수정. @@ -2472,11 +2678,11 @@ git diff --check #### Phase 6 Gate -**Goal 실행 `P6-GATE`:** Phase 6 FanTalk reply의 root·ownership·저장·회귀 품질을 최종 판정한다. +**Goal 실행 `P6-GATE`:** Phase 6 FanTalk 목록·reply의 조회·root·ownership·저장·회귀 품질을 최종 판정한다. - [ ] **`P6-GATE` 완료:** 시작 조건과 완료 증거를 모두 충족하고 현재 상태표와 Progress를 갱신한다. -- **시작 조건:** `P6-T1`~`P6-T4` 완료. +- **시작 조건:** `P6-T1`~`P6-T5` 완료. - **완료 증거:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.*'`와 `./gradlew ktlintCheck` 성공, Progress 기록. - **범위 밖:** 실패와 무관한 신규 기능. @@ -2496,7 +2702,7 @@ git diff --check - Phase 1~6 완료. #### API endpoint와 request/response contract -- Endpoint Contract Summary의 모든 endpoint가 구현되어야 한다. +- `api-contract.openapi.json`의 23개 operation이 모두 구현되어야 한다. - 모든 신규 endpoint가 JWT ADMIN + 현재 DB ADMIN 이중 인가와 공통 오류 envelope/i18n을 공유해야 한다. - legacy/public endpoint URI와 성공·오류 status/body/message diff가 없어야 한다. @@ -2614,10 +2820,11 @@ git diff --check | 12 | `P3-R5` → `P3-R6` → `P3-R4-GATE` | `P2-R4-GATE`, 5차 Phase 3 review | 아니요 | `REV-016`~`REV-017` 소유 Task에서 수정·증거 보강 | | 13 | `P2-R5` → `P2-R5-GATE` | 6차 Phase 2 review | 아니요 | `REV-018` 문서 계약 동기화 | | 14 | `P3-R7` → `P3-R8` → `P3-R5-GATE` | `P2-R5-GATE`, 6차 Phase 3 review | 아니요 | `REV-019`~`REV-020` 소유 Task에서 수정·증거 보강 | -| 15 | `P4-T1`~`P4-T6` → `P4-GATE` | `P3-R5-GATE`, 사용자 진행 지시 | 아니요 | 실패 소유 Task로 되돌림 | -| 16 | `P5-T1`~`P5-T6` → `P5-GATE` | `P4-GATE` | 아니요 | 실패 소유 Task로 되돌림 | -| 17 | `P6-T1`~`P6-T4` → `P6-GATE` | `P5-GATE` | 아니요 | 실패 소유 Task로 되돌림 | -| 18 | `P7-T1` → `P7-T2` → `P7-GATE` | Phase 1~6 최신 Gate | 아니요 | 실패 소유 Phase에 회귀 수정 Goal 추가 | +| 15 | `P23-CONTRACT-1` → `P23-CONTRACT-2` → `P23-CONTRACT-3` → `P23-CONTRACT-GATE` | `P3-R5-GATE`, 사용자 계약 확정 | 아니요 | 문서 또는 runtime 불일치 소유 Goal에서 수정 | +| 16 | `P4-T1`~`P4-T6` → `P4-GATE` | `P23-CONTRACT-GATE` | 아니요 | 실패 소유 Task로 되돌림 | +| 17 | `P5-T1`~`P5-T6` → `P5-GATE` | `P4-GATE` | 아니요 | 실패 소유 Task로 되돌림 | +| 18 | `P6-T1`~`P6-T5` → `P6-GATE` | `P5-GATE` | 아니요 | 실패 소유 Task로 되돌림 | +| 19 | `P7-T1` → `P7-T2` → `P7-GATE` | Phase 1~6 최신 Gate | 아니요 | 실패 소유 Phase에 회귀 수정 Goal 추가 | ## 변경 금지·중단 규칙 @@ -2842,6 +3049,38 @@ git diff --check 불일치를 확인해 `처리 완료`로 동기화했다. - 남은 항목: 없음. 다음 Goal은 `P4-T1`이지만 사용자 진행 지시 전까지 시작하지 않는다. +### `P23-CONTRACT-1` 완료 — 2026-07-28 + +- 상태: 완료 +- 무엇을: 캐릭터, 테마·오디오 콘텐츠, 시리즈, 커뮤니티, FanTalk의 신규 관리자 API 23개 request/response를 + OpenAPI 3.1 JSON과 설명 문서로 고정했다. +- 왜: 신규 path와 관리자 target만 적용하고 클라이언트 JSON 계약은 레거시 필드명·타입·optional/nullable·기본값과 성공 + `data` 형태를 그대로 이관해야 하기 때문이다. +- 어떻게: 레거시 Kotlin DTO·controller·service와 schema를 대조하고 path ID만 body에서 제거했다. FanTalk 답변 축약 응답, + 관리자 FanTalk 목록, 분리된 시리즈 미연결 콘텐츠 검색만 확정 예외로 반영했다. 독립 리뷰에서 확인한 + `Accept-Language` fallback, 캐릭터 비활성화 혼합 입력, 시리즈 상세 문자열 `state`, Phase 4 선행 Gate 문제를 교정했다. +- 결과: JSON parse, 내부 `$ref` 누락 0건, 고유 operation 23개와 상태 9/14를 확인했다. Redocly lint와 OpenAPI Generator + validate가 통과했고 `typescript-fetch` 생성 후 TypeScript 5.9.3 `tsc --noEmit`도 성공했다. + `./gradlew tasks --all`은 `BUILD SUCCESSFUL in 898ms`였으며 독립 최종 리뷰는 Critical 0, Important 0이었다. +- 남은 항목: 현재 구현된 캐릭터 4개 endpoint의 runtime DTO를 맞추는 `P23-CONTRACT-2`. production code는 변경하지 않았다. + +### `P23-CONTRACT-2`~`P7-GATE` 실행 계획 보완 — 2026-07-28 + +- 상태: 구현 시작 준비 완료 +- 무엇을: `P23-CONTRACT-2`·`P23-CONTRACT-3`의 실제 facade와 전용 query/theme/ownership/legacy test 범위를 보강하고, + Task별 focused 명령을 추가했다. Phase 4~6은 각 OpenAPI tag를 정식 schema로, Phase 7은 OpenAPI 23개 operation을 + 최종 구현 기준으로 명시했다. +- 왜: 확정 계약이 있어도 실제 request parsing·response 조립을 담당하는 facade와 전용 테스트가 계획에서 누락되면 + 구현 중 범위가 다시 흔들릴 수 있기 때문이다. +- 어떻게: OpenAPI operation 23개와 Character 4, AudioContent 5, Series 9, Community 3, FanTalk 2 분류를 Phase/Goal과 + 대조하고 기존 source/test 파일 존재, Goal ID 중복, 선행 Gate와 PRD Open Questions를 확인했다. +- 결과: 과거 축약 예시는 비규범 이력으로 분리했고 현재 Endpoint Contract Summary는 OpenAPI와 같은 9/14 상태 및 예외만 + 제공한다. Task 3.17부터 `P7-GATE`까지 시작 조건·파일·RED/GREEN/REFACTOR·검증 명령이 연결됐으며 + `./gradlew tasks --all`은 `BUILD SUCCESSFUL in 922ms`였다. +- 제외: 생성 TypeScript의 `tsc` 실행은 클라이언트 build/CI 책임으로 두고 server runtime `P23-CONTRACT-GATE`에는 추가하지 + 않았다. `P23-CONTRACT-1`의 일회성 생성·컴파일 검증 기록은 유지한다. +- 다음 행동: production 변경 없이 문서 보완만 완료했다. 구현 시작 Goal은 `P23-CONTRACT-2`다. + ## Decision Log | 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 | @@ -2856,6 +3095,10 @@ git diff --check | 2026-07-28 | `DEC-REVIEW-005` | 확정 | 5차 보완과 완료 이력은 보존하고, 추가로 확인한 문서 계약·empty-file 경계·detached publisher 증거 문제를 `REV-018`~`REV-020`과 새 후속 Task/Gate로 추적한다. | fresh focused 216건과 lint는 통과했지만 Endpoint Contract Summary, `MultipartFile.isEmpty` 처리와 실제 service publisher field를 코드·test 단위로 대조해 세 문제가 재현됐다. | `P2-R5`~`P3-R5-GATE`, 두 6차 review | | 2026-07-28 | `DEC-P3-R7-001` | 확정 | 생성의 빈 `coverImage`·`audioFile`은 400 `common.error.invalid_request`로 거부한다. 수정의 빈 `coverImage`는 생략으로 정규화하고, 수정 `audioFile`은 미지원이므로 part가 존재하면 크기와 관계없이 400으로 거부한다. | non-null binding은 part 누락만 차단하며 빈 파일은 0-byte upload와 cover 교체를 유발할 수 있다. optional cover의 빈 part는 일반 multipart client의 생략 표현으로 안전하게 처리할 수 있지만 미지원 audio part는 존재 자체가 계약 위반이다. | `P3-R7`, `REV-019`, Endpoint Contract Summary | | 2026-07-28 | `DEC-REVIEW-006` | 확정 | Phase 2·3 6차 보완의 코드와 완료 이력은 유지하고 새 production Goal은 추가하지 않는다. 종결 Gate가 있는 `REV-001`~`REV-009`의 종합 표 상태만 `처리 완료`로 동기화한다. | fresh focused 130건이 모두 통과했고 6차 보완 범위의 추가 production 결함은 재현되지 않았지만, 하단 종합 표 상태가 각 Gate·Progress의 처리 완료 판정과 모순됐다. | `P2-GATE`, `P2-R2-GATE`, `P3-GATE`, 하단 발견된 문제 표 | +| 2026-07-28 | `DEC-API-CONTRACT-001` | 확정 | 신규 관리자 endpoint는 레거시 request/response의 필드명·타입·optional/nullable·기본값·성공 `data` 형태를 유지하고 path로 이동한 ID만 body에서 제거한다. FanTalk 답변은 축약 응답을 유지한다. | 신규 endpoint의 목적이 로그인 불가능한 AI 캐릭터를 관리자 경계로 대리 관리하는 것이며, 클라이언트 계약까지 임의로 재설계하는 범위가 아니기 때문이다. | PRD, `api-contract.openapi.json`, `P23-CONTRACT-1`~`P23-CONTRACT-GATE` | +| 2026-07-28 | `DEC-API-CONTRACT-002` | 확정 | FanTalk 목록은 공개 v2 field 형태를 유지하는 관리자 전용 endpoint로 추가하고, 시리즈 연결 목록과 미연결 검색은 응답 형태가 달라 별도 endpoint로 분리한다. | 공개 v2 직접 호출은 `creatorId`, viewer/block filter와 CORS 경계가 관리자 요구와 다르고, 시리즈 두 legacy API의 응답은 wrapper와 direct array로 서로 다르다. | Phase 4, Phase 6, `api-contract.openapi.json` | +| 2026-07-28 | `DEC-API-CONTRACT-003` | 확정 | `DEC-P2-T5-001`의 캐릭터 `isActive=false` 단독 입력 제한을 최신 JSON 계약에서 폐기한다. `ChatCharacterUpdateRequest`처럼 다른 optional field와 동시 입력을 허용하고, `isActive=false`이면 레거시 service와 같이 비활성화만 반영한다. | 사용자 확정 원칙은 path ID만 제거하고 레거시 request를 그대로 이관하는 것이다. 레거시 controller는 혼합 request를 받으며 service는 비활성화 분기에서 나머지 JSON field를 적용하지 않는다. | `api-contract.openapi.json`, `P23-CONTRACT-2` | +| 2026-07-28 | `DEC-API-CONTRACT-004` | 확정 | 생성된 TypeScript의 `tsc` 실행은 클라이언트 build/CI 책임으로 두고 server runtime `P23-CONTRACT-GATE` 완료 조건에는 추가하지 않는다. | 서버 Gate는 실제 HTTP runtime과 OpenAPI validate/client 생성 가능성을 판정하며, 언어별 client compile은 소비 클라이언트의 toolchain에서 검증해야 한다. 계약 작성 Task의 일회성 TypeScript 생성·컴파일 검증은 이미 완료됐다. | `P23-CONTRACT-GATE`, `api-contract.md` | ## 발견된 문제 @@ -2883,6 +3126,20 @@ git diff --check | `REV-020` | Low | 처리 완료 | Phase 3 ownership/domain test의 event no-interaction mock이 실제 `AudioContentService`·`CreatorAdminContentService` publisher field에 연결되지 않았다. | `P3-R8`, `P3-R5-GATE` | 실제 두 service proxy target의 publisher를 mock으로 교체·복원하고 identity/no-interaction을 단언했다. | ## 검증 기록 +- 실행 계획 동기화 검증(2026-07-28): OpenAPI 23개 고유 operation과 Character 4, AudioContent 5, Series 9, + Community 3, FanTalk 2 분류를 현재 Endpoint Contract Summary 및 `P23-CONTRACT-2`~Phase 7과 대조했다. Goal ID 중복은 + 없고 PRD Open Questions는 `없음`이며 Task 3.17·3.18에 추가한 production/test/characterization 파일의 존재를 확인했다. +- 실행 계획 명령 유효성(2026-07-28): `./gradlew tasks --all` 실행 결과 `test`, `ktlintCheck`, `tasks`가 존재했고 + `BUILD SUCCESSFUL in 922ms`였다. `git diff --check`, `git diff --cached --check`는 출력이 없었다. +- `P23-CONTRACT-1` OpenAPI 검증(2026-07-28): `jq empty`, 23개 고유 operation과 + `implemented-contract-alignment-required` 9개/`planned` 14개 assertion, 내부 `$ref` 해석을 실행해 모두 성공했다. + `npx --yes @redocly/cli lint --skip-rule info-license ...`는 `valid`, OpenAPI Generator `validate`는 + `No validation issues detected`였다. +- `P23-CONTRACT-1` 클라이언트 검증(2026-07-28): OpenAPI Generator 7.24.0 `typescript-fetch` 생성을 완료하고, + 생성된 23개 Raw operation(Character 4, AudioContent 5, Series 9, Community 3, FanTalk 2)을 확인했다. + `tsc --noEmit --target ES2020 --module commonjs --lib ES2020,DOM .../index.ts`는 TypeScript 5.9.3에서 성공했다. +- `P23-CONTRACT-1` 문서·리뷰 Gate(2026-07-28): `./gradlew tasks --all`은 `BUILD SUCCESSFUL in 898ms`였고, + 독립 레거시 DTO/controller/service 대조 및 생성물 재검증 결과 Critical 0, Important 0이었다. - Phase 2·3 6차 보완 재점검 focused 검증(2026-07-28): `./gradlew test --rerun-tasks --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentUpdateTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentControllerTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentOwnershipTest` 실행 결과 `BUILD SUCCESSFUL in 4m 19s`였다. 다섯 XML 합계 130건의 failure/error/skipped는 모두 0이었다. - Phase 2·3 6차 보완 재점검 문서 명령 유효성(2026-07-28): `./gradlew tasks --all` 실행 결과 `test`, `ktlintCheck`, `tasks`가 존재했고 `BUILD SUCCESSFUL in 858ms`였다. - Phase 2·3 6차 보완 재점검 판정(2026-07-28): `REV-018`~`REV-020`의 production/test 보완과 Gate 완료 증거가 일치했고 추가 production finding은 확정되지 않았다. 하단 종합 표에서만 미처리로 남은 `REV-001`~`REV-009`를 각 소유 Gate·Progress 판정에 맞춰 `처리 완료`로 동기화했다. diff --git a/docs/20260724_AI캐릭터_관리자_API/prd.md b/docs/20260724_AI캐릭터_관리자_API/prd.md index cc6754f1..db7c4ba6 100644 --- a/docs/20260724_AI캐릭터_관리자_API/prd.md +++ b/docs/20260724_AI캐릭터_관리자_API/prd.md @@ -19,7 +19,7 @@ - 모든 신규 target endpoint는 외부 대상 식별자로 `characterId`를 받고, 서버가 `ChatCharacter.creatorMember`를 내부 행위자로 해석한다. 단, 캐릭터 목록/검색은 아직 선택된 target이 없어 `characterId`를 받지 않고, 캐릭터 생성은 새 `ChatCharacter`를 만드는 endpoint라 path `characterId`를 받지 않는다. - target 해석 시 `ChatCharacter` 존재, `creatorMember` 존재, `creatorMember.role == CREATOR`, `creatorMember.memberKind == AI_CHARACTER`를 모두 검증한다. - 검증 실패 시 4xx로 거부하고 DB, S3, 외부 캐릭터 API, 이벤트 발행 등 후속 부작용을 만들지 않는다. -- 캐릭터, 오디오 콘텐츠, 시리즈, 커뮤니티 게시글, FanTalk 답변, 오디오 signed URL을 신규 관리자 API에서 관리한다. +- 캐릭터, 오디오 콘텐츠, 시리즈, 커뮤니티 게시글, FanTalk 목록·답변, 오디오 signed URL을 신규 관리자 API에서 관리한다. - 기존 legacy/public endpoint의 URI, 성공·오류 HTTP status, response body, message/i18n을 포함한 외부 계약은 변경하지 않는다. 단, 캐릭터 관리자 frontend가 기존 관리자 인증을 재사용할 수 있도록 `/admin/member/login`, `/member/logout`의 CORS 허용 Origin만 path-specific으로 확장한다. @@ -56,7 +56,7 @@ - 운영자는 선택한 AI 캐릭터 소유 오디오 콘텐츠를 조회, 생성, 수정, soft delete하고 관리자 화면에서 재생 가능한 signed URL을 받고 싶다. - 운영자는 선택한 AI 캐릭터 소유 시리즈를 조회, 생성, 수정, soft delete하고 콘텐츠 연결/해제/순서를 관리하고 싶다. - 운영자는 선택한 AI 캐릭터 소유 커뮤니티 게시글을 작성, 수정, 고정/해제, soft delete하고 싶다. -- 운영자는 선택한 AI 캐릭터가 자신의 활성 root FanTalk에 creator reply를 작성하게 하고 싶다. +- 운영자는 선택한 AI 캐릭터의 FanTalk 목록과 기존 creator reply를 확인한 뒤 활성 root FanTalk에 답변하고 싶다. - 서버는 다른 AI 캐릭터나 HUMAN creator의 resource ID가 전달되면 변경 없이 4xx로 거부해야 한다. --- @@ -95,7 +95,8 @@ #### Requirements - 캐릭터 소유 콘텐츠 목록/검색/상세 조회, 생성, 수정, 기존 삭제 동작에 따른 soft delete를 제공한다. -- 콘텐츠 생성 화면에서 사용할 활성 콘텐츠 테마(카테고리) 목록 조회를 제공한다. 기존 크리에이터 관리자 콘텐츠 등록 화면의 테마 조회와 같은 기능이며, 신규 v2 응답 필드는 `themeId`, `themeName`, `imageUrl`로 명확히 구분한다. +- 콘텐츠 생성 화면에서 사용할 활성 콘텐츠 테마(카테고리) 목록 조회를 제공한다. 기존 크리에이터 관리자 콘텐츠 등록 + 화면의 `GetAudioContentThemeResponse`와 같은 `id`, `theme`, `image` 필드명을 유지한다. - 기존 크리에이터 콘텐츠 관리의 검증, 파일 처리, content upload/processing pipeline, 가격, 공개/예약, 번역/알림 등 business behavior parity를 유지한다. - 관리자 화면 재생용 signed URL을 콘텐츠 목록/상세 응답에 제공한다. - signed URL은 공통 `AudioContentCloudFront`를 재사용하고 기존 크리에이터 관리자와 같은 만료 정책을 따른다. @@ -135,6 +136,9 @@ ### Feature F. FanTalk 답변 #### Requirements +- 선택한 AI 캐릭터의 FanTalk 목록과 각 root 글의 creator reply 목록을 관리자 전용 endpoint로 조회한다. +- 목록 응답 필드와 page 정책은 공개 v2 `CreatorChannelFanTalkTabResponse`를 유지하되, 공개 v2 endpoint를 직접 재사용하지 + 않고 `characterId` target 해석과 관리자 ownership 정책을 적용한다. - 선택한 AI 캐릭터가 자신의 활성 root FanTalk에 creator reply를 작성한다. - 요청은 `characterId`와 대상 root `fanTalkId`를 포함한다. - 대상 FanTalk가 존재하고 활성 상태이며, 대상 creator가 해석된 `creatorMember`와 일치하는 root 글인지 검증한다. @@ -181,12 +185,23 @@ - 이후 phase의 domain/client/server 오류는 각 task에서 정확한 HTTP status와 KO/EN/JA message key를 먼저 정의하고 같은 envelope를 적용한다. - 신규 prefix 전용 오류 처리는 legacy/public endpoint의 기존 성공·오류 응답에 적용하지 않는다. -- page 기반 조회는 기존 v2 탭 API 관례를 따라 `page` 기본값 0, `size` 기본값 20, 최소 20, 최대 50 보정을 기본안으로 하며, 경계값 보정은 구현 task와 테스트에 포함한다. +- request/response의 기계 검증 가능한 단일 기준은 같은 디렉터리의 `api-contract.openapi.json`이다. 설명과 레거시 근거는 + `api-contract.md`에 기록한다. +- 신규 endpoint는 레거시 API의 request/response 필드명, 타입, optional/nullable, 기본값과 성공 `data` 형태를 그대로 + 이관한다. `characterId`, `contentId`, `seriesId`, `postId`, `fanTalkId`처럼 신규 path로 이동한 ID만 request body에서 + 중복 제거한다. +- 캐릭터 수정은 레거시 `ChatCharacterUpdateRequest`처럼 `isActive=false`와 다른 optional field의 동시 입력을 허용하며, + 이 경우 레거시 service 의미대로 비활성화만 반영한다. +- 목록 endpoint의 query와 page 동작은 각 레거시 API를 따른다. FanTalk 관리자 목록만 공개 v2 탭의 `page` 기본값 0, + `size` 기본값 20, 최소 20, 최대 50 보정을 따른다. - multipart 생성/수정은 기존 admin/creator-admin 관례대로 파일 part와 `request` JSON string part를 사용한다. -- `GET /api/v2/admin/ai-characters/audio-content-themes`는 request body 없이 활성 콘텐츠 테마 목록을 반환한다. 성공 응답 `data`는 `[{ "themeId": 11, "themeName": "ASMR", "imageUrl": "https://cdn.example.com/audio-content-theme/asmr.png" }]` 형태이며, 기존 내부/legacy DTO의 `id`, `theme`, `image` 필드명을 외부 계약으로 노출하지 않는다. -- 오디오 콘텐츠 생성 `request` JSON에는 콘텐츠 테마 선택값인 `themeId`와 기존 `CreateAudioContentRequest`의 생성 필드 전체를 포함한다. v2는 `detail` 대신 `description`, `releaseDate` 대신 UTC ISO-8601 `releaseDateUtc`를 외부 계약으로 사용하고 legacy pipeline 호출 시 변환한다. -- 오디오 콘텐츠 목록 응답은 현 v2 관리자 목록 계약을 유지한다. 상세 응답은 기존 `GetAudioContentDetailResponse`의 필드 전체를 v2 상세 DTO에 포함하되, 구매·좋아요·핀·추천·댓글 목록처럼 viewer 상태가 필요한 필드는 관리자 상세에서 안전한 기본값을 반환한다. -- request/response DTO는 신규 v2 AI character admin API 전용 DTO로 두고 legacy/public DTO를 외부 계약으로 재노출하지 않는다. +- 시리즈 연결 콘텐츠 목록과 미연결 콘텐츠 검색은 응답 형태가 다르므로 각각 + `GET .../series/{seriesId}/contents`와 `GET .../series/{seriesId}/contents/search?search_word=...`로 분리한다. +- 레거시 mutation이 `ApiResponse.ok(null)`을 반환하면 신규 endpoint도 `data: null`을 반환한다. 오디오 콘텐츠 생성은 + 레거시 `CreateAudioContentResponse(contentId)`를 유지한다. FanTalk 답변 작성만 신규 계획의 축약 응답 + `fanTalkId`, `replyId`, `creatorMemberId`, `content`, `createdAtUtc`를 사용한다. +- request/response 구현 DTO는 신규 v2 AI character admin API 전용으로 둘 수 있지만, JSON 외부 계약은 + `api-contract.openapi.json`의 레거시 필드명과 형태를 유지한다. --- @@ -248,6 +263,8 @@ - 콘텐츠 생성/수정/soft delete와 signed URL 응답은 기존 creator/admin behavior parity를 유지한다. - 시리즈 CRUD, 콘텐츠 연결/해제/순서 변경은 target character 소유 범위를 벗어나지 않는다. - 커뮤니티 생성/수정/고정/해제/soft delete는 target creatorMember 소유 게시글에만 적용된다. +- FanTalk 관리자 목록은 target AI character의 root 글과 creator reply를 공개 v2 응답 필드명으로 반환하며, 공개 v2의 + viewer/block 필터나 creator 관리자 CORS 경계에 의존하지 않는다. - FanTalk 답변은 target AI character 자신의 활성 root FanTalk에만 저장된다. - 기존 legacy/public endpoint 테스트가 통과하고, 두 공유 인증 경로의 CORS 허용 Origin 확장 외 성공·오류 status/body/message를 포함한 request/response contract가 변경되지 않는다.