19 KiB
19 KiB
Frontend Original Work Management Add-on Prompt
이 문서는 이미 구현 또는 구현 중인 AI 캐릭터 관리자 Frontend에 원작 관리 기능만 추가하기 위한 delta 프롬프트다. 기존 PRD 27.8의 stack, 인증·세션, 환경별 API Base URL, Jenkins, noindex, UTC/KST, feedback와 공통 API 규칙은 그대로 유지한다.
Copy-Paste Prompt
당신은 기존 AI 캐릭터 관리자 Frontend에 원작 관리 기능을 추가한다.
[작업 원칙]
- 기존 기술 stack, package version, lockfile, VITE_API_BASE_URL mode 설정과 Jenkins 명령을 변경하지 않는다.
- .env.development와 .env.production은 같은 VITE_API_BASE_URL key에 서로 다른 실제 dev·production URL을 유지하고 source code에 URL을 하드코딩하지 않는다.
- Jenkins의 install·검증 명령은 기존 pnpm install --frozen-lockfile, pnpm run ci:prod를 그대로 사용한다.
- 기존 로그인, sessionStorage, fetch wrapper, ApiResponse envelope, error handling, noindex, UTC/KST와 Sonner 규칙을 재사용한다.
- 아래 실제 Request/Response JSON과 명시된 nullable JSON 값으로 TypeScript type, API 함수와 mock을 만든다. Backend DTO/data class의 존재나 이름을 전제하지 않는다.
- legacy /admin/chat/original/**와 /api/chat/original/**를 호출하지 않는다.
- 메뉴는 Backend에서 조회하지 않고 기존 typed static menu configuration에 추가한다.
- 새 원작 route에도 기존 접근 제어와 noindex 정책을 동일하게 적용하고 검색 노출 가능한 public route나 metadata를 만들지 않는다.
- 도메인별 화면을 범용 CRUD 설정 하나로 합치지 않는다. 두 화면 이상에서 실제 반복되는 UI와 동작만 component로 추출한다.
[완료 목표]
- global 원작 목록·검색·상세·등록·수정·삭제 화면을 제공한다.
- 원작 상세에서 연결된 활성·비활성 캐릭터를 조회하고, 활성 AI 캐릭터를 배정하며 기존 연결을 해제할 수 있게 한다.
- 기존 캐릭터 등록·수정 form에서 원작을 검색·선택하거나 연결을 해제할 수 있게 한다.
- 아래 ORIGINAL-WORK-01~08을 기존 API client, query key, 화면과 test에 연결한다.
- 기존 58개 신규 관리자 Operation에 8개를 더한 66개 신규 관리자 Operation을 브라우저에서 사용할 수 있게 한다.
[메뉴와 route]
- 기존 typed static menu에 다음 global item을 추가한다.
{ key: "original-works", label: "원작 관리", to: "/ai-characters/original-works", scope: "GLOBAL" }
- 원작 관리는 character-scoped 메뉴가 아니며 진입 전에 캐릭터를 선택하지 않는다.
- route는 다음을 사용한다.
/ai-characters/original-works
/ai-characters/original-works/new
/ai-characters/original-works/:originalWorkId
/ai-characters/original-works/:originalWorkId/edit
- /ai-characters/original-works의 static segment가 /ai-characters/:characterId보다 우선 매칭되는지 route test로 고정한다.
[Page와 Dialog 결정]
- 원작 목록, 상세, 등록, 수정은 Page로 만든다. 서버 페이징, URL 복원, 이미지와 다중 field form이 있으므로 Dialog로 만들지 않는다.
- 원작 상세의 캐릭터 배정은 검색·다중 선택 Dialog로 만든다.
- 연결 캐릭터 해제는 table row action 또는 table bulk action으로 제공하고 실행 전에 AlertDialog로 확인한다.
- 원작 삭제는 AlertDialog로 확인한다. characterCount가 0보다 크면 UI에서 이유를 표시하고 비활성화하되, race condition에 대한 Backend 409도 처리한다.
- 캐릭터 등록·수정 form의 원작 선택은 form 안의 searchable Combobox 또는 선택 Dialog로 구현한다. 별도 사전 선택 Page를 추가하지 않는다.
[캐릭터 등록·수정의 원작 선택]
- ORIGINAL-WORK-01의 page, size, search를 사용해 isDeleted=false 원작을 검색한다.
- 캐릭터 생성 form의 선택값이 없으면 CHAR-03 request.originalWorkId에 null을 보낸다.
- 캐릭터 수정 form은 CHAR-02 response.originalWork의 id, title, imageUrl로 현재 선택을 복원한다.
- CHAR-02 response.originalWork 자체는 null일 수 있다. null이면 현재 원작 미선택 상태로 복원하고 ORIGINAL-WORK-02를 호출하지 않는다.
- 수정 form에서 선택을 지우면 CHAR-04 request.originalWorkId에 명시적 null을 보낸다.
- originalWorkId=0을 보내지 않는다.
- CHAR-04는 기존 계약대로 originalWorkId key 자체를 반드시 포함한다.
- 수정 진입 시 현재 선택된 원작이 첫 검색 page에 없어도 CHAR-02의 brief를 선택값으로 유지하고, 사용자가 검색 결과를 선택할 때만 교체한다.
- 현재 brief의 ORIGINAL-WORK-02가 404이면 legacy 데이터의 삭제·누락 원작 연결로 표시하고, 새 원작 선택 또는 명시적 해제 전에는 수정을 제출하지 않는다.
[원작 form]
- 생성 기본값은 isAdult=false, description="", nullable string=null, originalLinks=[], tags=[]다.
- 수정 요청은 아래 11개 JSON key를 항상 모두 보낸다.
title, contentType, category, isAdult, description, originalWork, originalLink,
writer, studio, originalLinks, tags
- nullable string을 지울 때 null, 링크와 태그를 모두 지울 때 []를 보낸다. update key를 생략하지 않는다.
- title, contentType, category는 trim 후 필수다.
- originalLink와 originalLinks는 http/https 절대 URL만 허용한다.
- originalLinks와 tags는 trim하고 빈 값을 제거하며 첫 등장 순서를 유지해 중복을 제거한다.
- create의 image는 필수, update의 image는 선택이다. update에서 새 image를 선택하지 않으면 image part를 보내지 않는다.
- multipart request part에는 아래 Request JSON을 JSON.stringify한 문자열을 넣고 multipart 전체 Content-Type은 직접 지정하지 않는다.
[재사용 component]
- OriginalWorkForm: create와 edit Page가 공유하되 create/update API 호출은 각 Page에 둔다.
- OriginalWorkSelector: 캐릭터 create/edit form이 공유한다.
- OriginalWorkSummaryCell: 원작 목록과 캐릭터 form의 선택 결과가 공유한다.
- AssignedCharacterTable: 원작 상세의 연결 캐릭터 목록과 bulk selection을 담당한다.
- CharacterAssignmentDialog: CHAR-01 검색과 ORIGINAL-WORK-07 호출을 담당한다.
- 기존 Pagination, DataTable, PageHeader, LoadingState, EmptyState, ErrorState, FormErrorSummary, Confirm AlertDialog를 재사용한다.
- 원작 전용 validation과 캐릭터 배정 규칙을 generic CRUD schema로 추상화하지 않는다.
[Query key와 invalidation]
- 목록: ["original-works", { page, size, search }]
- 상세: ["original-work", originalWorkId]
- 연결 캐릭터: ["original-work", originalWorkId, "characters", { page, size, search, isActive }]
- 캐릭터 배정 후보는 기존 CHAR-01 global query를 isActive=true로 조회한다.
- ORIGINAL-WORK-03 성공 후 원작 목록을 invalidate한다.
- ORIGINAL-WORK-04 성공 후 해당 상세와 원작 목록을 invalidate하고, title/image brief가 바뀔 수 있으므로 ["ai-character"] prefix의 CHAR-02 상세 query도 invalidate한다.
- ORIGINAL-WORK-05 성공 후 해당 상세를 제거하고 목록으로 이동한 뒤 원작 목록을 invalidate한다.
- ORIGINAL-WORK-07/08 성공 후 원작 목록 전체와 ["original-work"] prefix의 상세·연결 캐릭터 query를 invalidate한다. 배정은 이전 원작에서 이동할 수 있으므로 새 원작 cache만 갱신하지 않는다. 응답 characterIds 각각의 CHAR-02 query도 invalidate한다.
- CHAR-03/04 성공 후 새 원작과 이전 원작이 있으면 해당 원작 상세·연결 캐릭터 목록·목록의 characterCount를 invalidate한다.
- CHAR-05 성공 후 삭제 캐릭터가 연결된 원작의 연결 캐릭터 목록과 characterCount를 invalidate한다.
[배정·해제 UX]
- 배정 후보는 CHAR-01의 활성 AI 캐릭터만 표시한다.
- 배정 확인문에 “다른 원작에 연결된 캐릭터는 이 원작으로 이동합니다”를 명시한다.
- ORIGINAL-WORK-07은 선택한 characterIds 전체를 한 요청으로 전송한다.
- ORIGINAL-WORK-08은 DELETE method와 JSON body를 함께 사용하고 Content-Type: application/json을 명시한다.
- 배정·해제에서 일부 성공을 가정하지 않는다. 성공 Response 뒤에만 선택을 비우고 toast를 표시한다.
- 배정·해제 성공 Response의 characterIds는 실제 변경 여부와 관계없이 검증된 요청 ID 전체가 요청 순서대로 반환된다. characterCount는 처리 건수가 아니라 작업 후 해당 원작에 연결된 전체 캐릭터 수다.
- 연결 목록의 creatorId는 legacy 불일치 정리를 위해 null일 수 있다. null이면 캐릭터 row는 계속 표시하고 “연결 크리에이터 없음” 상태를 표시하며 해제 action을 숨기지 않는다.
- 400 errorProperty=characterIds는 Dialog의 selection error로 표시한다.
- 409 errorProperty=title은 trim·대소문자 무시 중복 제목 field error로 표시한다.
- 409 errorProperty=characterIds는 비활성 상태 변경 가능성을 설명하고 후보 목록을 refetch한다.
- 409 errorProperty=originalWorkId는 삭제된 원작 또는 연결 캐릭터가 남은 삭제 요청으로 처리하고 상세·연결 목록을 refetch한다.
- originalWorkId와 원작 route param은 양수만 전송한다. 0 이하는 client validation으로 차단하고, 404 originalWorkId는 누락·삭제된 조회 대상으로, mutation의 409 originalWorkId는 삭제 상태 또는 연결 존재 충돌로 구분해 처리한다.
- 이미 삭제가 성공한 원작의 DELETE 재시도는 같은 성공 Response가 올 수 있으므로 오류로 간주하지 않는다.
[날짜와 feedback]
- createdAtUtc와 updatedAtUtc는 UTC Z 원문으로 cache·비교하고 기존 formatUtcInKst utility로 KST 표시한다.
- mutation 성공은 기존 Sonner success toast, field 오류는 inline, 최초 조회 실패는 ErrorState, 삭제·해제 사전 확인은 AlertDialog 규칙을 유지한다.
- characterCount에는 활성·비활성 및 연결 creator 상태와 관계없이 모든 연결 캐릭터가 포함되므로 UI에서 임의로 다시 계산하지 않는다.
[응답 nullable JSON 값]
- 아래 값은 기존 Operation Response JSON의 해당 위치에 나타날 수 있는 유효 JSON이다. 예시의 non-null 값만 보고 required string/number로 좁히지 않는다.
- ORIGINAL-WORK-01 data.items[0]과 ORIGINAL-WORK-02 data의 imageUrl, createdAtUtc, updatedAtUtc는 null일 수 있다.
- ORIGINAL-WORK-02 data의 originalWork, originalLink, writer, studio는 null일 수 있다.
- ORIGINAL-WORK-06 data.items[0]의 creatorId, imageUrl, createdAtUtc는 null일 수 있다.
- CHAR-02 data.originalWork는 아래 null 또는 brief 객체다. brief의 imageUrl도 null일 수 있다.
ORIGINAL-WORK-01 data.items[0] nullable JSON:
{
"originalWorkId": 71,
"title": "달빛 도서관",
"contentType": "WEB_NOVEL",
"category": "FANTASY",
"isAdult": false,
"imageUrl": null,
"characterCount": 2,
"createdAtUtc": null,
"updatedAtUtc": null
}
ORIGINAL-WORK-02 data nullable JSON:
{
"originalWorkId": 71,
"title": "달빛 도서관",
"contentType": "WEB_NOVEL",
"category": "FANTASY",
"isAdult": false,
"description": "",
"originalWork": null,
"originalLink": null,
"writer": null,
"studio": null,
"originalLinks": [],
"tags": [],
"imageUrl": null,
"characterCount": 0,
"createdAtUtc": null,
"updatedAtUtc": null
}
ORIGINAL-WORK-06 data.items[0] nullable JSON:
{
"characterId": 101,
"creatorId": null,
"name": "루나",
"imageUrl": null,
"isActive": false,
"createdAtUtc": null
}
CHAR-02 data.originalWork nullable JSON:
null
CHAR-02 data.originalWork brief nullable JSON:
{
"id": 71,
"title": "달빛 도서관",
"imageUrl": null
}
[Operation Catalog]
공통 성공 envelope key는 success, message, data, errorProperty다.
공통 page data key는 items, page, size, totalCount, hasNext다.
공통 오류 Response JSON:
{"success":false,"message":"요청을 처리할 수 없습니다.","data":null,"errorProperty":"originalWorkId"}
배정·해제 field 오류 Response JSON:
{"success":false,"message":"캐릭터 선택을 확인해 주세요.","data":null,"errorProperty":"characterIds"}
중복 제목 오류 Response JSON:
{"success":false,"message":"이미 사용 중인 원작 제목입니다.","data":null,"errorProperty":"title"}
ORIGINAL-WORK-01 GET /admin/ai-characters/original-works
Path: 없음
Query: page=0&size=20&search=달빛
Request JSON: 없음
Response JSON:
{
"success": true,
"message": null,
"data": {
"items": [{
"originalWorkId": 71,
"title": "달빛 도서관",
"contentType": "WEB_NOVEL",
"category": "FANTASY",
"isAdult": false,
"imageUrl": "https://cdn.example.com/originals/71/original.webp",
"characterCount": 2,
"createdAtUtc": "2026-07-20T01:00:00Z",
"updatedAtUtc": "2026-07-20T02:00:00Z"
}],
"page": 0,
"size": 20,
"totalCount": 1,
"hasNext": false
},
"errorProperty": null
}
search는 title, contentType, category의 대소문자 무시 부분 검색이다.
ORIGINAL-WORK-02 GET /admin/ai-characters/original-works/{originalWorkId}
Request JSON: 없음
Response JSON:
{
"success": true,
"message": null,
"data": {
"originalWorkId": 71,
"title": "달빛 도서관",
"contentType": "WEB_NOVEL",
"category": "FANTASY",
"isAdult": false,
"description": "밤에만 문을 여는 도서관의 이야기",
"originalWork": "Moonlight Library",
"originalLink": "https://example.com/works/71",
"writer": "김작가",
"studio": "소다 스튜디오",
"originalLinks": [
"https://example.com/works/71",
"https://example.com/works/71/official"
],
"tags": ["힐링", "판타지"],
"imageUrl": "https://cdn.example.com/originals/71/original.webp",
"characterCount": 2,
"createdAtUtc": "2026-07-20T01:00:00Z",
"updatedAtUtc": "2026-07-20T02:00:00Z"
},
"errorProperty": null
}
ORIGINAL-WORK-03 POST /admin/ai-characters/original-works
Content-Type: multipart/form-data
Parts: image 필수, request 필수 JSON string
Request JSON:
{
"title": "달빛 도서관",
"contentType": "WEB_NOVEL",
"category": "FANTASY",
"isAdult": false,
"description": "밤에만 문을 여는 도서관의 이야기",
"originalWork": "Moonlight Library",
"originalLink": "https://example.com/works/71",
"writer": "김작가",
"studio": "소다 스튜디오",
"originalLinks": [
"https://example.com/works/71",
"https://example.com/works/71/official"
],
"tags": ["힐링", "판타지"]
}
Response JSON:
{
"success": true,
"message": null,
"data": {
"originalWorkId": 71,
"isDeleted": false
},
"errorProperty": null
}
ORIGINAL-WORK-04 PUT /admin/ai-characters/original-works/{originalWorkId}
Content-Type: multipart/form-data
Parts: image 선택, request 필수 JSON string
Request JSON:
{
"title": "달빛 도서관 개정판",
"contentType": "WEB_NOVEL",
"category": "FANTASY",
"isAdult": false,
"description": "개정된 작품 소개",
"originalWork": null,
"originalLink": null,
"writer": "김작가",
"studio": "소다 스튜디오",
"originalLinks": [],
"tags": ["판타지"]
}
Response JSON:
{
"success": true,
"message": null,
"data": {
"originalWorkId": 71,
"isDeleted": false
},
"errorProperty": null
}
ORIGINAL-WORK-05 DELETE /admin/ai-characters/original-works/{originalWorkId}
Request JSON: 없음
Response JSON:
{
"success": true,
"message": null,
"data": {
"originalWorkId": 71,
"isDeleted": true
},
"errorProperty": null
}
삭제되지 않은 원작에 연결된 캐릭터가 하나라도 있으면 409다. 이미 삭제된 원작의 재시도는 같은 성공 Response다.
ORIGINAL-WORK-06 GET /admin/ai-characters/original-works/{originalWorkId}/characters
Query: page=0&size=20&search=루나&isActive=true
Request JSON: 없음
Response JSON:
{
"success": true,
"message": null,
"data": {
"items": [{
"characterId": 101,
"creatorId": 10001,
"name": "루나",
"imageUrl": "https://cdn.example.com/characters/101.webp",
"isActive": true,
"createdAtUtc": "2026-07-20T01:30:00Z"
}],
"page": 0,
"size": 20,
"totalCount": 1,
"hasNext": false
},
"errorProperty": null
}
isActive를 생략하면 활성·비활성 연결 캐릭터를 모두 반환한다. creatorId는 null일 수 있다.
ORIGINAL-WORK-07 POST /admin/ai-characters/original-works/{originalWorkId}/characters
Content-Type: application/json
Request JSON:
{
"characterIds": [101, 102]
}
Response JSON:
{
"success": true,
"message": null,
"data": {
"originalWorkId": 71,
"characterIds": [101, 102],
"characterCount": 2
},
"errorProperty": null
}
ORIGINAL-WORK-08 DELETE /admin/ai-characters/original-works/{originalWorkId}/characters
Content-Type: application/json
Request JSON:
{
"characterIds": [101, 102]
}
Response JSON:
{
"success": true,
"message": null,
"data": {
"originalWorkId": 71,
"characterIds": [101, 102],
"characterCount": 0
},
"errorProperty": null
}
[필수 test]
- static 원작 route가 dynamic :characterId route보다 우선한다.
- 원작 목록의 page, search, empty, loading, error와 KST 날짜 표시가 동작한다.
- create는 image를 필수로 보내고 update는 선택 image를 생략할 수 있다.
- update가 nullable key를 누락하지 않고 null과 []를 정확히 직렬화한다.
- 원작 삭제가 characterCount>0에서 UI상 차단되고 Backend 409 race도 표시한다.
- 배정이 다른 원작 연결 이동 경고를 표시하고 성공 뒤 관련 query를 invalidate한다.
- 같은 원작 반복 배정에서도 Response characterIds가 요청 순서를 유지하고 characterCount를 처리 건수로 오인하지 않는다.
- creatorId=null인 연결 캐릭터를 fallback 상태로 표시하고 해제할 수 있다.
- DELETE body 해제가 정확한 method, header와 JSON body를 전송한다.
- 캐릭터 생성에서 미선택 null, 수정에서 현재 원작 복원, 명시적 해제 null을 전송하고 0을 보내지 않는다.
- CHAR-02 originalWork=null을 미선택 상태로 복원하고, 원작·연결 캐릭터 Response의 모든 nullable field가 null이어도 화면과 typecheck가 정상 동작한다.
- 401, 403, 400 originalWorkId/characterIds, 404 originalWorkId, 409 title/originalWorkId/characterIds와 최초 조회 실패가 기존 feedback 규칙을 따른다.
- API가 반환한 UTC Z를 유지하고 화면에서만 Asia/Seoul로 표시한다.
[검증과 결과물]
- 기존 package scripts와 Jenkins의 pnpm install --frozen-lockfile, pnpm run ci:prod 경로가 그대로 통과해야 한다.
- typecheck, ESLint, unit/UI test와 production build를 모두 실행한다.
- 핵심 E2E에 원작 CRUD, 캐릭터 배정·해제, 캐릭터 form의 원작 선택·해제를 추가한다.
- README 또는 기존 API 문서에 원작 route, 8개 Operation, DELETE JSON body와 캐시 무효화 규칙을 추가한다.
- 구현하지 못한 항목은 숨기지 말고 Operation ID와 이유를 명시한다.