# AI 캐릭터 관리자 웹 정규화 API Contract ## 문서 정보 | 항목 | 내용 | |---|---| | 상태 | 인터뷰 보정 반영, 백엔드 제공 대기 계약 분리 | | 작성일 | 2026-07-25 | | 기준 | 사용자 제공 API Contract + 인터뷰 확정사항 | | 관련 요구사항 | [prd.md](./prd.md) | | 관련 구현 계획 | [plan-task.md](./plan-task.md) | 이 문서는 대화로 제공된 API Contract를 저장소에 영속적으로 보존하고 인터뷰에서 확정된 보정사항을 적용한 프론트엔드 기준 계약이다. 실제 백엔드 구현을 다른 저장소에서 추정하지 않는다. “백엔드 제공 대기”로 표시한 endpoint와 세부 규칙은 프론트엔드 Open Question이 아니며, 백엔드 계약이 제공되기 전에는 해당 network integration을 구현하지 않는다. goal 실행 시 계약 입력은 해당 Task의 `시작 조건`이 가리키는 이 문서 section을 사용한다. 제공 대기 계약을 추정해 goal을 완료하지 않으며, 계약이 새로 제공되거나 제외 결정이 나면 PRD 결정 기록 → 이 문서 → `plan-task.md` 순서로 갱신한다. ## 1. 공통 규칙 ### 1.1 성공 응답 ```json { "success": true, "message": null, "data": {} } ``` - 모든 성공 응답은 `ApiResponse.ok(...)` wrapper를 사용한다. - 인증 endpoint의 실제 성공 응답은 최상위 `errorProperty=null`도 포함한다. 프론트엔드 공통 type은 성공 응답의 `errorProperty`가 없거나 `null`인 두 형태를 수용한다. ### 1.2 오류 응답 ```json { "success": false, "message": "잘못된 요청입니다.", "data": null, "errorProperty": null } ``` - 모든 오류는 의미에 맞는 비2xx HTTP status와 `ApiResponse.error(...)` wrapper를 사용한다. - 오류를 2xx로 normalize하지 않는다. - `Accept-Language: ko|en|ja`에 따라 KO/EN/JA message를 반환한다. - header가 없거나 지원하지 않는 언어면 KO로 fallback한다. - security filter 단계도 MVC interceptor에 의존하지 않고 `Accept-Language` header를 직접 해석한다. - 이 관리자 웹은 모든 요청에 `Accept-Language: ko`를 보낸다. | 오류 | HTTP status | message key | |---|---:|---| | JWT 없음·잘못됨·만료·폐기 | 401 | `common.error.bad_credentials` | | JWT role 비ADMIN | 403 | `common.error.access_denied` | | JWT ADMIN + 현재 DB role 비ADMIN stale claim | 403 | `common.error.access_denied` | | request binding·target 미존재·creatorMember 누락·role/memberKind 불변식 위반 | 400 | `common.error.invalid_request` | | 신규 prefix 미매핑 경로 | 404 | `common.error.invalid_request` | | 지원하지 않는 HTTP method | 405 | `common.error.invalid_request` | | 지원하지 않는 media type | 415 | `common.error.invalid_request` | | 예상하지 못한 서버 오류 | 500 | `common.error.unknown` | Phase 2~6에서 추가되는 domain/client/server 오류는 구현 전에 정확한 비2xx status와 KO/EN/JA message key를 확정한다. 신규 prefix 전용 오류 처리는 legacy/public endpoint에 적용하지 않는다. ### 1.3 페이지 응답 모든 목록·검색 endpoint는 `page` 기본 0, `size` 기본 20, 최소 20, 최대 50 보정을 적용한다. ```ts type PageData = { totalCount: number page: number size: number hasNext: boolean items: T[] } ``` ### 1.4 인터뷰 공통 보정 - `characterId`는 선택된 target resource endpoint의 외부 대상 식별자다. - 캐릭터 목록·검색과 생성에는 path `characterId`가 없다. - `externalCharacterId`는 존재하지 않으므로 모든 request, response, UI에서 제거한다. - 생성 request에는 `isActive`를 보내지 않는다. 최초 상태는 백엔드가 결정한다. - 일반 수정 request에는 `isActive` key를 보내지 않는다. soft delete request에만 `isActive=false`를 보내며 `isActive=true`는 전송하지 않는다. - 비활성 resource의 복원과 hard delete는 현재 범위가 아니다. - multipart의 JSON part 이름은 `request`다. - optional 교체 파일을 보내지 않으면 기존 파일을 유지한다. 기존 media 자체를 제거하는 contract는 없다. - Character, Audio, Series, Community 목록 endpoint는 활성 resource만 반환하며 `isActive`, `activeStatus` 등 활성 상태 query를 받지 않는다. - Character, Audio, Series의 soft delete 성공 응답을 받으면 프론트엔드는 관련 active-only 목록 query를 무효화·재조회해 비활성화한 항목을 표시하지 않고 목록으로 이동해 성공 알림을 표시한다. Community는 열린 Sheet를 닫고 현재 active-only 목록을 갱신해 해당 항목을 제거한다. - 비활성 ID 상세 GET의 반환·거부 정책은 백엔드 책임이며 이 프론트엔드 계약에서 규정하거나 구현 Gate로 관리하지 않는다. 클라이언트는 성공 envelope가 오면 반환 데이터를 사용하고 비2xx이면 공통 오류 처리를 적용한다. - 오디오 media error는 signed URL 만료로 구분하거나 추정하지 않는다. 재생 오류만으로 목록·상세 API를 자동 재조회하거나 `play()`를 자동 재호출하지 않으며 일반 오류와 수동 재시도·페이지 새로고침 안내를 제공한다. - 커뮤니티 첨부 audio URL만을 갱신하기 위한 요청은 하지 않고 media error를 refetch trigger로 사용하지 않는다. 사용자 페이지 새로고침이나 mutation 후 cache 무효화 등 일반 목록 재조회에서는 새 응답의 URL을 사용한다. 전용 상세 조회나 URL 재발급 호출을 추가하지 않는다. ## 2. 공통 enum과 형식 ```ts type AudioContentStatus = "OPEN" | "SCHEDULED" type SeriesState = "PROCEEDING" | "SUSPEND" | "COMPLETE" type SeriesPublishedDay = | "SUN" | "MON" | "TUE" | "WED" | "THU" | "FRI" | "SAT" | "RANDOM" ``` - `OPEN`은 현재 출시된 오디오 콘텐츠다. - `SCHEDULED`는 미래 공개가 예약된 오디오 콘텐츠다. - Audio status는 백엔드가 선택해 반환하며 프론트엔드가 계산하지 않는다. - `RANDOM`은 다른 요일과 함께 보낼 수 없다. - published days는 RANDOM 단독 또는 하나 이상의 실제 요일 목록이다. - 모든 `*AtUtc`, `releaseDateUtc`는 ISO-8601 UTC `Z` 문자열이다. - 가격은 0 이상의 정수이며 단위는 “캔”이다. ### 2.1 이미지 업로드와 crop 모든 image part의 원본 파일 크기는 최대 `10,485,760 bytes`다. `10,485,760 bytes`는 허용하고 `10,485,761 bytes`부터 거부한다. 확장자 문자열만 신뢰하지 않고 실제 MIME을 함께 검증한다. | resource | 허용 확장자 | 허용 MIME | crop aspect ratio | 등록 결과 최대 가로 폭 | 결과 세로 | |---|---|---|---:|---:|---| | 캐릭터 | `.jpg`, `.jpeg`, `.png` | `image/jpeg`, `image/png` | `1:1` | 800px | 가로와 동일 | | 커뮤니티 JPEG/PNG | `.jpg`, `.jpeg`, `.png` | `image/jpeg`, `image/png` | 자유 | 800px | 선택한 crop ratio에 따라 결정 | | 커뮤니티 GIF | `.gif` | `image/gif` | crop 없음 | 800px | 원본 비율 유지 | | 시리즈 | `.jpg`, `.jpeg`, `.png` | `image/jpeg`, `image/png` | `210:297` | 1,000px | 비율에 따라 결정 | | 오디오 콘텐츠 cover | `.jpg`, `.jpeg`, `.png` | `image/jpeg`, `image/png` | `1:1` | 800px | 가로와 동일 | - WebP와 표에 없는 image 형식은 허용하지 않는다. - GIF는 커뮤니티 image에서만 허용하며 캐릭터, 시리즈, 오디오 콘텐츠 cover에서는 거부한다. - 캐릭터, 커뮤니티 JPEG/PNG, 시리즈, 오디오 콘텐츠에서 새 image를 선택하면 resource별 비율로 crop한 결과 File을 기존 multipart file part에 보낸다. crop 좌표나 비율을 별도 request field로 추가하지 않는다. - 커뮤니티 GIF는 crop Dialog를 열거나 canvas로 재처리하지 않는다. 원본 가로가 800px 이하인 경우에만 원본 비율·animation 유지 File을 기존 multipart `image` part에 보낸다. - 수정 화면에서 새 image를 선택하지 않거나 crop을 취소하면 해당 file part를 보내지 않으며 기존 image를 유지한다. - 서버는 파일 크기, 실제 MIME, 결과 pixel 크기와 고정 비율을 다시 검증해야 한다. - “가로 800/1,000”은 등록 결과의 최대 출력 폭이다. JPEG/PNG crop 결과에는 이 제한을 적용하되 선택된 원본 crop 영역보다 확대하지 않는다. - Series crop 결과의 세로 pixel은 `round(width × 297 ÷ 210)`으로 계산한다. 최대 폭에서는 1,000×1,414px이며 server 비율 검증은 계산된 세로값 기준 1px 이내 오차를 허용한다. - 커뮤니티 GIF의 원본 가로가 800px을 초과하면 client가 제출을 차단하고 server도 `400`과 `common.error.invalid_request` envelope로 거부한다. GIF를 축소·crop·재인코딩하지 않는다. ## 3. 인증 인증 endpoint는 AI 캐릭터 관리자 신규 prefix가 아니라 기존 admin/common member 경로를 사용한다. ### 3.1 Admin Login `POST /admin/member/login` Content-Type: `application/json` Authorization header: 없음 Request body: ```json { "email": "admin@test.com", "password": "password" } ``` Response: ```json { "success": true, "message": null, "data": { "token": "jwt-token", "role": "ADMIN" }, "errorProperty": null } ``` - 보호 route에 진입하려면 `data.role`이 `ADMIN`이어야 한다. - `data.token`은 보호 API의 Bearer token으로 사용한다. ### 3.2 Admin Logout 관리자 전용 로그아웃 endpoint는 없다. 공통 로그아웃 endpoint를 사용한다. `POST /member/logout` Headers: ```http Authorization: Bearer {jwt-token} ``` - Request body 없음 Response: ```json { "success": true, "message": null, "data": {}, "errorProperty": null } ``` ### 3.3 클라이언트 인증 규칙 - 로그인 요청에는 Authorization header를 보내지 않는다. - 로그인 이후 보호 API와 로그아웃에는 `Authorization: Bearer {jwt-token}`을 보낸다. - refresh token과 자동 갱신은 사용하지 않는다. - 401이면 인증 상태를 제거하고 로그인으로 이동한다. - 로그인 성공 시 `data.token`과 `data.role="ADMIN"`을 `sessionStorage`에 저장한다. - 앱 시작과 같은 탭의 새로고침에서는 `sessionStorage`의 token과 role을 읽어 session을 복원한다. 값이 없거나 role이 `ADMIN`이 아니면 보호 route에 진입시키지 않는다. - 로그아웃 요청은 현재 Bearer token으로 한 번 호출한다. 성공 여부와 관계없이 `sessionStorage`의 인증 정보를 제거하고 로그인 화면으로 이동한다. - 로그아웃이 네트워크 오류 또는 비2xx로 실패하면 서버 로그아웃 확인 실패 경고를 표시하고 제거한 session을 복원하지 않는다. - 401 처리 시에도 `sessionStorage`의 인증 정보를 제거하고 로그인 화면으로 이동한다. - 인증 정보를 `localStorage`, IndexedDB 또는 cookie로 복제하지 않는다. 탭 종료 시에는 `sessionStorage`의 브라우저 lifecycle을 따른다. ## 4. 캐릭터 ### 4.1 목록·검색 `GET /api/v2/admin/ai-characters?search=루나&page=0&size=20` - 활성 캐릭터만 반환한다. 활성 상태 filter/query는 없다. Query: ```ts { search?: string page?: number size?: number } ``` Response `data`: ```json { "totalCount": 1, "page": 0, "size": 20, "hasNext": false, "items": [ { "characterId": 101, "name": "루나", "description": "달빛을 좋아하는 AI 캐릭터", "imageUrl": "https://cdn.example.com/characters/luna.png", "creatorMemberId": 9001, "creatorNickname": "루나", "originalWorkId": 31, "isActive": true, "createdAtUtc": "2026-07-24T00:00:00Z" } ] } ``` ### 4.2 상세 `GET /api/v2/admin/ai-characters/{characterId}` Response `data`: ```json { "characterId": 101, "name": "루나", "description": "달빛을 좋아하는 AI 캐릭터", "imageUrl": "https://cdn.example.com/characters/luna.png", "creatorMemberId": 9001, "creatorNickname": "루나", "creatorProfileImageUrl": "https://cdn.example.com/characters/luna.png", "creatorIntroduce": "달빛을 좋아하는 AI 캐릭터", "originalWorkId": 31, "isActive": true, "createdAtUtc": "2026-07-24T00:00:00Z", "updatedAtUtc": "2026-07-24T00:00:00Z" } ``` ### 4.3 생성 `POST /api/v2/admin/ai-characters` Content-Type: `multipart/form-data` | Part | Content | 필수 | |---|---|---:| | `image` | File | 아니요 | | `request` | `application/json` | 예 | `request`: ```ts { name: string description: string originalWorkId?: number | null } ``` - `isActive`와 `externalCharacterId`를 보내지 않는다. - `originalWorkId`는 UI에서 선택 사항이다. 미선택 값을 `null`로 보낼지 key를 생략할지는 백엔드 canonical form 확정이 필요하다. - 백엔드는 새 `ChatCharacter`와 연결된 `creator(memberKind = AI_CHARACTER)`를 함께 생성한다. - Response `data`는 캐릭터 상세와 같다. ### 4.4 수정·비활성화 `PUT /api/v2/admin/ai-characters/{characterId}` Content-Type: `multipart/form-data` | Part | Content | 필수 | |---|---|---:| | `image` | 교체 File | 아니요 | | `request` | `application/json` | 예 | `request`: ```ts { name: string description: string originalWorkId?: number | null isActive?: false } ``` - `image` 미전송은 기존 이미지 유지다. - 기존 이미지 자체를 제거하는 flag는 제공되지 않았다. - 일반 수정에서는 `isActive`를 생략한다. `isActive=false`는 soft delete 전용이며 `true`는 보내지 않는다. - 현재 백엔드는 name/description/image 변경을 creator nickname/introduce/profile image에 동기화한다. - creator 상황에 따른 조건부 동기화는 다음 백엔드 범위이며 현재 프론트엔드가 분기하지 않는다. - Response `data`는 캐릭터 상세와 같다. - soft delete 성공 후 캐릭터 active-only 목록으로 이동하고 성공 알림을 표시한다. ## 5. 오디오 콘텐츠 ### 5.0 오디오 콘텐츠 테마 목록 `GET /api/v2/admin/ai-characters/audio-content-themes` - Query 없음 - Request body 없음 Response `data`: ```json [ { "themeId": 11, "themeName": "ASMR", "imageUrl": "https://cdn.example.com/audio-content-theme/asmr.png" } ] ``` - 오디오 콘텐츠 생성 UI는 이 목록에서 테마를 선택하게 하고, 선택한 `themeId`를 생성 request JSON에 포함한다. - 테마 목록은 선택 캐릭터 path를 포함하지 않는 공통 관리자 조회다. ### 5.1 목록·검색 `GET /api/v2/admin/ai-characters/{characterId}/audio-contents?search=밤&status=OPEN&page=0&size=20` - 활성 오디오만 반환한다. 활성 상태 filter/query는 없으며 `status`는 공개 상태 필터다. Query: ```ts { search?: string status?: "OPEN" | "SCHEDULED" page?: number size?: number } ``` Response `data`: ```json { "totalCount": 1, "page": 0, "size": 20, "hasNext": false, "items": [ { "contentId": 501, "title": "밤 산책", "coverImageUrl": "https://cdn.example.com/audio/501-cover.png", "audioSignedUrl": "https://cdn.example.com/signed/audio/501.m4a?Expires=...", "price": 1000, "isAdult": false, "isActive": true, "releaseDateUtc": "2026-07-25T00:00:00Z", "status": "OPEN" } ] } ``` - status 미전송 시 어떤 집합을 반환할지는 백엔드가 결정한다. ### 5.2 상세 `GET /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` Response `data`: ```json { "contentId": 501, "title": "밤 산책", "description": "조용한 밤 산책 오디오", "coverImageUrl": "https://cdn.example.com/audio/501-cover.png", "audioSignedUrl": "https://cdn.example.com/signed/audio/501.m4a?Expires=...", "price": 1000, "isAdult": false, "isActive": true, "releaseDateUtc": "2026-07-25T00:00:00Z", "status": "OPEN", "seriesIds": [701], "createdAtUtc": "2026-07-24T00:00:00Z", "updatedAtUtc": "2026-07-24T00:00:00Z" } ``` ### 5.3 생성 `POST /api/v2/admin/ai-characters/{characterId}/audio-contents` Content-Type: `multipart/form-data` | Part | Content | 필수 | |---|---|---:| | `coverImage` | File | 예 | | `audioFile` | File | 예 | | `request` | `application/json` | 예 | `request`: ```ts { title: string description: string themeId: number price: number isAdult: boolean releaseDateUtc: string | null seriesIds: number[] } ``` - `isActive`와 `status`를 보내지 않는다. - `themeId`는 필수이며 `GET /api/v2/admin/ai-characters/audio-content-themes` 응답에서 선택한 값이다. - 즉시 공개는 `releaseDateUtc=null`이다. - 예약 공개는 Asia/Seoul 미래 시각을 UTC `Z` 문자열로 변환해 보낸다. - Response `data`는 오디오 상세와 같다. ### 5.4 수정·비활성화 `PUT /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` Content-Type: `multipart/form-data` | Part | Content | 필수 | |---|---|---:| | `coverImage` | 교체 File | 아니요 | | `audioFile` | 교체 File | 아니요 | | `request` | `application/json` | 예 | `request`: ```ts { title: string description: string price: number isAdult: boolean isActive?: false releaseDateUtc: string | null seriesIds: number[] } ``` - 교체 File 미전송은 기존 media 유지다. - 일반 수정에서는 `isActive`를 생략한다. `isActive=false`는 soft delete 전용이며 `true`는 보내지 않는다. - Response `data`는 오디오 상세와 같다. - soft delete 성공 후 선택 캐릭터의 오디오 active-only 목록으로 이동하고 성공 알림을 표시한다. ### 5.5 업로드 규칙 - 오디오 콘텐츠 확장자: `.mp3`, `.aac`, `.m4a` - canonical MIME: `audio/mpeg`, `audio/aac`, `audio/mp4` - compatibility MIME: `audio/x-m4a`는 `.m4a` 파일에 한해 허용 - 최대 파일 크기: decimal 1,024MB, `1,024,000,000 bytes` 이하 - byte 경계: `1,024,000,000` 허용, `1,024,000,001` 거부 - 최대 재생 길이: 제한 없음 - WAV: 미지원 - 서버는 확장자와 MIME 외에 실제 container/codec을 검증해야 한다. - `audio/x-m4a`가 `.m4a`가 아닌 확장자와 조합되거나 실제 MP4/M4A container·codec 검증에 실패하면 `415`와 `common.error.invalid_request` envelope로 거부한다. - cover image의 형식, `1:1` crop과 최대 800px 출력 규칙은 `2.1 이미지 업로드와 crop`을 따른다. ## 6. 시리즈 ### 6.1 목록 `GET /api/v2/admin/ai-characters/{characterId}/series?page=0&size=20` - 활성 시리즈만 반환한다. 활성 상태 filter/query는 없다. Query: ```ts { page?: number size?: number } ``` Response `data`: ```json { "totalCount": 1, "page": 0, "size": 20, "hasNext": false, "items": [ { "seriesId": 701, "title": "루나의 밤", "introduction": "밤을 주제로 한 시리즈", "coverImageUrl": "https://cdn.example.com/series/701.png", "genreId": 3, "isAdult": false, "state": "PROCEEDING", "isActive": true, "orders": 1 } ] } ``` ### 6.2 상세 `GET /api/v2/admin/ai-characters/{characterId}/series/{seriesId}` Response `data`: ```json { "seriesId": 701, "title": "루나의 밤", "introduction": "밤을 주제로 한 시리즈", "coverImageUrl": "https://cdn.example.com/series/701.png", "publishedDaysOfWeek": ["MON", "WED"], "genreId": 3, "keywords": ["밤", "산책"], "isAdult": false, "state": "PROCEEDING", "isActive": true, "writer": "루나", "studio": "소다라이브", "orders": 1 } ``` ### 6.3 생성 `POST /api/v2/admin/ai-characters/{characterId}/series` Content-Type: `multipart/form-data` | Part | Content | 필수 | |---|---|---:| | `image` | File | 아니요 | | `request` | `application/json` | 예 | `request`: ```ts { title: string introduction: string publishedDaysOfWeek: SeriesPublishedDay[] genreId: number keywords: string[] isAdult: boolean writer: string studio: string } ``` - `state`와 `isActive`를 보내지 않는다. - 초기 state는 백엔드가 결정한다. 프론트엔드는 정확한 기본값에 의존하지 않고 생성 응답의 `state`를 그대로 표시한다. - Response `data`는 시리즈 상세와 같다. ### 6.4 수정·비활성화 `PUT /api/v2/admin/ai-characters/{characterId}/series/{seriesId}` Content-Type: `multipart/form-data` | Part | Content | 필수 | |---|---|---:| | `image` | 교체 File | 아니요 | | `request` | `application/json` | 예 | `request`: ```ts { title: string introduction: string publishedDaysOfWeek: SeriesPublishedDay[] genreId: number keywords: string[] isAdult: boolean state?: "PROCEEDING" | "SUSPEND" | "COMPLETE" isActive?: false writer: string studio: string } ``` - state를 변경하지 않을 때는 key를 생략한다. `null`을 보내지 않는다. - image 미전송은 기존 이미지 유지다. - 일반 수정에서는 `isActive`를 생략한다. `isActive=false`는 soft delete 전용이며 `true`는 보내지 않는다. - Response `data`는 시리즈 상세와 같다. - soft delete 성공 후 선택 캐릭터의 시리즈 active-only 목록으로 이동하고 성공 알림을 표시한다. ### 6.5 시리즈 콘텐츠 조회 `GET /api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents?search=밤&page=0&size=20` Query: ```ts { search?: string page?: number size?: number } ``` Response `data`: ```json { "totalCount": 1, "page": 0, "size": 20, "hasNext": false, "items": [ { "contentId": 501, "title": "밤 산책", "coverImageUrl": "https://cdn.example.com/audio/501-cover.png", "isAdult": false, "orders": 1 } ] } ``` ### 6.6 시리즈 콘텐츠 연결 `POST /api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents` Request body: ```json { "contentIds": [501, 502] } ``` Response `data`는 시리즈 상세와 같다. ### 6.7 시리즈 콘텐츠 연결 해제 `DELETE /api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents/{contentId}` - Request body 없음 - Response `data`는 시리즈 상세와 같다. ### 6.8 시리즈 순서 변경 `PUT /api/v2/admin/ai-characters/{characterId}/series/orders` Request body: ```json { "seriesIds": [701, 702, 703] } ``` - 활성 시리즈 전체의 ID를 최종 순서대로 보낸다. - 50개를 초과할 때 전체를 읽는 방식, 누락 ID 오류, 동시 변경 충돌 처리는 백엔드 제공 대기 계약이다. - Response `data`는 시리즈 목록과 같은 page envelope다. ## 7. 커뮤니티 게시글 ### 7.1 목록 `GET /api/v2/admin/ai-characters/{characterId}/community-posts?page=0&size=20` - 활성 게시글만 반환한다. 활성 상태 filter/query는 없다. Query: ```ts { page?: number size?: number } ``` Response `data`: ```json { "totalCount": 1, "page": 0, "size": 20, "hasNext": false, "items": [ { "postId": 801, "content": "오늘의 소식입니다.", "imageUrl": "https://cdn.example.com/community/801.png", "audioSignedUrl": null, "price": 0, "isAdult": false, "isFixed": true, "fixedAtUtc": "2026-07-24T00:00:00Z", "isActive": true, "createdAtUtc": "2026-07-24T00:00:00Z" } ] } ``` ### 7.2 등록 `POST /api/v2/admin/ai-characters/{characterId}/community-posts` Content-Type: `multipart/form-data` | Part | Content | 필수 | |---|---|---:| | `image` | File | 아니요 | | `audioFile` | File | 아니요 | | `request` | `application/json` | 예 | `request`: ```ts { content: string price: number isAdult: boolean isFixed: boolean } ``` - `isActive`를 보내지 않는다. Response `data`: ```json { "postId": 801, "content": "오늘의 소식입니다.", "imageUrl": "https://cdn.example.com/community/801.png", "audioSignedUrl": null, "price": 0, "isAdult": false, "isFixed": false, "fixedAtUtc": null, "isActive": true, "createdAtUtc": "2026-07-24T00:00:00Z", "updatedAtUtc": "2026-07-24T00:00:00Z" } ``` ### 7.3 수정·고정·비활성화 `PUT /api/v2/admin/ai-characters/{characterId}/community-posts/{postId}` Content-Type: `multipart/form-data` | Part | Content | 필수 | |---|---|---:| | `image` | 교체 File | 아니요 | | `audioFile` | 교체 File | 아니요 | | `request` | `application/json` | 예 | `request`: ```ts { content: string price: number isAdult: boolean isFixed: boolean isActive?: false } ``` - 교체 File 미전송은 기존 media 유지다. - 일반 수정에서는 `isActive`를 생략한다. `isActive=false`는 soft delete 전용이며 이때 서버 응답은 `isFixed=false`, `fixedAtUtc=null`이어야 한다. `true`는 보내지 않는다. - Response `data`는 등록 응답과 같다. - soft delete 성공 후 열린 게시글 Sheet를 닫고 선택 캐릭터의 커뮤니티 게시글 active-only 목록을 무효화·재조회해 해당 항목을 제거하며 성공 알림을 표시한다. ### 7.4 프론트엔드 조회·갱신 규칙 - `GET /api/v2/admin/ai-characters/{characterId}/community-posts/{postId}`는 추가하거나 소비하지 않는다. - 커뮤니티 상세·수정 직접 route를 제공하지 않고 목록 응답의 item으로 목록 행/카드 Sheet를 초기화한다. - 조회·audio 재생·수정·고정·비활성화·댓글 진입에 목록 item의 `postId`와 필드를 사용한다. - 생성·수정 응답은 목록 cache를 갱신하는 데 사용한다. - 사용자 페이지 새로고침이나 mutation 후 cache 무효화 등 일반 목록 재조회에서는 새 `audioSignedUrl`을 사용한다. media error는 목록·상세 조회, URL 재발급 또는 자동 재생의 trigger가 아니다. ### 7.5 첨부 audio upload 규칙 커뮤니티의 optional `audioFile`은 오디오 콘텐츠의 `5.5 업로드 규칙`을 그대로 사용한다. - 확장자: `.mp3`, `.aac`, `.m4a` - canonical MIME: `audio/mpeg`, `audio/aac`, `audio/mp4` - compatibility MIME: `audio/x-m4a`는 `.m4a` 파일에 한해 허용 - 최대 파일 크기: decimal 1,024MB, `1,024,000,000 bytes` 이하 - byte 경계: `1,024,000,000` 허용, `1,024,000,001` 거부 - 최대 재생 길이: 제한 없음 - WAV: 미지원 - 확장자와 MIME 외에 실제 container/codec을 server가 검증한다. - `audio/x-m4a`도 실제 MP4/M4A container·codec 검증을 통과해야 하며 불일치는 `415`와 `common.error.invalid_request` envelope로 거부한다. ## 8. FanTalk 답변 ### 8.1 답변 작성 `POST /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies` Request body: ```json { "content": "응원해줘서 고마워요!" } ``` Response `data`: ```json { "fanTalkId": 901, "replyId": 902, "creatorMemberId": 9001, "content": "응원해줘서 고마워요!", "createdAtUtc": "2026-07-24T00:00:00Z" } ``` - 하나의 FanTalk에는 답변을 한 번만 생성할 수 있다. - 답변이 이미 있으면 수정만 가능하고 삭제는 범위 밖이다. - 백엔드가 유일성을 원자적으로 강제하는 방식과 중복 생성의 비2xx status/message key는 백엔드 제공 대기 계약이다. 프론트엔드에서 status를 추정하지 않는다. ## 9. 백엔드 제공 대기 계약 아래 항목은 프론트엔드 인터뷰의 Open Question이 아니라 백엔드 소유 계약이다. P0 계약을 제공받기 전에는 관련 network integration을 구현하지 않는다. P1은 제공 계약 없이 값을 추정하지 않고 출시 전 검증을 맞춘다. | 우선순위 | 영역 | 필요한 계약 | |---:|---|---| | P0 | FanTalk | 목록·상세·답변 수정, 전체/미답변/답변 완료 filter, 최신순, reply uniqueness 오류 | | P0 | 댓글 | 오디오·커뮤니티의 2단계 목록·작성·수정·soft delete, 팬 댓글 삭제를 포함한 작성자별 권한 오류와 status/message key | | P0 | lookup | original work 이름 검색, genre 이름 검색, originalWork 미선택 `null`/omit | | P0 | Series 연결 후보 | 선택 캐릭터의 연결 가능한 활성 오디오 조회 | | P0 | Series | 전체 순서의 50개 초과 로딩·누락 ID·동시 변경 충돌 | | P1 | validation | price 최대값 | | P0 | 오류 | 신규 domain 오류별 비2xx status와 KO/EN/JA message key | | P2 | 감사 로그 | backend event schema. 관리자 조회 UI 여부는 PRD OQ-010에서 별도로 결정 | ## 10. UI 작성 후 확정할 validation 문자열 최대 길이와 배열 최대 개수는 초기 UI를 만든 뒤 페이지별로 검토해 결정한다. 제공된 계약에 명시되지 않은 최대값을 프론트엔드가 먼저 추정해 schema에 추가하지 않는다. - 문자열 대상: Character `name`·`description`, Audio `title`·`description`, Series `title`·`introduction`·`keywords[]`·`writer`·`studio`, Community `content`, FanTalk reply `content`, 댓글 `content` - 배열 대상: Audio `seriesIds`, Series `publishedDaysOfWeek`·`keywords`·연결 `contentIds`·순서 `seriesIds` - UI 검토에서 권고값을 작성하고 백엔드 validation과 호환되는지 확인한 뒤 확정한다. 확정 시 이 문서, PRD, form schema와 최소/최대/초과 경계값 test를 같은 변경에서 갱신한다.