From 3d409dc108eaabc48c09578db2f1a58c80fc454e Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 20 Jul 2026 23:21:13 +0900 Subject: [PATCH 1/5] =?UTF-8?q?docs(ai-character):=20AI=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B3=84=ED=9A=8D=EC=9D=84=20=EC=9E=91=EC=84=B1?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../frontend-original-work-prompt.md | 395 ++ .../20260720_AI캐릭터_관리자기능/plan-task.md | 989 ++++ docs/20260720_AI캐릭터_관리자기능/prd.md | 4513 +++++++++++++++++ 3 files changed, 5897 insertions(+) create mode 100644 docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md create mode 100644 docs/20260720_AI캐릭터_관리자기능/plan-task.md create mode 100644 docs/20260720_AI캐릭터_관리자기능/prd.md diff --git a/docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md b/docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md new file mode 100644 index 00000000..748f302b --- /dev/null +++ b/docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md @@ -0,0 +1,395 @@ +# 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 + +```text +당신은 기존 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와 이유를 명시한다. +``` diff --git a/docs/20260720_AI캐릭터_관리자기능/plan-task.md b/docs/20260720_AI캐릭터_관리자기능/plan-task.md new file mode 100644 index 00000000..9a478478 --- /dev/null +++ b/docs/20260720_AI캐릭터_관리자기능/plan-task.md @@ -0,0 +1,989 @@ +# AI 캐릭터 관리자 기능 Implementation Plan + +> **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:** 기존 관리자 로그인, legacy 원작 API와 AWS upload-complete 계약을 유지하면서 `ROLE_ADMIN` 전용 AI 캐릭터 관리자 API 66개를 v2 도메인 경계 안에 구현하고, 원작 관리와 AI 캐릭터의 비동기 콘텐츠·커뮤니티 운영을 안전하게 제공한다. + +**Architecture:** 신규 web adapter와 DTO는 `kr.co.vividnext.sodalive.v2.admin.aicharacter`가 소유하고, 업무 규칙은 `v2.aicharacter`, `v2.originalwork`, `v2.content.*`, `v2.creator.channel.*`의 각 도메인이 소유한다. admin facade는 실제 `ADMIN` principal의 ID를 유지하고, character-scoped 요청에서만 Path의 `characterId`를 `AiCharacterAdminTarget`으로 해석한다. global 원작 facade는 원작 use case를 직접 조정하고 배정·해제에서만 캐릭터 집합을 검증한다. 기존 JPA entity와 QueryDSL Q type은 v2 persistence adapter에서만 사용하고, 기존 Service·Repository·web DTO는 신규 v2 업무 로직에서 호출하지 않는다. + +**Tech Stack:** Kotlin, Java 17, Spring Boot 2.7.14, Spring Security, Spring Data JPA, QueryDSL, AWS S3/CloudFront infrastructure client, JUnit 5, MockMvc, Gradle Wrapper, ktlint + +--- + +## 0. 구현 범위와 고정 결정 + +- 기준 문서: `docs/20260720_AI캐릭터_관리자기능/prd.md` +- 이 계획은 Backend 구현만 다룬다. PRD 27장의 Frontend handoff는 클라이언트 구현 입력이며 Backend 완료 조건에 포함하지 않는다. +- 기존 `POST /admin/member/login`을 그대로 사용한다. 신규 로그인, 사칭 토큰, menu/capability Endpoint를 만들지 않는다. +- 메뉴는 클라이언트의 typed static configuration이 소유한다. Backend의 기존 `GET /menu` 및 메뉴 코드는 수정하지 않는다. +- Kotlin package는 v2지만 HTTP base path는 `/admin/ai-characters`다. `/v2`, `/admin/v2`, `/v2/admin` prefix를 추가하지 않는다. +- 신규 웹 Operation은 PRD에 명시된 66개다. 기존 `PUT /audio-content/upload-complete`는 호환 계약이므로 신규 Operation 수에 포함하지 않는다. +- `ORIGINAL-WORK-01`~`ORIGINAL-WORK-08`은 `/admin/ai-characters/original-works`의 global 원작 CRUD·검색·캐릭터 배정 계약이다. legacy `/admin/chat/original/**`의 Method·Path·Request·성공 Response와 일반 사용자용 `/api/chat/original/**`는 호환을 위해 유지한다. +- `CONTENT-08`이라는 신규 Endpoint, V1/V2 dispatcher와 v2 completion use case를 만들지 않는다. v2 콘텐츠도 기존 row·S3 계약을 따라 현재 callback이 동일하게 처리한다. +- AWS S3 Trigger worker 코드, worker 스케줄, metadata 계약 및 AWS Trigger 설정은 생성·수정하지 않는다. +- v2 전용 예약 공개 scheduler를 만들지 않는다. 기존 scheduler component의 cron·lock은 유지하고 활성 creator만 선택하도록 기존 release query의 안전 조건만 보강한다. +- 이번 기능을 위한 DB 테이블·컬럼·인덱스·JPA mapping, DDL, backfill과 데이터 migration을 생성·수정하지 않는다. +- 콘텐츠 `status`는 기존 `isActive`, `releaseDate`, `duration`과 creator 활성 상태에서 계산한다. `content` 경로는 Signed URL 발급 전 canonical output key 검증에만 사용한다. +- 캐릭터 삭제 시 소유 콘텐츠의 기존 raw `isActive`만 `false`로 전환하고 `releaseDate`, `content`, `duration`과 구매 이력은 보존한다. 이는 기존 컬럼의 논리 상태 변경이며 schema·JPA mapping 변경이 아니다. +- 캐릭터 삭제가 실제 commit된 경우에만 `default`, `cache_ttl_3_days`, `popularCharacters_24h_locale` cache를 1회 clear한다. 동적 cache key 열거, Redis key scan과 범용 cache invalidation framework는 만들지 않는다. +- `previewStartTime`, `previewEndTime`은 생성 Request에서 기존 S3 metadata로만 전달하고 DB와 목록·상세 Response에는 저장하지 않는다. +- 신규 v2 domain/application/port는 v2 외부의 legacy Controller, Service, Repository, Request/Response DTO를 호출하지 않는다. +- 원작은 `v2.originalwork`가 소유하고 기존 `OriginalWork` 관련 entity와 `ChatCharacter.originalWork` 관계는 persistence adapter에서만 사용한다. 원작용 table·column·index·JPA mapping·DDL·backfill·data migration을 만들지 않는다. +- 원작 삭제는 연결 캐릭터가 0명일 때만 `isDeleted=true`로 전환한다. 배정·해제는 전건 검증 후 한 transaction에서 처리하며 누락 ID를 무시하는 부분 성공을 허용하지 않는다. +- legacy 원작 등록·수정·삭제·배정·해제는 원작 호환 adapter가 같은 v2 원작 input port를 호출한다. legacy 캐릭터 등록·수정 전체는 캐릭터 호환 adapter가 같은 v2 캐릭터 command를 호출하고 해당 command가 v2 원작 참조·잠금 정책을 사용한다. v2는 legacy Controller·Service·DTO를 참조하지 않으며 legacy 조회 API는 기존 구현을 유지한다. +- 원작 관계 mutation은 양수 연결 대상 또는 Path 원작 row가 있으면 해당 원작을 `PESSIMISTIC_WRITE`로 먼저 잠그고 캐릭터 row를 ID 오름차순으로 잠근 뒤 재검증한다. `CHAR-04`의 `null`과 legacy 수정의 `0` 해제는 대상 원작이 없으므로 캐릭터 row만 잠그고 재검증한다. 원작 생성과 제목이 실제 바뀌는 수정은 MySQL `SERIALIZABLE` transaction과 deadlock·serialization 실패 시 최대 1회 새 transaction 재시도로 동시성 불변식을 지킨다. +- 이미 삭제된 원작의 반복 DELETE는 연결 수보다 먼저 판정해 성공한다. 배포 전 삭제 원작 연결 불일치가 1건 이상이면 자동 migration을 만들지 않고 별도 승인된 보정을 완료하기 전 mutation 전환을 활성화하지 않는다. +- 원작 이미지 port는 저장과 해당 request가 새로 만든 object 삭제를 지원한다. 원작 command에도 서버 생성 `requestId`를 사용한다. 이미지 저장 자체의 실패는 502다. 이미지 저장 후 재시도 가능한 DB 실패는 해당 attempt object 삭제 보상이 성공한 뒤 최대 1회 새 transaction으로 재시도한다. 재조회에서 실제 중복이 확인되면 409이고, 비재시도 DB 실패·재시도 소진·보상 실패는 500이다. +- 신규 `CHAR-03`의 `originalWorkId=null`은 미연결, `CHAR-04`의 명시적 `null`은 해제이며 신규 계약은 `0` sentinel을 허용하지 않는다. legacy 호환 adapter는 기존 등록의 `0`을 미연결로, 수정의 `0`만 해제로 변환한다. +- legacy 캐릭터 등록의 `originalWorkId=null`과 `0`은 미연결, 수정의 `null`은 변경 없음, `0`은 해제다. 호환 adapter는 이 네 경우를 v2 command의 명시적 의미로 변환한다. +- 캐릭터 삭제가 기존 공개 화면에 반영되도록 legacy 소비자 query와 cache에 필요한 active guard만 최소 보강한다. 이는 v2 업무 로직에서 legacy Service·Repository를 호출하거나 재사용하는 것이 아니다. +- 공통 JWT, `ApiResponse`, 기존 JPA entity/Q type, S3·CloudFront·외부 캐릭터 client는 PRD가 허용한 adapter 경계 뒤에서만 재사용한다. +- 기존 소비자용 v2 시리즈·커뮤니티·FanTalk query는 마스킹과 소비자 필터가 있어 관리자 조회에 직접 재사용하지 않는다. 같은 v2 도메인 아래 관리자 전용 projection과 use case를 추가한다. +- 독립 리소스는 논리 삭제한다. 시리즈-콘텐츠와 Member-creator tag 연결 해제만 join row 물리 삭제가 가능하고, `CategoryContent`는 기존 `isActive` 모델을 유지한다. +- 모든 절대 날짜·시간 응답은 UTC `Z`로 직렬화한다. duration과 preview offset `HH:mm:ss`는 timezone 변환 대상이 아니다. +- 시간 의존 서비스는 생성자에 `Clock`을 받고 운영 기본값은 `Clock.systemUTC()`로 둔다. 테스트는 fixed `Clock`을 사용한다. +- 알림·번역처럼 기존 `@TransactionalEventListener(AFTER_COMMIT)`가 처리하는 event는 transaction 안에서 publish해 listener가 commit 후 실행하게 한다. home news처럼 transactional listener를 거치지 않는 직접 호출만 `AfterCommitExecutor`에 등록한다. 외부 캐릭터 API/S3는 commit 전 실행하되 실패·rollback·commit 예외를 보상한다. 어느 경로든 rollback, 동일 상태 전이 재시도, 멱등 삭제 재시도에서 외부 효과를 중복 실행하지 않는다. +- 순수 policy는 JUnit unit test, application은 outbound port fake/mock test, persistence는 `@DataJpaTest`와 기존 `QueryDslConfig` 패턴, HTTP 계약은 `@SpringBootTest`+MockMvc로 검증한다. Redis가 실제 경로에 필요한 경우에만 기존 embedded Redis fixture를 사용한다. + +## 1. 성공 기준과 Operation 추적 + +### 1.1 Operation coverage + +| 구현 Task | Operation ID | 수 | +|---|---|---:| +| Task 2.4 | `CHAR-01`~`CHAR-04` | 4 | +| Task 2.7 | `ORIGINAL-WORK-01`~`ORIGINAL-WORK-08` | 8 | +| Task 10.2 | `CHAR-05` | 1 | +| Task 4.3 | `CONTENT-01`~`CONTENT-07` | 7 | +| Task 5.2 | `CONTENT-COMMENT-01`~`CONTENT-COMMENT-05` | 5 | +| Task 6.2 | `CATEGORY-01`~`CATEGORY-09` | 9 | +| Task 7.2 | `SERIES-01`~`SERIES-11` | 11 | +| Task 8.2 | `COMMUNITY-POST-01`~`COMMUNITY-POST-06` | 6 | +| Task 8.4 | `COMMUNITY-COMMENT-01`~`COMMUNITY-COMMENT-05` | 5 | +| Task 9.2 | `FAN-TALK-01`~`FAN-TALK-05` | 5 | +| Task 9.4 | `NOTICE-01`~`NOTICE-02`, `CREATOR-TAG-01`, `CHANNEL-PROFILE-01`~`CHANNEL-PROFILE-02` | 5 | +| 합계 | 신규 관리자 Operation | 66 | + +### 1.2 공통 완료 조건 + +- 모든 신규 Controller가 class level `@PreAuthorize("hasRole('ADMIN')")`를 사용한다. +- 비로그인은 401, ADMIN 외 인증 사용자는 403이며 두 경우 모두 `ApiResponse` 오류 JSON을 반환한다. +- 400/404/409/500/502 오류가 PRD 20장의 `errorProperty`와 HTTP status를 따른다. +- page는 음수이면 0, size는 1 미만이면 20, 50 초과이면 50으로 정규화한다. 기존 소비자용 page 정책은 변경하지 않는다. +- character-scoped 작업은 `AiCharacterAdminTarget` 해석 후 각 리소스 소유권을 다시 검증한다. +- global 원작 CRUD는 character target 없이 수행하고, 배정·해제만 Request의 전체 AI 캐릭터 집합과 현재 원작 귀속을 검증한다. +- 목록·상세·mutation JSON field, nullable, 기본값, 정렬, UTC 규칙은 PRD 9장과 각 Operation의 Request/Response JSON을 그대로 따른다. +- `CONTENT-01`, `CONTENT-02`의 Signed URL과 만료 시각은 같은 절대 `Instant`를 사용하고 `Cache-Control: private, no-store`를 반환한다. +- 사람 관리자 mutation은 `adminMemberId`, `characterId`, `creatorMemberId`, `action`, `resourceType`, `resourceId`, `result`를 구조화 로그에 남긴다. global 원작 CRUD의 두 캐릭터 field는 `null`이고 배정·해제는 캐릭터별 로그를 남긴다. +- 신규 v2 domain/application/port에서 legacy Service·Repository·web DTO와 JPA entity/Q type import가 검출되지 않는다. +- 기존 로그인, 메뉴, callback, 예약 공개 scheduler와 소비자용 v2 API의 회귀 테스트가 통과한다. + +### 1.3 구현 전 기술 Gate + +다음은 제품·API·UX 미결정 사항이 아니라 외부 시스템의 실제 지원 범위를 확인하는 구현 선행 Gate다. + +1. Weraser 외부 캐릭터 API의 create/update idempotency 전달 방식, 생성 결과 식별자, nullable field 삭제·빈 목록 전체 삭제 표현, remote 현재값 조회, 보상 update 지원 여부를 운영 계약 또는 실제 client 문서와 대조한다. +2. 지원하지 않는 기능을 임의 header나 가짜 delete 호출로 만들지 않는다. PRD의 보상 요구를 충족할 수 없으면 코드 작성 전에 PRD에 지원 가능한 보상·divergence 처리 경계를 명시하고 승인받는다. +3. 외부 캐릭터 레코드를 물리 삭제하거나 `inactive_*`로 rename하지 않는다. +4. 실제 worker/Trigger 코드는 이 저장소 밖에 있으므로 staging에서 v2 생성 input object가 기존 callback으로 완료되는 E2E를 배포 전 1회 확인한다. S3 저장과 DB commit 사이에 callback이 도착할 때 worker가 조회 실패를 retry하는지도 확인하며, 이 검증을 위해 worker 코드·설정이나 새 callback을 만들지는 않는다. +5. 배포 전 운영 데이터에서 `isDeleted=true` 원작을 참조하는 캐릭터 건수를 읽기 전용으로 확인한다. 0건이면 mutation 전환을 진행한다. 1건 이상이면 자동 해제·backfill을 추가하지 않고 대상과 영향 범위를 보고해 별도 승인된 데이터 보정을 완료한 뒤 mutation 전환을 활성화한다. +6. H2 test만으로 MySQL 잠금 의미를 대신하지 않는다. 배포 전 MySQL 8 환경의 서로 다른 connection에서 동일 제목 생성·수정, 원작 삭제 대 배정·해제·CHAR-03/04를 동시에 실행해 중복 활성 제목과 삭제 원작 참조가 모두 0건인지 확인한다. 실패하면 schema 변경 없이 안전하다고 간주하지 않고 배포를 중단해 PRD를 재검토한다. + +## 2. 파일 구조 계획 + +### 2.1 공통 관리자 계약 + +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminCommonDtos.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicy.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLogger.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminJsonRequestParser.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminImagePartValidator.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandler.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAuthenticationEntryPoint.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAccessDeniedHandler.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutor.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/common/SodaException.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt` + +### 2.2 AI 캐릭터 + +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterAdminTarget.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterModels.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterPolicy.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterQueryService.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterCommandService.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterAdminTargetResolver.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterPersistencePort.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterExternalPort.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterImageStoragePort.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterEventPort.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapter.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/external/WeraserAiCharacterAdapter.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/storage/S3AiCharacterImageStorageAdapter.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/event/AiCharacterEventAdapter.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/cache/AiCharacterVisibilityCacheInvalidator.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminAiCharacterDtos.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminAiCharacterFacade.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminAiCharacterController.kt` + +### 2.3 원작 + +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/domain/OriginalWorkAdminModels.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/domain/OriginalWorkAdminPolicy.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/application/OriginalWorkAdminService.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/port/in/OriginalWorkAdminUseCase.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/port/out/OriginalWorkAdminPort.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/port/out/OriginalWorkImageStoragePort.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/port/out/OriginalWorkEventPort.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/adapter/out/persistence/DefaultOriginalWorkAdminRepository.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/adapter/out/storage/S3OriginalWorkImageStorageAdapter.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/adapter/out/event/OriginalWorkEventAdapter.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminOriginalWorkDtos.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminOriginalWorkFacade.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminOriginalWorkController.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/LegacyOriginalWorkMutationAdapter.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/LegacyAiCharacterMutationAdapter.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkController.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/osiv/OsivLazyLoadingRegressionTest.kt` + +### 2.4 콘텐츠와 기존 callback·scheduler 호환 + +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/domain/ContentModels.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/domain/ContentPolicy.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/domain/ContentStatusPolicy.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/domain/ContentOutputKeyPolicy.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/application/ContentManagementService.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/port/in/ContentCreatorDeactivationUseCase.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/port/out/ContentManagementPort.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/port/out/ContentFileStoragePort.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/port/out/SignedAudioUrlPort.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/port/out/ContentEventPort.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/persistence/DefaultContentManagementRepository.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/storage/S3ContentFileStorageAdapter.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/cloudfront/CloudFrontSignedAudioUrlAdapter.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/event/ContentEventAdapter.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminAiContentDtos.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminAiContentFacade.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminAiContentController.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/scheduler/AudioContentReleaseScheduledTask.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/AudioContentCloudFront.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/aws/s3/S3Uploader.kt` + +### 2.5 나머지 creator 작업 도메인 + +- Content comment: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/comment` +- Content category: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/category` +- Series admin extension: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series` +- Community admin extension: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community` +- FanTalk admin extension: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` +- Channel notice: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/notice` +- Channel profile: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/profile` +- 위 패키지의 정확한 파일은 해당 Phase의 Task에 명시한다. + +## 3. 구현·배포 순서 + +```text +Phase 0 기존 callback·소비자 계약 고정 + -> Phase 1 공통 계약/보안/대상 해석 + -> Phase 2 CHAR-01~04와 ORIGINAL-WORK-01~08 + -> Phase 3 계산 status와 기존 callback/scheduler 안전 조건 + -> Phase 4~9 콘텐츠·하위 도메인 53개 Operation + -> Phase 10 CHAR-05 삭제 cascade/legacy 호환 + -> Phase 11 전체 계약/경계/회귀 검증 +``` + +운영 DB schema 선행 작업은 없다. 배포 diff에서 `content`·원작 관련 테이블 DDL, JPA 컬럼·관계 mapping, V1/V2 dispatcher와 신규 scheduler가 추가되지 않았는지 확인한다. + +### 3.1 TDD 공통 실행 규칙 + +- 각 Task의 `실패 확인` 명령은 production 변경 전에 실행해 신규 기대가 실패하는지 확인한다. +- 구현 시작 전에 기준 commit hash와 이미 존재하는 dirty/untracked file manifest를 5장에 기록한다. Task 11.3의 금지 변경 검증은 이 기준 이후 이번 구현이 만든 diff만 판정한다. +- `GREEN` 구현 직후에는 같은 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. 이 재실행은 각 Task의 GREEN 검증 항목이며 실제 결과를 5장에 기록한다. +- `REFACTOR` 후에도 같은 명령과 Task에 명시된 인접 회귀 test를 다시 실행한다. +- `TDD 예외` Task는 명시한 대체 검증을 실행하고 의도적인 실패를 만들지 않는다. +- Gradle test는 kapt 임시 파일 충돌을 피하도록 문서 순서대로 실행하며 서로 병렬 실행하지 않는다. + +--- + +### Phase 0: 기존 callback·소비자 계약 고정 + +- [ ] **Task 0.1: 변경 전 callback·소비자 API 계약을 회귀 테스트로 고정** + - Files: + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentUploadCompletionContractTest.kt` + - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterControllerTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkControllerContractTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkControllerContractTest.kt` + - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt` + - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt` + - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt` + - RED: TDD 예외 사유: 변경 대상이 아닌 기존 callback·소비자 계약을 characterization test로 고정하는 작업이므로 의도적인 production 결함을 먼저 만들지 않는다. + - 대체 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.chat.original.controller.OriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest`를 실행해 기존 `PUT /audio-content/upload-complete`, legacy 캐릭터·원작 관리자, 일반 사용자용 원작 API, BOT/ADMIN 인가, Request/Response 및 소비자 API 계약 중 현재 구현과 어긋난 지점이 있으면 먼저 조사하고, 모두 일치하면 최초 통과 결과를 baseline으로 기록한다. legacy 원작 mutation은 이후 같은 v2 정책으로 수렴하므로 이 Task에서는 Method·Path·Request·성공 Response를 고정하고 잘못된 mutation을 성공시키는 내부 동작을 호환 계약으로 고정하지 않는다. + - GREEN: 이 Task에서는 신규 route를 구현하지 않는다. callback과 기존 소비자 API가 현재 상태에서 통과하는지 baseline을 기록한다. + - REFACTOR: 테스트 fixture는 실제 Spring mapping과 응답 surface를 검증하며 운영 코드를 위한 범용 endpoint registry를 만들지 않는다. + - 기대 결과: 기존 callback, legacy 캐릭터·원작 관리자와 소비자 API 계약이 초록색 baseline으로 고정된다. + +### Phase 1: 공통 관리자 계약, 보안, 대상 해석 + +- [ ] **Task 1.1: 공통 page/응답/multipart JSON 계약 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminCommonDtos.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicy.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminJsonRequestParser.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminImagePartValidator.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicyTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminJsonRequestParserTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminImagePartValidatorTest.kt` + - RED: page `null/-1/0`, size `null/0/1/20/50/51`, page response `hasNext`, multipart JSON과 일반 JSON body의 필수 key 누락/명시적 `null` 구분, 이미지 bytes의 실제 MIME과 `allowGif` 조건을 테스트한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AdminPagePolicyTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: `AdminPageResponse`, 공통 mutation/comment request와 PRD의 정규화 규칙만 구현한다. `AdminJsonRequestParser`는 multipart JSON string과 `JsonNode` 모두에서 required nullable key를 검증하고, `AdminImagePartValidator`는 v2 web adapter에서 실제 MIME을 검사한다. domain port에는 admin DTO나 Spring `Pageable`을 넘기지 않고 정규화된 offset/limit을 전달한다. + - REFACTOR: 기존 `CreatorChannel*QueryPolicy`는 size 1~19 처리 계약이 다르므로 수정하거나 재사용하지 않는다. + - 기대 결과: 모든 관리자 목록과 multipart update가 하나의 명시적 계약을 사용한다. + +- [ ] **Task 1.2: `/admin/ai-characters/**` 전용 오류·인증 응답 경계 구현** + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/common/SodaException.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandler.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAuthenticationEntryPoint.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAccessDeniedHandler.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminSecurityIntegrationTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandlerTest.kt` + - RED: 기존 `POST /admin/member/login` 응답 token으로 신규 API 호출 성공, 무JWT 401, 잘못된 JWT 401, `USER/CREATOR/AGENT/CONTENT_MANAGER` 403, ADMIN 통과 및 400/404/409/500/502별 `ApiResponse` body와 `errorProperty`를 검증한다. 같은 예외가 legacy route에서는 기존 HTTP 200 관례를 유지하는 회귀 케이스도 추가한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminSecurityIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminExceptionHandlerTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: `SodaException` 생성자 끝에 legacy 기본 동작을 보존하는 선택 HTTP status를 추가하고, 우선순위가 높은 admin Controller 범위 advice와 path-specific security handler만 그 status를 응답에 사용한다. + - REFACTOR: 기존 `SodaExceptionHandler`, `JwtAuthenticationEntryPoint`, `JwtAccessDeniedHandler`의 응답을 변경하지 않는다. + - 기대 결과: 신규 관리자 API만 PRD 20장의 status/envelope를 사용하고 legacy API는 영향받지 않는다. + +- [ ] **Task 1.3: AI 캐릭터 관리자 대상 해석과 owner 입력 차단 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterAdminTarget.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterAdminTargetResolver.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterPersistencePort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapter.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterAdminTargetResolverTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapterTest.kt` + - RED: 존재하지 않는 character 404, 연결 Member 없음/`role!=CREATOR`/`memberKind!=AI_CHARACTER` 404, 생성·수정 시 둘 중 하나 비활성 409, 삭제·상태 조회 시 비활성 target 반환을 검증한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterAdminTargetResolverTest --tests kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.persistence.DefaultAiCharacterPersistenceAdapterTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: PRD의 여섯 필드만 갖는 `AiCharacterAdminTarget`을 반환하고, web body에는 `creatorId`/writer ID를 추가하지 않는다. + - REFACTOR: JPA `ChatCharacter`, `Member`, Q type은 persistence adapter 밖으로 노출하지 않는다. + - 기대 결과: 모든 character-scoped facade가 동일한 대상 해석 결과를 사용한다. + +- [ ] **Task 1.4: direct after-commit 실행과 구조화 관리자 audit 기반 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutor.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLogger.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutorTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitEventBoundaryIntegrationTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLoggerTest.kt` + - RED: direct callback의 commit 후 1회 실행, rollback/동일 command 재시도에서 미실행·비중복, transaction 안에서 publish한 기존 FCM/언어 event listener가 commit 후 실행되고 유실되지 않는지, mutation 성공/실패 audit field와 민감 본문 미기록을 검증한다. global 원작 CRUD는 nullable character field를 허용하고 원작 배정·해제는 캐릭터별 context를 요구하는지도 고정한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: `AfterCommitExecutor`는 home news 같은 direct callback만 Spring transaction synchronization에 등록한다. 기존 `FcmEvent`, `LanguageDetectEvent`, `LanguageTranslationEvent(waitTransactionCommit=true)`는 transaction 안에서 publish하고 각 listener의 AFTER_COMMIT 경계를 유지한다. structured audit logger는 global/character-scoped context를 명시적으로 구분하는 정도로만 추가하며 audit table이나 AOP framework는 만들지 않는다. + - REFACTOR: 비밀번호, JWT, system prompt 전체, 댓글/게시글 본문, 업로드 파일 내용이 logger argument에 들어갈 수 없도록 audit context를 ID와 enum 중심으로 제한한다. + - 기대 결과: 이후 facade와 event adapter가 같은 commit/audit 원칙을 반복 구현하지 않는다. + +### Phase 2: AI 캐릭터와 원작 관리 (`CHAR-01`~`CHAR-04`, `ORIGINAL-WORK-01`~`ORIGINAL-WORK-08`) + +- [ ] **Task 2.1: 캐릭터 policy, 관리자 projection과 persistence command 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterModels.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterPolicy.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterPersistencePort.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapter.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterPolicyTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapterTest.kt` + - RED: characterType, age, ISO region, 양수 원작 ID의 `isDeleted=false` 검증, 생성 `null` 미연결, 수정 명시적 `null` 해제와 `0` 거부, 중첩 문자열 trim, 이름 예약/중복, 목록 검색·상태·정렬, 상세의 systemPrompt 위치, character와 AI creator Member의 unique 1:1 동시 저장·동기화를 검증한다. 신규 Member는 `role=CREATOR`, `memberKind=AI_CHARACTER`, `email=null`, `password=""`이고 일반·크리에이터 관리자 로그인 대상이 아님을 고정한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.aicharacter.domain.AiCharacterPolicyTest --tests kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.persistence.DefaultAiCharacterPersistenceAdapterTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: 관리자 전용 domain model/projection과 v2 persistence query/command를 구현한다. 캐릭터 생성 시 `role=CREATOR`, `memberKind=AI_CHARACTER` Member를 연결하고 수정 시 nickname/profileImage/introduce를 동기화한다. + - REFACTOR: legacy `AdminChatCharacterService`, repository와 DTO를 주입하지 않고 기존 entity mapping만 adapter에서 사용한다. + - 기대 결과: 외부 시스템과 web 계층 없이도 캐릭터 데이터 규칙이 고정된다. + +- [ ] **Task 2.2: 외부 캐릭터·이미지 port와 보상 경계 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterExternalPort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterImageStoragePort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/external/WeraserAiCharacterAdapter.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/storage/S3AiCharacterImageStorageAdapter.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/aws/s3/S3Uploader.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/external/WeraserAiCharacterAdapterTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/storage/S3AiCharacterImageStorageAdapterTest.kt` + - RED: 기술 Gate에서 확인한 create/update/idempotency 계약, 검증 완료 이미지의 upload/delete 보상, timeout/4xx/5xx의 502 매핑을 먼저 고정한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.external.WeraserAiCharacterAdapterTest --tests kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.storage.S3AiCharacterImageStorageAdapterTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: command별 `requestId`를 두 port에 전달하고, 외부/파일 생성 후 DB 실패 시 지원되는 보상을 실행한다. 보상 미지원·실패는 request/resource/stage만 orphan/divergence 로그에 남긴다. + - REFACTOR: 외부 API key, bucket, 원본 prompt나 파일을 로그에 기록하지 않고 기존 설정 주입 방식을 유지한다. + - 기대 결과: 외부 실패가 부분 성공으로 반환되지 않고 재처리 근거가 남는다. + +- [ ] **Task 2.3: 캐릭터 query/create/update service와 side effect 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterQueryService.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterCommandService.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterEventPort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/event/AiCharacterEventAdapter.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterQueryServiceTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterCommandServiceTest.kt` + - RED: 생성의 외부 create→로컬 character/Member flush로 ID 확보→`characters/{characterId}` 이미지 저장→DB commit 각 단계 실패와 commit 예외 보상, 수정 remote 성공 후 DB 실패의 compensating update/divergence를 검증한다. 수정에서는 nullable field·`originalWorkId`의 명시적 null과 목록의 `[]`가 local/remote 양쪽에서 실제 clear로 표현되는지, 양수 원작의 `isDeleted=false` 검증과 `0` 거부, 이미지 생략 유지, region 변경 불가, 비활성 수정 409, 등록 description 언어 감지와 수정 번역의 commit 후 1회 실행도 검증한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterQueryServiceTest --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterCommandServiceTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: query/create/update orchestration만 구현하고 delete cascade는 하위 도메인 deactivation port가 준비되는 Phase 10까지 보류한다. 외부·파일 작업이 포함된 command는 `TransactionTemplate` 경계 밖에서 commit 예외까지 포착해 생성 리소스 삭제 또는 remote compensating update를 실행한다. + - REFACTOR: requestId 생성, 보상 순서, after-commit event 발행을 작은 private 함수로만 분리하고 범용 workflow engine을 만들지 않는다. + - 기대 결과: CHAR-01~04의 업무 흐름이 web DTO와 분리되어 검증된다. + +- [ ] **Task 2.4: CHAR-01~04 DTO, facade, Controller contract 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminAiCharacterDtos.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminAiCharacterFacade.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminAiCharacterController.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminAiCharacterControllerIntegrationTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` + - RED: PRD 11장의 CHAR-01/02 JSON 전체 field·nullable·UTC, CHAR-03/04 multipart part 이름·필수 key·기본값과 이미지 실제 MIME/GIF 거부, 목록에 systemPrompt 미노출, 생성된 AI Member의 일반·크리에이터 관리자 로그인 거부, ADMIN 인가, 400/404/409/502, audit success/failure를 MockMvc로 고정한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminAiCharacterControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: Controller는 JSON key와 image bytes를 외부 호출 전에 검증하고 principal에서 실제 `adminMemberId`만 추출해 facade에 전달한다. facade는 target 해석과 use case 호출/응답 변환/audit만 담당한다. + - REFACTOR: multipart `request`는 `AdminJsonRequestParser`를 사용하고 DTO에 `creatorId`, `characterId`, `isActive` writable field를 추가하지 않는다. route inventory는 이 Task에서 구현한 CHAR-01~04만 실제 mapping과 대조하고 이후 Phase가 자기 Operation을 누적한다. + - 기대 결과: CHAR-01~04 mapping이 route inventory에서 통과하고 PRD JSON으로 호출 가능하다. + +- [ ] **Task 2.5: 원작 policy, 관리자 projection과 기존 mapping 기반 persistence 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/domain/OriginalWorkAdminModels.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/domain/OriginalWorkAdminPolicy.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/port/out/OriginalWorkAdminPort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/adapter/out/persistence/DefaultOriginalWorkAdminRepository.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/originalwork/domain/OriginalWorkAdminPolicyTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/originalwork/adapter/out/persistence/DefaultOriginalWorkAdminRepositoryTest.kt` + - RED: 필수 문자열 trim·빈 값, nullable 빈 문자열의 null 정규화, http/https 링크, 링크·태그 첫 등장 순서 보존과 중복 제거, trim·대소문자 무시 생성/수정 제목 충돌과 현재 ID 제외, 수정 11개 key 전체 교체를 검증한다. link ID·tag-mapping ID 명시 정렬과 요청 순서 재생성, `isDeleted=false` 목록·대소문자 무시 검색·`createdAt DESC,id DESC`·공통 page, 상세, creator 누락을 포함한 활성·비활성 연결 캐릭터 목록과 전체 `ChatCharacter` 기준 `characterCount`, 연결 0건 삭제·연결 존재 409·이미 삭제된 상태 우선의 반복 삭제도 기존 entity와 관계를 사용한 persistence test로 고정한다. 양수 target/path 원작 `PESSIMISTIC_WRITE`와 캐릭터 ID 오름차순 잠금 query, null 해제의 캐릭터 단독 잠금도 test에서 확인한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.originalwork.domain.OriginalWorkAdminPolicyTest --tests kr.co.vividnext.sodalive.v2.originalwork.adapter.out.persistence.DefaultOriginalWorkAdminRepositoryTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: v2 원작 command/query record와 policy를 만들고 adapter가 `EntityManager`·QueryDSL로 기존 `OriginalWork`, link, tag와 `ChatCharacter.originalWork`를 읽고 쓴다. 양수 연결 대상 또는 Path 원작이 있는 relation mutation은 원작을 먼저, 캐릭터를 ID 오름차순으로 잠그고 잠금 뒤 상태·귀속을 재검증한다. `CHAR-04`의 `null`과 legacy 수정의 `0` 해제는 캐릭터만 잠그고 재검증한다. domain/application에는 legacy entity·Q type을 반환하지 않는다. + - REFACTOR: legacy `OriginalWorkRepository`, `OriginalWorkTagRepository`, `ChatCharacterRepository`를 주입하지 않고 새 `@Entity`, relation mapping, DDL 또는 data migration을 만들지 않는다. + - 기대 결과: 원작 CRUD·검색·연결 조회의 데이터 규칙이 legacy service 없이 고정된다. + +- [ ] **Task 2.6: 원작 application, 이미지·언어 event와 원자적 캐릭터 배정 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/application/OriginalWorkAdminService.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/port/in/OriginalWorkAdminUseCase.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/port/out/OriginalWorkImageStoragePort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/port/out/OriginalWorkEventPort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/adapter/out/storage/S3OriginalWorkImageStorageAdapter.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/adapter/out/event/OriginalWorkEventAdapter.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/aws/s3/S3Uploader.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterCommandService.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/originalwork/application/OriginalWorkAdminServiceTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/originalwork/adapter/out/storage/S3OriginalWorkImageStorageAdapterTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/aws/s3/S3UploaderTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/originalwork/adapter/out/event/OriginalWorkEventAdapterTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterCommandServiceTest.kt` + - RED: 생성 DB·`originals/{originalWorkId}/...` 이미지 저장·commit 각 실패의 새 이미지 보상, 수정 이미지 생략 유지와 실패 보상, 기존 이미지 보존, 삭제 시 이미지 미삭제를 검증한다. storage port의 `store(originalWorkId, validatedImage, requestId, attemptNumber)`와 `delete(objectKey)`, 서버 생성 `requestId`, attempt별 고유 key, 저장 실패 502, 저장 후 비재시도 DB 실패와 보상 성공·실패의 500 및 구조화 ERROR orphan 로그를 구분한다. 첫 attempt가 이미지 저장 후 commit deadlock이면 첫 key를 삭제한 뒤에만 두 번째 `REQUIRES_NEW` `SERIALIZABLE` transaction을 시작하고, 두 번째 성공 후 S3에는 최종 key 하나만 남아야 한다. 첫 key 보상 실패 시 재시도하지 않으며, retry 뒤 실제 중복만 409이고 재시도 소진은 500인지 검증한다. 생성 언어 감지와 정규화된 `title`, `contentType`, `category`, `description`, `tags`가 실제 바뀐 수정의 번역 갱신은 commit 후 1회, 다른 field 변경·rollback·동일 값 수정·배정·해제는 0회여야 하며 감지 결과 commit 전 후속 번역이 시작되지 않아야 한다. 배정은 중복 없는 전건 활성 AI 캐릭터 검증 후 다른 원작에서 이동하고 같은 원작 반복은 멱등해야 하며, 해제는 creator 상태와 무관하게 기존 캐릭터의 Path 원작 귀속을 전건 검증한다. 배정의 누락·비활성 ID 또는 해제의 누락·미연결·다른 원작 ID가 있으면 부분 변경 0건인지 확인한다. CHAR-03/04도 같은 `isDeleted=false`, 신규 null/`0` 규칙 및 relation lock을 사용하는지 고정한다. concurrent 배정과 삭제, 연결 변경과 삭제를 latch 기반 통합 test로 교차 실행해 삭제 원작 참조가 0건인지 검증하고, 동시 동일 제목 생성·수정의 transaction isolation과 재시도 정책도 확인한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.originalwork.application.OriginalWorkAdminServiceTest --tests kr.co.vividnext.sodalive.v2.originalwork.adapter.out.storage.S3OriginalWorkImageStorageAdapterTest --tests kr.co.vividnext.sodalive.v2.originalwork.adapter.out.event.OriginalWorkEventAdapterTest --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterCommandServiceTest --tests kr.co.vividnext.sodalive.aws.s3.S3UploaderTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: 원작 CRUD와 배정·해제 transaction을 `OriginalWorkAdminService`와 input port에 구현한다. image port에는 `originalWorkId`, 검증된 image, requestId와 attempt number를 전달하고 반환된 exact object key를 attempt별로 기록한다. 비transactional orchestration은 외부 이미지 저장을 포함한 각 DB attempt를 하나의 `REQUIRES_NEW` `TransactionTemplate.execute`로 감싸 commit 예외까지 포착하며 outer REQUIRED transaction이나 중첩 template을 만들지 않는다. 생성과 제목이 실제 바뀌는 수정에만 `SERIALIZABLE` isolation을 적용하고, 제목이 바뀌지 않는 수정은 기본 isolation을 사용한다. retry 가능한 실패는 해당 attempt key 보상 성공 후에만 새 transaction으로 최대 한 번 재시도한다. 재조회에서 실제 중복이 확인되면 409로, 비재시도·재시도 소진 또는 보상 실패는 500으로 끝낸다. 언어 event는 원작 transaction commit 후 시작하고 캐릭터 command는 같은 v2 원작 참조·잠금 규칙을 사용한다. + - REFACTOR: legacy 원작 Service/Repository/DTO, Controller의 S3 직접 호출, v2 public/domain command의 `0` sentinel, 부분 성공과 범용 workflow engine을 도입하지 않는다. legacy adapter의 기존 등록 `0 -> 미연결`, 수정 `0 -> 해제` 변환은 호환 경계에만 둔다. 공용 `S3Uploader` 확장은 정확한 bucket·object key를 받는 `delete` 한 메서드로 제한하고 존재 확인·prefix 삭제·정리 scheduler를 추가하지 않는다. + - 기대 결과: 이미지·DB·언어 side effect와 캐릭터 관계 변경이 명시적인 실패·rollback 계약을 갖는다. + +- [ ] **Task 2.7: ORIGINAL-WORK-01~08 DTO, facade, Controller contract 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminOriginalWorkDtos.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminOriginalWorkFacade.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminOriginalWorkController.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/LegacyOriginalWorkMutationAdapter.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkController.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminOriginalWorkControllerIntegrationTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/original/LegacyOriginalWorkMutationAdapterTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkControllerContractTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/osiv/OsivLazyLoadingRegressionTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` + - RED: PRD 11.7의 8개 exact method/path, Query, 전체 envelope JSON field·nullable·UTC, 생성/수정 multipart part와 11개 key, 이미지 실제 MIME/GIF 거부, DELETE JSON body와 proxy 통과, ADMIN 인가와 정확한 400/404/409/500/502를 MockMvc로 고정한다. assignment response의 요청 순서 전체 ID와 작업 후 전체 `characterCount`, 같은 원작 멱등 ID, nullable `creatorId`, global CRUD audit의 nullable character field와 배정·해제 캐릭터별 audit도 검증한다. legacy 원작 5개 mutation route는 Method·Path·Request·성공 `data=null`을 유지하면서 같은 v2 원작 input port를 호출하고, legacy update의 null은 잠금 transaction 안에서 현재 값 유지로 병합되며 연결 삭제·부분 배정·오귀속 해제는 더 이상 성공하지 않는지 확인한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminOriginalWorkControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest --tests kr.co.vividnext.sodalive.admin.chat.original.LegacyOriginalWorkMutationAdapterTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.osiv.OsivLazyLoadingRegressionTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: 신규 Controller는 `AdminJsonRequestParser`, `AdminImagePartValidator`와 실제 `adminMemberId`만 사용하고 facade는 원작 use case 호출, Response 변환과 audit만 담당한다. `ORIGINAL-WORK-08`은 `Content-Type: application/json` DELETE body를 명시적으로 매핑한다. legacy 원작 compatibility adapter는 legacy DTO와 multipart의 non-null update field만 v2 호환 patch command로 변환한다. 현재 값 병합은 adapter 선조회가 아니라 v2 application이 원작 row를 잠근 transaction 안에서 수행한다. 원작 조회는 기존 service를 유지하고 legacy 원작 5개 direct mutation만 v2 input으로 수렴한다. legacy 캐릭터가 아직 호출하는 `assignOneCharacter`는 Phase 10의 전체 캐릭터 호환 전환 전까지만 남기고, 나머지 미사용 direct mutation 메서드와 그로 인해 불필요해진 dependency만 제거해 OSIV 회귀 fixture의 constructor를 맞춘다. + - REFACTOR: 신규 v2에 별도 `/search`, legacy와 같은 `/register`, `/update`, `/assign-characters`, `/unassign-characters`, 원작 복구 또는 공개 원작 Endpoint를 추가하지 않는다. v2가 legacy adapter나 DTO를 import하지 않고 legacy Controller가 신규 Controller를 호출하지 않는다. + - 기대 결과: CHAR-01~04와 ORIGINAL-WORK-01~08의 12개 mapping이 route inventory를 통과하고 PRD JSON으로 호출 가능하며, legacy 원작 5개 mutation도 외부 성공 계약을 유지한 채 같은 불변식을 적용한다. + +### Phase 3: 계산 status와 기존 callback·scheduler 안전 조건 + +- [ ] **Task 3.1: 기존 컬럼 기반 content status와 Signed output key 정책 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/domain/ContentStatusPolicy.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/domain/ContentOutputKeyPolicy.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/management/domain/ContentStatusPolicyTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/management/domain/ContentOutputKeyPolicyTest.kt` + - RED: `!isActive && releaseDate==null -> DELETED`, creator 비활성 `SUSPENDED`, active `PUBLISHED`, duration null `PROCESSING`, 가공 완료·미래 공개 `SCHEDULED`, 나머지 `SUSPENDED` 우선순위와 fixed UTC `Clock`을 표 기반 test로 고정한다. output key는 정확한 `output/{contentId}/...`만 허용하고 URI scheme, host, 선행 slash/backslash, query/fragment, percent encoding, 빈 segment, `.`/`..`, 다른 content ID와 input/raw/preview를 거부하는지 검증한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.management.domain.ContentStatusPolicyTest --tests kr.co.vividnext.sodalive.v2.content.management.domain.ContentOutputKeyPolicyTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: DB에 status를 저장하지 않는 순수 `ContentStatusPolicy`와 Signed URL 발급 직전에만 호출하는 `ContentOutputKeyPolicy`를 구현한다. + - REFACTOR: 계산 status와 output key 검증을 결합하거나 key를 정규화해 통과시키지 않는다. 한 요청의 목록 필터와 Response는 같은 `now`를 사용한다. + - 기대 결과: 신규 컬럼 없이 PRD 12.2의 status와 Signed URL 안전 조건을 재현한다. + +- [ ] **Task 3.2: 기존 callback·예약 공개의 삭제 및 비활성 creator guard 보강** + - Files: + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/scheduler/AudioContentReleaseScheduledTask.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentUploadCompletionContractTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentReleaseQueryTest.kt` + - RED: 기존 callback의 method/path/BOT·ADMIN/Request/`data={}`는 그대로인 상태에서 v2 생성과 동일한 기존 row가 정상 완료되는지 검증한다. 처리 중 삭제된 `releaseDate=null` row와 비활성 creator row는 callback이 output path·duration을 기록해도 raw `content.isActive=false`와 공개 FCM/home news 0회를 유지해야 한다. 비활성 creator의 과거 불일치 row가 raw `content.isActive=true`이면 callback 후 `false`로 보정한다. 활성 creator의 즉시 공개는 최초 false→true에서만 공개 side effect를 내며 동일 callback 재시도는 이를 중복하지 않아야 한다. 예약 공개 query는 `isActive=false`, non-null due releaseDate, non-null duration, 활성 creator를 모두 만족하는 row만 반환하는지 검증한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests kr.co.vividnext.sodalive.content.AudioContentReleaseQueryTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: 기존 `AudioContentService.uploadComplete`의 공개 조건에 non-null releaseDate, 활성 creator와 최초 활성 전이를 추가하고 비활성 creator의 raw `content.isActive`를 `false`로 유지·보정한다. 기존 release query에는 활성 creator 조건을 추가한다. Controller, Request/Response, scheduler component의 cron·lock은 변경하지 않는다. + - REFACTOR: 신규 callback Controller/DTO/use case, V1/V2 dispatcher, pipeline metadata와 v2 scheduler가 생기지 않았는지 diff를 확인한다. + - 기대 결과: 기존 callback과 scheduler를 모든 콘텐츠가 공용하면서 삭제 콘텐츠와 비활성 creator를 다시 공개하지 않는다. + +### Phase 4: 콘텐츠 관리 API (`CONTENT-01`~`CONTENT-07`) + +- [ ] **Task 4.1: 콘텐츠 생성·수정·삭제·고정 domain policy와 persistence 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/domain/ContentModels.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/domain/ContentPolicy.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/application/ContentManagementService.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/port/out/ContentManagementPort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/port/out/ContentEventPort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/persistence/DefaultContentManagementRepository.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/event/ContentEventAdapter.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/management/domain/ContentPolicyTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/management/application/ContentManagementServiceTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/persistence/DefaultContentManagementRepositoryTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/event/ContentEventAdapterTest.kt` + - RED: title/detail, price 0/1~4/5, 테마 12~14, purchaseOption, limited, preview pair/15초, 무료 preview, rental/full-detail 파생 규칙, 수정 불가 field, 기존 유료 무료 전환 금지, 소유권, 계산 status 전체의 삭제, 공개·최대 3개 pin 교체와 생성·수정 언어 작업의 commit/rollback/retry를 테스트한다. 생성은 `isActive=false`, `duration=null`, `releaseDate=request 값 또는 fixed Clock now`이고 status 필터와 Response 계산이 Task 3.1 policy와 일치하는지도 persistence test로 고정한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.management.domain.ContentPolicyTest --tests kr.co.vividnext.sodalive.v2.content.management.application.ContentManagementServiceTest --tests kr.co.vividnext.sodalive.v2.content.management.adapter.out.persistence.DefaultContentManagementRepositoryTest --tests kr.co.vividnext.sodalive.v2.content.management.adapter.out.event.ContentEventAdapterTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: 생성은 기존 `content` row에 `isActive=false`, non-null releaseDate와 input path를 저장하고, 삭제는 `isActive=false`, `releaseDate=null`로 기록한다. status는 저장하지 않고 Task 3.1 policy로 계산하며 pin은 조건부 update/가장 오래된 pin 교체로 구현한다. 생성·수정은 언어 코드가 없으면 감지, 있으면 번역 작업을 commit 후 1회 예약한다. + - REFACTOR: legacy `CreatorAdminContentService`, `AudioContentRepository`, theme service를 호출하지 않고 persistence adapter의 자체 query로 소유권과 기준정보를 검증한다. + - 기대 결과: CONTENT command 규칙과 원자성이 web/storage와 독립적으로 고정된다. + +- [ ] **Task 4.2: 콘텐츠 S3 업로드, Signed URL과 UTC 만료 계약 구현** + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/application/ContentManagementService.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/management/application/ContentManagementServiceTest.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/port/out/ContentFileStoragePort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/port/out/SignedAudioUrlPort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/storage/S3ContentFileStorageAdapter.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/cloudfront/CloudFrontSignedAudioUrlAdapter.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/AudioContentCloudFront.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/aws/s3/S3Uploader.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/storage/S3ContentFileStorageAdapterTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/cloudfront/CloudFrontSignedAudioUrlAdapterTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/AudioContentCloudFrontTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentUploadCompletionContractTest.kt` + - RED: 빈 audio 거부, 검증 완료 cover와 `input/{contentId}/{contentId}-content-...` key, 기존 `generateFileName(prefix = "${contentId}-content")` basename 규칙과 그 basename을 유지한 output key가 callback의 content ID 검증을 통과하는지 검증한다. `generate_preview` 및 선택 preview metadata만 전달되고 새 pipeline/callback metadata는 추가되지 않는지, preview 값이 DB projection이나 Response에 저장되지 않는지, 부분 업로드 보상, `(duration HH + 2)시간`, fixed Clock의 URL policy/응답 expiresAt 동일성, 서명 실패 500/no fallback도 검증한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.management.adapter.out.storage.S3ContentFileStorageAdapterTest --tests kr.co.vividnext.sodalive.v2.content.management.adapter.out.cloudfront.CloudFrontSignedAudioUrlAdapterTest --tests kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFrontTest --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: `AudioContentCloudFront`에 절대 `expiresAt: Instant` overload를 추가하고 기존 상대 TTL 함수를 유지한다. `ContentManagementService.create`는 `TransactionTemplate` 안에서 content ID를 만든 뒤 기존 filename 생성 규칙으로 `input/{contentId}/{contentId}-content-...` 업로드를 수행하고, execute/commit 예외를 바깥에서 포착해 DB rollback과 이미 생성된 object 보상을 실행한다. query service는 한 번 얻은 `now`에서 expiresAt을 계산해 port와 응답에 같은 값을 쓴다. + - REFACTOR: 기존 callback이 저장한 canonical output key와 duration은 query에서 읽기만 하고 생성된 Signed URL과 expiresAt은 domain/persistence에 저장하지 않는다. cover만 공통 CDN 절대 URL 변환을 사용한다. + - 기대 결과: 계산 status가 SCHEDULED/PUBLISHED이고 canonical key+duration이 있을 때만 Signed URL을 받고 raw path는 노출되지 않는다. + +- [ ] **Task 4.3: CONTENT-01~07 DTO, facade, Controller contract 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminAiContentDtos.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminAiContentFacade.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminAiContentController.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminAiContentControllerIntegrationTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` + - RED: CONTENT-01 검색/계산 status/유효 isActive 필터/정렬/전체 field와 `previewStartTime`, `previewEndTime` 미포함, CONTENT-02 재조회 URL 갱신과 두 preview field 미포함, 01/02 `private,no-store`, 03 multipart preview 입력·metadata/default/계산 PROCESSING과 cover 실제 MIME/GIF 거부, 04 immutable field와 선택 cover 검증, 05 delete, 06 pin, 07 비페이징 active theme JSON과 모든 오류/audit를 MockMvc로 고정한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminAiContentControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: facade가 target을 해석하고 콘텐츠 use case를 호출하며 DTO는 PRD 12장의 실제 JSON 이름과 nullability를 그대로 반환한다. status와 유효 isActive는 기존 필드와 creator 활성 상태로 계산하고 preview offset은 Response DTO에 두지 않는다. 파일 저장과 보상 orchestration은 `ContentManagementService`가 소유한다. + - REFACTOR: 목록 query에서 구매·성인 선호·차단 마스킹을 적용하지 않고, 브라우저용 URL refresh Endpoint를 추가하지 않는다. + - 기대 결과: CONTENT-01~07이 route inventory와 클라이언트 JSON 계약을 통과한다. + +### Phase 5: 콘텐츠 댓글 API (`CONTENT-COMMENT-01`~`CONTENT-COMMENT-05`) + +- [ ] **Task 5.1: 콘텐츠 댓글·답글 domain, persistence와 side effect 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/comment/domain/ContentCommentModels.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/comment/domain/ContentCommentPolicy.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/comment/application/ContentCommentManagementService.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/comment/port/out/ContentCommentManagementPort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/comment/port/out/ContentCommentEventPort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/comment/adapter/out/persistence/DefaultContentCommentManagementRepository.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/comment/adapter/out/event/ContentCommentEventAdapter.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/comment/domain/ContentCommentPolicyTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/comment/application/ContentCommentManagementServiceTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/comment/adapter/out/persistence/DefaultContentCommentManagementRepositoryTest.kt` + - RED: 루트/직접 답글 정렬, 동일 콘텐츠의 활성 루트에만 답글, 답글의 답글 거부, AI 작성자만 수정, 작성자 또는 콘텐츠 소유자의 삭제, 다른 owner의 Path 콘텐츠·댓글은 404, body의 부모 미존재·다른 콘텐츠 귀속·중첩 답글은 400과 `parentCommentId`, 반복 삭제, 필터 값과 무관한 활성 직접 replyCount를 검증한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.comment.domain.ContentCommentPolicyTest --tests kr.co.vividnext.sodalive.v2.content.comment.application.ContentCommentManagementServiceTest --tests kr.co.vividnext.sodalive.v2.content.comment.adapter.out.persistence.DefaultContentCommentManagementRepositoryTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: 댓글 writer는 body가 아니라 resolved `creatorMemberId`로 고정하고, 콘텐츠 알림과 언어 감지는 생성 transaction commit 후에만 발행한다. + - REFACTOR: legacy `AudioContentCommentService/Repository`를 호출하지 않고 entity/Q type은 persistence adapter에서만 변환한다. + - 기대 결과: 본문 수정 권한과 소유자 moderation 권한이 서로 분리된다. + +- [ ] **Task 5.2: CONTENT-COMMENT-01~05 DTO, facade, Controller contract 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminContentCommentDtos.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminContentCommentFacade.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminContentCommentController.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminContentCommentControllerIntegrationTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` + - RED: 두 목록의 root/direct-child 범위와 정렬, Query `isActive` 생략 시 true 및 false 명시 조회, PRD 13장의 response JSON, create 기본값, update body에 content만 허용, 타 작성자 update 404/403 대신 소유권 은닉 404, 소유 콘텐츠 타인 댓글 delete 성공과 audit를 MockMvc로 검증한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminContentCommentControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: facade가 character/content/comment 귀속을 순서대로 검증하고 공통 `AdminCommentUpdateRequest`와 mutation response를 매핑한다. + - REFACTOR: client가 writer/creator ID를 주입할 수 있는 field를 만들지 않는다. + - 기대 결과: CONTENT-COMMENT-01~05가 route inventory를 통과한다. + +### Phase 6: 콘텐츠 카테고리 API (`CATEGORY-01`~`CATEGORY-09`) + +- [ ] **Task 6.1: 콘텐츠 카테고리 domain, 구성·순서 persistence와 event 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/category/domain/ContentCategoryModels.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/category/domain/ContentCategoryPolicy.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/category/application/ContentCategoryManagementService.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/category/port/in/ContentCategoryCreatorDeactivationUseCase.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/category/port/out/ContentCategoryManagementPort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/category/port/out/ContentCategoryEventPort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/category/adapter/out/persistence/DefaultContentCategoryManagementRepository.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/category/adapter/out/event/ContentCategoryEventAdapter.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/category/domain/ContentCategoryPolicyTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/category/application/ContentCategoryManagementServiceTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/category/adapter/out/persistence/DefaultContentCategoryManagementRepositoryTest.kt` + - RED: title trim/2자/활성 중복, create contentIds 전건 동일 소유·활성·중복 없음, 활성 카테고리 전체 order 집합, 포함/가용 목록 정렬, add 전건 rollback, inactive link 재활성화, active 중복 409, 반복 remove `[]`, category delete 시 link 비활성화를 검증한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.category.domain.ContentCategoryPolicyTest --tests kr.co.vividnext.sodalive.v2.content.category.application.ContentCategoryManagementServiceTest --tests kr.co.vividnext.sodalive.v2.content.category.adapter.out.persistence.DefaultContentCategoryManagementRepositoryTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: category와 `CategoryContent`의 기존 테이블을 adapter에서 사용하고 create/title update의 언어 감지·번역을 commit 후 1회 발행한다. + - REFACTOR: category-content는 물리 삭제하지 않고 기존 `isActive` 및 `orders` 의미를 유지한다. + - 기대 결과: 카테고리 mutation과 구성 변경이 전건 검증 후 하나의 transaction으로 수행된다. + +- [ ] **Task 6.2: CATEGORY-01~09 DTO, facade, Controller contract 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminContentCategoryDtos.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminContentCategoryFacade.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminContentCategoryController.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminContentCategoryControllerIntegrationTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` + - RED: CATEGORY-01 page/filter/order/count, 02~04 mutation JSON, 05 정확한 전체 순서, 06/07 포함·가용 page, 08/09 affected IDs와 오류/audit를 PRD 18.1~18.7 JSON으로 검증한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminContentCategoryControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: target의 creator ID만 use case에 넘기고 모든 ID 집합 오류를 요청 전체 실패로 매핑한다. + - REFACTOR: 콘텐츠 응답에 raw cover path를 노출하지 않고 공통 CDN absolute URL만 사용한다. + - 기대 결과: CATEGORY-01~09가 route inventory를 통과한다. + +### Phase 7: 시리즈 API (`SERIES-01`~`SERIES-11`) + +- [ ] **Task 7.1: 관리자 시리즈 domain, persistence, image와 event 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorSeriesAdminModels.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorSeriesAdminPolicy.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorSeriesAdminService.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/port/in/SeriesCreatorDeactivationUseCase.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/port/out/CreatorSeriesAdminPort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/port/out/SeriesCoverStoragePort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/port/out/CreatorSeriesEventPort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorSeriesAdminRepository.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/storage/S3SeriesCoverStorageAdapter.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/event/CreatorSeriesEventAdapter.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorSeriesAdminPolicyTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorSeriesAdminServiceTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorSeriesAdminRepositoryTest.kt` + - RED: 요일 허용값/빈 배열/RANDOM 단독, 활성 genre, update nullable writer/studio와 필수 key, 상태, 동일 소유권, 검증 완료 이미지의 저장 보상, 포함·가용 정렬, add 전건 rollback과 기존 활성 연결 중복 409, join row 물리 제거/반복 remove, 활성 시리즈 전체 order 집합, 언어 event commit 후 1회를 검증한다. SERIES-11 장르 응답에는 PRD에 없는 순서 assertion을 추가하지 않는다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorSeriesAdminPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorSeriesAdminServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorSeriesAdminRepositoryTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: 기존 `Series`, `SeriesContent`, genre 테이블을 persistence adapter에서 사용하고 관리자 projection을 별도로 구현한다. + - REFACTOR: 기존 `CreatorChannelSeriesQueryService`와 소비자 DTO는 수정하지 않고, 공통화가 정확히 일치하는 CDN 변환 외에는 관리자 query에 끌어오지 않는다. + - 기대 결과: 관리자 시리즈 계약이 소비자 필터/마스킹과 독립적으로 동작한다. + +- [ ] **Task 7.2: SERIES-01~11 DTO, facade, Controller contract 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminSeriesDtos.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminSeriesFacade.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminSeriesController.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminSeriesControllerIntegrationTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` + - RED: SERIES-01의 search/isActive/state 필터와 `order ASC, seriesId ASC`, SERIES-01/02 field·nullable·UTC, SERIES-03 문자열 trim/nonblank·`isAdult=false`·nullable writer/studio 기본값, 03/04 multipart·누락/null 구분과 이미지 실제 MIME/GIF 거부, 05 logical delete 후 포함 content 보존, 06/07 page/정렬, 08 기존 활성 연결 중복 409와 08/09 affected IDs, 10 전체 order, 11 비페이징 genre와 오류/audit를 MockMvc로 고정한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminSeriesControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: PRD 14장의 method/path/request/response를 그대로 mapping하고 image part 생략 시 기존 이미지를 유지한다. + - REFACTOR: PRD가 요일 response 배열과 genre metadata의 순서를 정의하지 않았으므로 특정 순서 assertion을 만들지 않고 값의 보존만 검증한다. + - 기대 결과: SERIES-01~11이 route inventory를 통과한다. + +### Phase 8: 커뮤니티 게시글·댓글 API + +- [ ] **Task 8.1: 관리자 커뮤니티 게시글 domain, persistence, file과 event 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorCommunityAdminModels.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorCommunityAdminPolicy.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorCommunityAdminPostService.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/in/CommunityCreatorDeactivationUseCase.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorCommunityAdminPort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorCommunityFilePort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorCommunityEventPort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorCommunityAdminRepository.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/storage/S3CreatorCommunityFileAdapter.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/event/CreatorCommunityEventAdapter.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorCommunityAdminPolicyTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorCommunityAdminPostServiceTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorCommunityAdminRepositoryTest.kt` + - RED: 전체 본문 관리자 projection, fixed/created 정렬, content trim 후 nonblank, price 기본 0과 0 이상, 유료 또는 audio 첨부 시 image 필수, update의 price/audio 불변, image 유지, delete 소유권과 하위 댓글 row 보존, 활성 게시글만 fixed, 캐릭터별 최대 3개 및 네 번째 고정은 자동 교체 없이 409, 검증 완료 파일의 부분 업로드 보상, 구독자 알림과 무료 home news commit 후 1회를 검증한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorCommunityAdminPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorCommunityAdminPostServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorCommunityAdminRepositoryTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: 게시글 persistence/storage/event port와 관리자 전용 service를 구현한다. storage adapter는 web 경계에서 검증된 file만 받아 저장하고 DB commit 실패 시 생성 object를 보상한다. + - REFACTOR: 단일 커뮤니티 audio 검증을 전역 media framework로 일반화하지 않고 게시글 web/storage 경계에 둔다. + - 기대 결과: 유료 본문도 축약·마스킹 없이 관리자에게 반환되고 업로드 실패가 부분 성공을 남기지 않는다. + +- [ ] **Task 8.2: COMMUNITY-POST-01~06 DTO, facade, Controller contract 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminCommunityPostDtos.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminCommunityPostFacade.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminCommunityAudioPartValidator.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminCommunityPostController.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminCommunityAudioPartValidatorTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminCommunityPostControllerIntegrationTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` + - RED: POST-01/02 전체 JSON과 nullable/UTC, 03 multipart 조합·기본값, audio bytes의 실제 MIME `audio/mp4|audio/x-m4a|audio/aac`, 무료 이미지 GIF 거부와 유료 이미지 GIF 허용, 04 허용 field만 수정, 05 logical delete, 06 fixed와 ADMIN/owner/error/audit를 PRD 15장 기준으로 검증한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminCommunityAudioPartValidatorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminCommunityPostControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: web adapter가 Tika 기반으로 file bytes를 검사하고 확장자만 신뢰하지 않는다. 이미지는 공통 `AdminImagePartValidator`에 유료 여부를 전달한다. facade는 target과 post 소유권을 검증한 뒤 service를 호출하고 CDN URL만 응답한다. + - REFACTOR: 기존 소비자용 `CreatorChannelCommunityQueryService`의 유료 마스킹과 차단 조건을 관리자 응답에 적용하지 않는다. + - 기대 결과: COMMUNITY-POST-01~06이 route inventory를 통과한다. + +- [ ] **Task 8.3: 관리자 커뮤니티 댓글 domain과 persistence 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorCommunityAdminCommentService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorCommunityAdminModels.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorCommunityAdminPolicy.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorCommunityAdminPort.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorCommunityAdminRepository.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorCommunityAdminPolicyTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorCommunityAdminCommentServiceTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorCommunityAdminRepositoryTest.kt` + - RED: 루트/직접 답글 정렬, 동일 활성 post의 활성 root만 parent 허용, 중첩 답글 거부, AI 작성자만 update, 작성자 또는 post 소유자 delete, secret/작성자 정보, 필터 값과 무관한 활성 직접 replyCount, 다른 owner의 Path post·comment는 404, body 부모 귀속·중첩 우회는 400과 `parentCommentId`를 검증한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorCommunityAdminPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorCommunityAdminCommentServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorCommunityAdminRepositoryTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: resolved creator ID를 writer로 사용하고 post/comment/root 귀속을 persistence에서 한 번 더 확인한다. + - REFACTOR: 콘텐츠 댓글과 규칙이 비슷해도 서로 다른 entity/알림 계약을 범용 comment engine으로 합치지 않는다. + - 기대 결과: community comment mutation 권한이 PRD 19장의 순서로 검증된다. + +- [ ] **Task 8.4: COMMUNITY-COMMENT-01~05 DTO, facade, Controller contract 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminCommunityCommentDtos.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminCommunityCommentFacade.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminCommunityCommentController.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminCommunityCommentControllerIntegrationTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` + - RED: COMMENT-01/02 JSON·root/direct child·정렬과 Query `isActive` 생략 시 true 및 false 명시 조회, 03 기본 parent/isSecret 및 body parent 귀속 오류 400, 04 content-only update, 05 소유자 moderation, 다른 owner의 Path resource 404와 audit를 MockMvc로 검증한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminCommunityCommentControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: 공통 update DTO를 재사용하되 community 전용 response와 use case를 사용한다. + - REFACTOR: 선택 캐릭터가 작성하지 않은 댓글에는 update 권한을 만들지 않는다. + - 기대 결과: COMMUNITY-COMMENT-01~05가 route inventory를 통과한다. + +### Phase 9: FanTalk, 채널 공지와 프로필 API + +- [ ] **Task 9.1: 관리자 FanTalk 조회·답글·moderation domain과 persistence 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorFanTalkAdminModels.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorFanTalkAdminPolicy.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorFanTalkAdminService.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/port/out/CreatorFanTalkAdminPort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/port/out/CreatorFanTalkEventPort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorFanTalkAdminRepository.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/event/CreatorFanTalkEventAdapter.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorFanTalkAdminPolicyTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorFanTalkAdminServiceTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorFanTalkAdminRepositoryTest.kt` + - RED: 목록에 활성 creator replies 중첩/replyId 포함, 별도 reply query 없음, root target 귀속, AI writer 답글만 update/delete, root owner moderation, 반복 delete, 비활성 creator 신규 답글 409, reply create 언어 감지 commit 후 1회를 검증한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorFanTalkAdminPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorFanTalkAdminServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorFanTalkAdminRepositoryTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: FanTalk root와 reply를 관리자 projection으로 함께 조회하고 root 삭제 시 답글 row를 물리 삭제하지 않는다. + - REFACTOR: 기존 소비자용 `CreatorChannelFanTalkQueryService`는 수정하지 않고 reply 전용 GET use case를 만들지 않는다. + - 기대 결과: 사용자 이력을 보존하면서 선택 캐릭터 답글만 관리할 수 있다. + +- [ ] **Task 9.2: FAN-TALK-01~05 DTO, facade, Controller contract 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminFanTalkDtos.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminFanTalkFacade.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminFanTalkController.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminFanTalkControllerIntegrationTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` + - RED: FAN-TALK-01 page item과 중첩 replies JSON, 02/03 reply response, 04 reply delete, 05 root delete, 다른 creator reply 수정/삭제 차단과 audit를 PRD 17장 기준으로 검증한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminFanTalkControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: 목록 하나로 root와 replies를 반환하고 네 mutation route만 추가한다. + - REFACTOR: `/replies` GET mapping이 route inventory에 존재하지 않는지 negative assertion을 유지한다. + - 기대 결과: FAN-TALK-01~05만 노출되고 불필요한 답글 조회 API가 생기지 않는다. + +- [ ] **Task 9.3: 채널 공지·프로필·creator tag domain과 persistence 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/notice/domain/CreatorChannelNoticeModels.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/notice/application/CreatorChannelNoticeService.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/notice/port/out/CreatorChannelNoticePort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/notice/port/out/CreatorChannelNoticeEventPort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/notice/adapter/out/persistence/DefaultCreatorChannelNoticeRepository.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/notice/adapter/out/event/CreatorChannelNoticeEventAdapter.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/profile/domain/CreatorChannelProfileModels.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/profile/domain/CreatorChannelProfilePolicy.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/profile/application/CreatorChannelProfileService.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/profile/port/out/CreatorChannelProfilePort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/profile/adapter/out/persistence/DefaultCreatorChannelProfileRepository.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/notice/application/CreatorChannelNoticeServiceTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/profile/domain/CreatorChannelProfilePolicyTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/profile/application/CreatorChannelProfileServiceTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/profile/adapter/out/persistence/DefaultCreatorChannelProfileRepositoryTest.kt` + - RED: 공지 없음의 empty/null, upsert와 실제 변경 때만 알림, URL empty 또는 http(s) absolute, donation period, tagIds 중복/active 전건 검증·전체 교체, 빈 tag, metadata 정렬, `kakaoOpenChatUrl <-> Member.websiteUrl`, UTC update를 검증한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.notice.application.CreatorChannelNoticeServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.profile.domain.CreatorChannelProfilePolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.profile.application.CreatorChannelProfileServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.profile.adapter.out.persistence.DefaultCreatorChannelProfileRepositoryTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: 공지는 `ChannelNotice`, 프로필은 `Member`와 `MemberCreatorTag`를 adapter에서 사용한다. tag 전체 교체 시 join row만 제거/생성하고 tag 자체는 변경하지 않는다. + - REFACTOR: `ChatCharacter.tags`와 Member creator tag를 동기화하지 않고, 이름·이미지·소개 update를 profile API에 중복 구현하지 않는다. + - 기대 결과: 캐릭터 원본 정보와 채널 운영 설정의 책임이 분리된다. + +- [ ] **Task 9.4: NOTICE/CREATOR-TAG/CHANNEL-PROFILE DTO, facade, Controller contract 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminChannelSettingsDtos.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminChannelNoticeFacade.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminChannelProfileFacade.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminChannelNoticeController.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminChannelProfileController.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminChannelSettingsControllerIntegrationTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` + - RED: NOTICE-01/02, CREATOR-TAG-01, PROFILE-01/02의 exact path와 Request/Response JSON, tag 비페이징, 빈 문자열 의미, `donationRankingPeriod` key 누락 거부와 명시적 null 허용, 다른 owner 404, invalid URL/tag 400, audit를 검증한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminChannelSettingsControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: 공지와 프로필 facade가 각 도메인 service만 호출하고 응답 날짜를 UTC `Z`로 변환한다. + - REFACTOR: 별도 POST notice, menu, profile image/name update Endpoint를 추가하지 않는다. + - 기대 결과: CHAR-05를 제외한 65개 Operation이 route inventory를 통과한다. + +### Phase 10: 캐릭터 삭제 cascade와 legacy 비활성화 + +- [ ] **Task 10.1: 도메인별 creator 비활성화 use case와 단일 transaction cascade 구현** + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/application/ContentManagementService.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/port/in/ContentCreatorDeactivationUseCase.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/port/out/ContentManagementPort.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/persistence/DefaultContentManagementRepository.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/category/application/ContentCategoryManagementService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/category/port/in/ContentCategoryCreatorDeactivationUseCase.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorSeriesAdminService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/port/in/SeriesCreatorDeactivationUseCase.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorCommunityAdminPostService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/in/CommunityCreatorDeactivationUseCase.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterCommandService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterEventPort.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/event/AiCharacterEventAdapter.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterPersistencePort.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapter.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/management/application/ContentManagementServiceTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/persistence/DefaultContentManagementRepositoryTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterDeletionServiceIntegrationTest.kt` + - RED: 한 transaction에서 character/Member inactive, 소유 콘텐츠 raw `isActive=false`, active series/post/category inactive, FanTalk/replies 불변, join/이력 물리 삭제 0건, 중간 실패 전체 rollback, 반복 삭제 동일 응답을 검증한다. 콘텐츠의 `releaseDate`, `content`, `duration`과 구매 이력은 보존하면서 기존 미삭제 콘텐츠 `SUSPENDED`, 기존 삭제 콘텐츠 `DELETED`로 계산되는지도 함께 검증한다. 실제 active→inactive 전이에서만 deactivation event 1회, rollback·반복 삭제에서는 0회인지 고정한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.management.application.ContentManagementServiceTest --tests kr.co.vividnext.sodalive.v2.content.management.adapter.out.persistence.DefaultContentManagementRepositoryTest --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterDeletionServiceIntegrationTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: `AiCharacterCommandService.delete`가 content, category, series, community의 좁은 deactivation input port를 호출하고 마지막에 character와 Member를 비활성화한다. content port는 소유 row의 raw `isActive`만 `false`로 전환하며 모든 port는 같은 transaction에 참여한다. 실제 active→inactive 전이에서만 같은 transaction 안에 deactivation event를 1회 publish한다. + - REFACTOR: aicharacter persistence adapter가 다른 도메인 테이블을 직접 update하지 않게 하고 FanTalk deactivation port는 만들지 않는다. + - 기대 결과: PRD 7.4의 cascade가 물리 삭제 없이 원자적으로 완료된다. + +- [ ] **Task 10.2: CHAR-05, legacy 비활성화·복구 거부와 공개 콘텐츠·채팅 차단 구현** + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminAiCharacterFacade.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminAiCharacterController.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/LegacyAiCharacterMutationAdapter.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminAiCharacterControllerIntegrationTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/character/LegacyAiCharacterMutationAdapterTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterControllerTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterInactiveTransitionTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/chat/character/InactiveAiCharacterPublicSurfaceTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/chat/room/service/InactiveAiCharacterChatAccessTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaControllerTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaServiceTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/content/InactiveAiCharacterPublicContentVisibilityTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/content/PurchasedContentPlaybackAfterAiCharacterDeletionTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRepository.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerServiceTest.kt` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt` + - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/search/SearchRepository.kt` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryRepository.kt` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryService.kt` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/AudioContentMainTabRepository.kt` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/ContentMainTabTagCurationRepository.kt` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotRepository.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/cache/AiCharacterVisibilityCacheInvalidator.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/cache/AiCharacterVisibilityCacheInvalidatorTest.kt` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/RedisConfig.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/rank/RankingRepositoryTest.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepositoryTest.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepository.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepositoryTest.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBannerRepository.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerServiceIntegrationTest.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerServiceIntegrationTest.kt` + - RED: CHAR-05 exact response/반복 삭제/audit, legacy active→false가 같은 cascade 호출, false→true 409, 이름·표시 정보 보존, 외부 rename/delete 미호출을 검증한다. legacy 캐릭터 등록·수정 2개는 기존 multipart/body ID/nullable patch/성공 `data=null`을 유지하면서 전체 외부·이미지·DB mutation을 v2 캐릭터 command로 호출한다. 등록의 `originalWorkId=null`·`0`은 미연결, 수정의 `null`은 변경 없음·`0`은 해제이며, 원작 최종 재검증 실패도 같은 보상 경계를 사용하는지 검증한다. 삭제 후 공개 캐릭터 상세·이미지 목록/구매·댓글 조회/작성, 방 생성/입장/session 상태/목록/메시지 조회·전송·구매/초기화, room quota 캔·광고 구매를 모두 차단하고, 목록에서는 제외하며 캔 차감이나 외부 session 호출 전에 실패하는지도 검증한다. 삭제 캐릭터의 콘텐츠는 legacy/v2 목록·검색·추천·크리에이터 채널·content/creator latest/previous ranking snapshot·legacy creator ranking·공개 캐릭터/콘텐츠/시리즈 banner와 미구매 상세에서 제외한다. 공개 콘텐츠 댓글·답글 조회·신규 등록·본문 수정·재활성화는 차단하고 권한 있는 논리 삭제만 허용한다. 현재 v2 FanTalk 탭과 legacy FanTalk 목록·신규 원문 등록은 비활성 creator를 거부하고 기존 FanTalk row는 보존하는지 검증한다. 기존 KEEP/RENTAL 구매자는 `AudioContentService.getDetail`·`generateUrl`에서 전체 Signed URL 재생을 유지하되 댓글과 관련 콘텐츠는 받지 않는다. ranking snapshot과 연결형 banner row는 변경하지 않고 `EVENT`, `LINK`처럼 연결 대상이 없는 banner의 공개 결과도 바꾸지 않는다. `default`, `cache_ttl_3_days`, `popularCharacters_24h_locale`를 pre-warm한 뒤 commit 시 stale DTO가 사라지고 rollback·반복 삭제에서는 cache clear 0회인지 검증한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminAiCharacterControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest --tests kr.co.vividnext.sodalive.admin.chat.character.LegacyAiCharacterMutationAdapterTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterInactiveTransitionTest --tests kr.co.vividnext.sodalive.chat.character.InactiveAiCharacterPublicSurfaceTest --tests kr.co.vividnext.sodalive.chat.room.service.InactiveAiCharacterChatAccessTest --tests kr.co.vividnext.sodalive.chat.quota.room.ChatRoomQuotaControllerTest --tests kr.co.vividnext.sodalive.chat.quota.room.ChatRoomQuotaServiceTest --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests kr.co.vividnext.sodalive.content.InactiveAiCharacterPublicContentVisibilityTest --tests kr.co.vividnext.sodalive.content.PurchasedContentPlaybackAfterAiCharacterDeletionTest --tests kr.co.vividnext.sodalive.explorer.ExplorerServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingSnapshotPersistenceAdapterTest --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest --tests kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.cache.AiCharacterVisibilityCacheInvalidatorTest --tests kr.co.vividnext.sodalive.rank.RankingRepositoryTest --tests kr.co.vividnext.sodalive.explorer.ExplorerQueryRepositoryTest --tests kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerRepositoryTest --tests kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceIntegrationTest --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceIntegrationTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: 신규 DELETE와 legacy 비활성화가 동일한 v2 delete use case를 호출한다. legacy 캐릭터 compatibility adapter는 register를 v2 create command로, update의 non-null field를 v2 patch command로, 등록의 null·0을 미연결로, 수정의 null을 변경 없음·0을 명시적 원작 해제로, `isActive=false`를 delete로 변환하며 inactive→true 복구를 거부한다. 이 전환 후 `AdminOriginalWorkService.assignOneCharacter`를 제거한다. `ChatCharacterService`의 공통 active-character guard를 소비성·외부 호출 전에 적용한다. 기존 공개 목록·검색·추천은 content deactivation을 통해 inactive 콘텐츠를 제외한다. `AudioContentService` 직접 상세는 미구매자에게 비활성 creator/content를 거부하고 구매자에게 재생 경로만 유지한다. `AudioContentCommentService`는 공개 댓글·답글 조회와 신규 등록·본문 수정·재활성화를 거부하고 권한 있는 논리 삭제만 허용한다. 기존 v2 FanTalk 조회의 active creator 조건은 유지하고 `ExplorerService.writeCheers`, `getCreatorProfileCheers`는 대상 creator가 비활성이면 저장·조회 전에 거부한다. audio/creator latest·previous ranking snapshot native query와 legacy creator ranking, 공개 언어별 character/content/series banner query는 현재 연결 대상의 active 상태를 확인한다. AFTER_COMMIT listener는 deactivation event를 받아 기존 세 cache namespace를 clear한다. + - REFACTOR: legacy Controller에서 외부 캐릭터·이미지·DB orchestration과 원작 직접 연결을 제거하고 호환 Request 변환과 `ApiResponse.ok(null)`만 남긴다. 이미 `content.isActive`를 검사하는 공개 query에는 중복 creator predicate를 추가하지 않고 snapshot을 삭제·갱신하거나 ranking job을 강제 실행하지 않는다. 동적 key prefix 삭제나 신규 cache abstraction을 만들지 않는다. + - 기대 결과: CHAR-05를 포함한 신규 66개 mapping이 route inventory를 통과하고, 삭제 캐릭터와 미구매 콘텐츠는 로컬 공개·소비 진입점에서 즉시 차단되며 기존 구매 재생은 유지된다. + +### Phase 11: 전체 계약, 보안, 경계와 회귀 검증 + +- [ ] **Task 11.1: 66개 Operation의 소유권·상태·audit 교차 시나리오 검증** + - Files: + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminOwnershipEndToEndTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminInactiveStateEndToEndTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminAuditEndToEndTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` + - RED: route inventory가 PRD의 method/path 66개와 중복·누락·추가 없이 정확히 일치하는지 먼저 검증한다. 이어서 리소스군마다 다른 character 소유 Path ID는 404, Request body의 잘못된 parent/root 귀속은 400과 해당 field, inactive character의 GET·DELETE 허용과 그 외 mutation 409, inactive parent mutation 409, 논리 리소스 DELETE retry 200, 실제 ADMIN과 대행 creator 구분, 모든 mutation success/failure audit를 parameterized E2E로 작성한다. global 원작은 character 선택 없이 접근되고, 0 이하·미존재·삭제 ID의 정확한 400/404/409, 연결 존재 삭제 409, 이미 삭제된 상태 우선의 반복 DELETE 200, 배정·해제 전건 검증·원자성, 다른 원작 이동과 audit nullable/캐릭터별 규칙도 포함한다. 같은 원작 재배정은 요청 순서 ID 전체를 반환하는 멱등 성공이고 이미 해제된 관계의 ORIGINAL-WORK-08 재시도는 귀속 불일치 400인지 구분한다. legacy 원작 mutation 5개와 캐릭터 mutation 2개 전체도 각각 같은 v2 원작·캐릭터 command를 사용하며 동시 삭제·배정에서 삭제 원작 참조를 만들지 않는지 포함한다. + - 실패 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdmin*EndToEndTest' --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: 누락된 owner/status/audit 연결만 각 facade/use case에 보완한다. + - REFACTOR: E2E fixture를 공유하되 production에 범용 CRUD/impersonation abstraction을 추가하지 않는다. + - 기대 결과: route inventory의 66개 mapping과 공통 보안 경계가 모두 통과한다. + +- [ ] **Task 11.2: side effect와 기존 callback·scheduler 호환 통합 검증** + - Files: + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminSideEffectIntegrationTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/originalwork/application/OriginalWorkAdminServiceTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/originalwork/adapter/out/event/OriginalWorkEventAdapterTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/cache/AiCharacterVisibilityCacheInvalidatorTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentUploadCompletionContractTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentReleaseQueryTest.kt` + - RED: PRD 21.3 side effect 표의 관리자 command를 commit/rollback/retry로 실행하고 0회 또는 정확히 1회인지 검증한다. 원작 생성 언어 감지와 정규화된 `title`, `contentType`, `category`, `description`, `tags` 중 실제 변경된 수정의 번역은 commit 후 1회, 다른 field 변경·동일 값·배정·해제·rollback에서는 0회여야 한다. 감지 결과 transaction이 commit되기 전에 후속 번역이 시작되지 않는지도 포함한다. v2 생성과 같은 기존 row가 현재 callback과 scheduler에서 즉시/예약 공개되고, 삭제 row·비활성 creator·동일 callback 재시도에서는 공개 FCM/home news가 0회 또는 최초 1회를 넘지 않는지도 함께 검증한다. 캐릭터 삭제 cache clear도 commit 시 1회, rollback·반복 삭제 시 0회여야 한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminSideEffectIntegrationTest --tests kr.co.vividnext.sodalive.v2.originalwork.application.OriginalWorkAdminServiceTest --tests kr.co.vividnext.sodalive.v2.originalwork.adapter.out.event.OriginalWorkEventAdapterTest --tests kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.cache.AiCharacterVisibilityCacheInvalidatorTest --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests kr.co.vividnext.sodalive.content.AudioContentReleaseQueryTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: 각 command는 동일 상태인지 먼저 판별하고 실제 최초 상태 전이가 있을 때만 기존 transactional listener용 event를 transaction 안에서 publish한다. direct home-news 호출만 `AfterCommitExecutor`에 등록하며 별도 lifecycle/completion 결과 모델은 만들지 않는다. + - REFACTOR: 이벤트 이름과 payload는 resource ID와 필요한 최소 값만 포함하고 대용량 본문을 넣지 않는다. + - 기대 결과: rollback과 멱등 재시도가 외부 알림·번역·home news 또는 cache clear를 중복 생성하지 않는다. + +- [ ] **Task 11.3: v2 의존성·Endpoint 및 DB schema·JPA·migration 변경 금지 검증** + - Files: + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminArchitectureTest.kt` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel` + - RED: 이 계획에서 신규 생성·수정한 v2 class manifest를 기준으로 domain/application/port의 legacy Service/Repository/web DTO 및 JPA entity/Q type import, `/v2` HTTP prefix, 신규 login/menu/callback/reply-GET mapping, legacy 형태의 원작 `/search`·`/register`·`/update` mapping, 신규 v2 `@Entity`/`@Table` 선언이 있으면 실패하는 architecture test를 작성한다. legacy compatibility adapter가 v2 input port를 호출하는 방향은 허용하되 v2 package가 해당 adapter나 legacy DTO를 import하면 실패한다. 기존 v2 파일의 선행 부채를 이번 변경 위반으로 오탐하지 않는다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminArchitectureTest` + - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. + - GREEN: 위반 import/mapping만 adapter 또는 기존 허용 경계로 이동한다. + - REFACTOR: 정적 검증을 위해 새 architecture dependency를 추가하지 않고 classpath/reflection과 소스 resource 검사로 충분히 구현한다. + - 추가 검증: + - `rg -n '"/(v2/admin|admin/v2)|upload-complete|member/login|"/menu' src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter` + - `rg -n 'lifecycle_status|upload_pipeline_version|ContentUploadCompletionUseCase|V2ContentReleaseScheduler|AudioContentUploadCompletionDispatcher' src/main/kotlin` + - 5장에 기록한 구현 시작 기준 commit과 dirty/untracked manifest를 현재 changed/untracked file manifest와 대조해 이번 구현이 생성·수정한 `*.sql`이 0건인지 확인한다. + - 같은 manifest에서 기존 `@Entity` source 수정 0건과 신규 v2 production source의 `@Entity`, `@Table` 선언 0건을 확인한다. + - 구현 시작 기준과 비교해 `AudioContent.kt`, `AudioContentController.kt`, `AudioContentReleaseScheduledTask.kt`에 diff가 없는지 확인한다. + - 구현 시작 기준과 비교해 `OriginalWork.kt`, `OriginalWorkLink.kt`, `OriginalWorkTag.kt`, `OriginalWorkTagMapping.kt`, `ChatCharacter.originalWork` JPA mapping에 diff가 없는지 확인한다. + - `docs/20260720_AI캐릭터_관리자기능` 아래 SQL 파일이 0건인지 확인한다. + - 위 `rg`는 출력 0건이 기대 결과이며, match 없음에 따른 exit code 1은 위반 검출 실패가 아니다. + - 기대 결과: PRD 7.6과 21.1~21.4의 경계 위반, 금지 Endpoint, DB schema·JPA mapping·migration과 pipeline 분기 추가가 0건이다. + +- [ ] **Task 11.4: 최종 targeted/full regression, 포맷과 문서 검증** + - Files: + - Modify: `docs/20260720_AI캐릭터_관리자기능/plan-task.md` + - Verify: `docs/20260720_AI캐릭터_관리자기능/prd.md` + - Verify: `docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md` + - RED: TDD 예외. 이 Task는 구현 완료 후 전체 회귀와 문서/API/DB schema·JPA·migration 비변경 정합성을 확인하는 최종 gate다. + - GREEN: 다음 순서로 검증한다. + 1. `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.admin.aicharacter.*'` + 2. `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.aicharacter.*'` + 3. `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.originalwork.*'` + 4. `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.*'` + 5. `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.*'` + 6. `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentUploadCompletion*' --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests 'kr.co.vividnext.sodalive.scheduler.*'` + 7. `./gradlew test` + 8. `./gradlew ktlintCheck` + 9. `./gradlew tasks --all` + 10. `git diff --check` + 11. `rg -o '^\| .(CHAR|ORIGINAL-WORK|CONTENT|CONTENT-COMMENT|CATEGORY|SERIES|COMMUNITY-POST|COMMUNITY-COMMENT|FAN-TALK|NOTICE|CREATOR-TAG|CHANNEL-PROFILE)-[0-9]{2}.' docs/20260720_AI캐릭터_관리자기능/prd.md | sort -u | wc -l` 결과가 66인지 확인한다. + 12. `rg -c '^ORIGINAL-WORK-0[1-8] (GET|POST|PUT|DELETE) ' docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md` 결과가 8인지 확인한다. + 13. `awk '$0=="### 27.8 Copy-Paste Frontend Development Prompt"{section=1; next} section && $0=="```text"{capture=1} capture{print} capture && $0=="```"{exit}' docs/20260720_AI캐릭터_관리자기능/prd.md | shasum -a 256` 결과가 `5956ddc152c026937728381d625859bdea65b9a2f16a39b200f0d6b3660a73e1`인지 확인한다. + 14. `find docs/20260720_AI캐릭터_관리자기능 -type f -name '*.sql' -print` 출력이 0건인지 확인한다. + 15. `rg -o '^### Phase [0-9]+' docs/20260720_AI캐릭터_관리자기능/plan-task.md | wc -l` 결과가 12인지 확인한다. + 16. `rg -o '^- \[ \] \*\*Task [0-9]+\.[0-9]+:' docs/20260720_AI캐릭터_관리자기능/plan-task.md | wc -l` 결과가 37인지 확인한다. + 17. `rg -c '^ - 실패 확인:' docs/20260720_AI캐릭터_관리자기능/plan-task.md`와 `rg -c '^ - 통과 확인:' docs/20260720_AI캐릭터_관리자기능/plan-task.md` 결과가 각각 35인지 확인한다. + 18. `rg -c '^ - RED: TDD 예외' docs/20260720_AI캐릭터_관리자기능/plan-task.md` 결과가 2인지 확인한다. + 19. `rg -n $'\t| +$' docs/20260720_AI캐릭터_관리자기능/prd.md docs/20260720_AI캐릭터_관리자기능/plan-task.md docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md` 출력이 0건인지 확인하고, 각 문서의 code fence 개수가 짝수인지 확인한다. untracked 문서는 `git diff --check`만으로 검사되지 않으므로 파일 자체 검사도 수행한다. + 20. `rg -c 'nullable JSON:$' docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md` 결과가 5인지 확인하고, PRD 11.7의 nullable response field와 CHAR-02 `originalWork` nullable 계약이 해당 JSON 값 및 Frontend test에 모두 나타나는지 대조한다. + 21. PRD의 fenced JSON 전체와 `frontend-original-work-prompt.md`의 `JSON:` label 값 전체를 JSON parser로 읽어 각각 51/51, 20/20이 유효한지 확인한다. 이어서 PRD 11.7과 Frontend delta의 `(Operation ID, Method, Path)` 8개가 exact equality인지 대조하고, Operation별 Request/Response JSON `(Operation ID, Request|Response)` 12개가 모두 존재하며 key, value, 배열과 null 구조가 12/12 exact equality인지 확인한다. + - REFACTOR: 실패가 발생하면 해당 Phase의 가장 좁은 RED test로 돌아가 최소 수정한 뒤 targeted와 전체 검증을 다시 수행한다. + - 문서화: 각 Task 완료 시 checkbox와 실제 RED/GREEN 명령 결과를 갱신한다. 실행하지 않은 staging worker E2E와 production 배포를 완료로 표시하지 않는다. + - 기대 결과: 66개 신규 Operation, 기존 callback/scheduler 호환, 원작 관리, 삭제 cascade, 보안·audit·경계 검증이 모두 통과한다. + +## 4. 명시적 비범위 확인 + +다음 항목은 구현 중 발견해도 이 계획에 임의로 추가하지 않는다. + +- Frontend 프로젝트, 메뉴, route guard, SEO/noindex, Jenkins frontend build 설정 +- 이미 적용된 PRD 27.8 Frontend baseline 프롬프트의 수정. 원작용 별도 delta 프롬프트만 문서로 제공한다. +- 신규 관리자 로그인, AI 캐릭터 사칭 token, Backend menu/capability Endpoint +- legacy `/admin/chat/original/**`의 Method·Path·Request·성공 Response 제거·변경 또는 일반 사용자용 `/api/chat/original/**` 변경. legacy mutation을 v2 input port로 수렴시키는 내부 변경은 범위에 포함한다. +- 원작 복구, 연결 캐릭터의 암묵적 일괄 해제, 원작 이미지 정리 작업 +- 신규 upload-complete Endpoint, AWS worker/Trigger/worker schedule 변경 +- 콘텐츠·원작 schema·JPA mapping 변경, DDL, backfill, 선제 데이터 migration +- V1/V2 callback dispatcher, v2 completion use case, v2 전용 예약 공개 scheduler +- FanTalk reply 별도 GET Endpoint +- 라이브, DM, 정산, 후원 분석, 시그니처 후원 +- 캐릭터 복구, 삭제 리소스 자동 복원 +- 영속 audit table, 범용 impersonation framework, 범용 CRUD engine +- legacy API 전체의 HTTP status/error envelope 일괄 변경 + +## 5. 구현 시 검증 기록 + +이 문서를 생성한 현재는 구현 전이므로 Task checkbox를 모두 미체크로 유지한다. 구현 에이전트는 각 Task에서 실제로 실행한 명령, RED 실패 원인, GREEN 성공 결과와 미실행 외부 Gate를 이 절에 누적한다. + +- 문서 생성 검증: 아래 “문서 자체 검증 기록”에만 기록한다. +- 코드 구현 검증: 아직 실행하지 않음. +- DB schema migration: 없음. +- 구현 기준 commit/기존 dirty·untracked manifest: 구현 시작 시 기록. +- staging worker/Trigger E2E: 아직 실행하지 않음. +- production 배포: 이 계획의 코드 작성 단계만으로 완료 처리하지 않음. + +## 6. 문서 자체 검증 기록 + +- 초기 문서 검증 기록: DDL 설계 당시 Operation 58개, Phase 12개, Task 40개, RED/GREEN 명령 36쌍, TDD 예외 Task 4개, `./gradlew tasks --all`의 `BUILD SUCCESSFUL in 12s`를 확인했다. +- 무DDL 보강 후 Operation coverage: PRD의 고유 신규 Operation ID 58개와 1.1 표 합계 58개가 일치한다. +- 무DDL 보강 후 구조 검증: Phase 12개, Task 34개, RED 실행 명령 32개, 대응 GREEN 통과 확인 32개다. 나머지 2개 Task는 사유와 대체 검증을 명시한 TDD 예외다. +- 무DDL 보강 후 Task 필드 검증: 모든 Task에 `Files`, `RED`, `GREEN`, `REFACTOR`, 기대 결과가 있다. +- 무DDL 보강 후 금지 범위 검증: placeholder, 신규 login/menu/callback/FanTalk reply GET, `/v2` 관리자 URL prefix, Frontend 구현 Task, 콘텐츠 DDL·pipeline 분기·v2 scheduler 구현 Task가 모두 0건이다. +- 무DDL 보강 후 Markdown 검증: code fence 짝 일치, trailing whitespace 0건, tab 0건이다. +- 원작 보강 전 Frontend baseline API 계약 검증: Frontend catalog의 `AUTH-01` 1개와 신규 관리자 Operation 58개가 중복 없이 존재하고, 59개 모두 Request JSON 표기와 유효한 Response JSON을 가진다. Request body가 있는 Operation은 25개이며 Response에 `previewStartTime`, `previewEndTime`은 0건이다. +- 최종 삭제·공개 경계 검증: 기존 컬럼의 논리 상태 변경, 구매 콘텐츠 재생 예외, 댓글·FanTalk 차단, ranking·banner 현재 활성 상태 결합, 지정된 cache 3종 무효화가 PRD와 Task 10.1~10.2에서 일치한다. +- 최종 callback 계약 검증: CONTENT-03의 `input/{contentId}/{contentId}-content-...` basename, 기존 callback·scheduler 재사용, 신규 worker·callback·pipeline 분기 금지가 PRD와 Task 0.1, 3.2, 4.2, 11.2에서 일치한다. +- 최종 DB 변경 금지 검증: 문서 디렉터리의 SQL 파일 0건이며, 계획에 DB table·column·index·JPA mapping·DDL·backfill·migration 생성·수정 Task가 없다. +- 원작 보강 전 Gradle 구성 검증: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL in 1s`다. +- 원작 보강 후 Operation 검증: PRD의 고유 Backend Operation 66개와 `frontend-original-work-prompt.md`의 `ORIGINAL-WORK-01`~`08` 8개가 일치한다. 기존 27.8 fenced block hash는 `5956ddc152c026937728381d625859bdea65b9a2f16a39b200f0d6b3660a73e1`로 유지됐다. +- 원작 보강 후 구조 검증: Phase 12개, Task 37개, 실패 확인 35개, 통과 확인 35개, TDD 예외 2개이며 모든 37개 Task에 `Files`, `RED`, `GREEN`, `REFACTOR`, 기대 결과가 있다. +- 원작 보강 후 계약 검증: PRD의 JSON code block 51개와 Frontend delta의 label JSON 값 20개(Operation Request/Response 12개, 공통 오류 3개, nullable 값 5개)가 모두 유효하다. PRD와 Frontend delta의 원작 `(Operation ID, Method, Path)`는 8/8, Operation Request/Response JSON의 key·value·배열·null 구조는 12/12 exact equality다. 원작 DTO nullable·배정 응답 의미, legacy mutation 5개와 캐릭터 mutation 2개의 v2 수렴, 잠금·보상·오류 경계는 PRD와 plan에서 일치한다. +- 원작 보강 후 Markdown/DB 검증: 세 문서의 code fence 수가 각각 146개, 2개, 2개로 짝이 맞고 trailing whitespace·tab과 SQL 파일은 0건이다. +- 원작 보강 후 Gradle 구성 검증: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`이다. +- 미실행 외부 Gate: 구현 전 문서 검증 단계이므로 실제 운영 데이터의 삭제 원작 연결 건수, MySQL 8 동시성, staging worker/Trigger E2E와 production 배포는 실행하지 않았다. diff --git a/docs/20260720_AI캐릭터_관리자기능/prd.md b/docs/20260720_AI캐릭터_관리자기능/prd.md new file mode 100644 index 00000000..bf9ccfde --- /dev/null +++ b/docs/20260720_AI캐릭터_관리자기능/prd.md @@ -0,0 +1,4513 @@ +# PRD: AI 캐릭터 관리자 기능 + +## 1. Overview + +AI 캐릭터는 `ChatCharacter`와 `Member(role=CREATOR, memberKind=AI_CHARACTER)`가 1:1로 연결되어 있지만, AI 캐릭터 자신은 로그인할 수 없다. + +사람 관리자(`MemberRole.ADMIN`)가 AI 캐릭터와 원작을 관리하고, 하나의 AI 캐릭터를 선택한 뒤 해당 캐릭터의 크리에이터 기능을 대행할 수 있는 관리자 API를 제공한다. 관리자는 원작, 캐릭터, 콘텐츠, 댓글, 시리즈, 커뮤니티 게시글, 커뮤니티 댓글, FanTalk를 한 화면 흐름에서 관리한다. + +이번 문서는 제품 범위, 인증·메뉴 정책, 신규 API 계약, v2 도메인 구현 경계 및 현재 크리에이터 기능과의 차이를 확정한다. 구현 계획과 구현은 이 문서의 범위가 아니다. + +## 2. Problem + +현재는 다음 문제가 있다. + +- AI 캐릭터 생성·조회·수정 API는 존재하지만 명시적인 삭제 API가 없다. +- AI 캐릭터와 연결된 `creatorMember`는 크리에이터 기능을 수행할 수 있는 데이터 모델이지만 직접 로그인할 수 없다. +- 기존 크리에이터 API는 인증된 `ROLE_CREATOR` 본인을 기준으로 동작하므로 `ROLE_ADMIN`이 그대로 호출할 수 없다. +- 관리자 대행 작업을 위해 AI 캐릭터용 로그인이나 사칭 토큰을 발급하면 기존의 AI 캐릭터 로그인 금지 정책과 충돌하고 실제 작업 관리자를 추적하기 어렵다. +- 사용자가 요구한 기능 목록에는 현재 크리에이터가 수행할 수 있는 일부 보조 기능과 라이브·정산 영역이 빠져 있다. +- 기존 관리자 메뉴는 서버가 DB 데이터를 기준으로 내려주지만 `role`만으로 조회하므로, 동일한 `ADMIN`이 사용하는 전체 플랫폼 관리자와 AI 캐릭터 관리자 화면의 메뉴를 구분할 수 없다. +- legacy 서비스에는 시리즈 순서 변경과 댓글·FanTalk 답글의 부모 귀속 검증처럼 그대로 계승하면 안 되는 규칙과 결손이 있다. +- 원작 등록·수정·삭제·검색·캐릭터 배정 기능은 legacy `/admin/chat/original/**`에만 있고 v2에는 없다. 캐릭터 등록·수정은 `originalWorkId`를 받지만, 신규 클라이언트가 원작을 검색·선택할 v2 계약이 없어 legacy API 없이는 화면을 완성할 수 없다. +- legacy 원작 배정은 존재하지 않거나 비활성인 캐릭터 ID를 조용히 무시하고, 다른 원작 소속 캐릭터도 경로 원작 확인 없이 해제한다. 원작 삭제는 연결 캐릭터를 남기지만 삭제 후 해제 API도 막아 그대로 이관할 수 없다. +- v2가 legacy 비즈니스 서비스와 DTO에 직접 의존하면 새 API의 권한·오류·페이징 계약이 legacy 구현에 다시 결합된다. + +## 3. Goals + +- 기존 관리자 계정으로만 AI 캐릭터 관리자 기능에 접근하게 한다. +- 관리자가 AI 캐릭터를 조회·등록·수정·논리 삭제할 수 있게 한다. +- 관리자가 원작을 검색·조회·등록·수정·논리 삭제하고, 원작과 AI 캐릭터의 연결을 조회·배정·해제할 수 있게 한다. +- 관리자가 선택한 AI 캐릭터의 `creatorMember` 명의로 다음 작업을 수행하게 한다. + - 콘텐츠 조회·등록·수정·논리 삭제 + - 콘텐츠 상단 고정·고정 해제 + - 콘텐츠 댓글과 답글 조회·등록·수정·논리 삭제 + - 선택한 AI 캐릭터의 콘텐츠에 작성된 다른 사용자의 댓글 논리 삭제 + - 콘텐츠 카테고리 조회·등록·수정·논리 삭제·순서 변경 및 콘텐츠 구성 + - 시리즈 조회·등록·수정·논리 삭제 + - 시리즈 콘텐츠 추가·제거·조회 및 시리즈 순서 변경 + - 커뮤니티 게시글 조회·등록·수정·논리 삭제 + - 커뮤니티 게시글 고정·고정 해제 + - 커뮤니티 게시글 댓글과 답글 조회·등록·수정·논리 삭제 + - 선택한 AI 캐릭터의 게시글에 작성된 다른 사용자의 댓글 논리 삭제 + - FanTalk 조회 + - FanTalk에 AI 캐릭터 답글 등록·수정·논리 삭제 + - 선택한 AI 캐릭터를 대상으로 작성된 FanTalk 원문 논리 삭제 + - 크리에이터 채널 공지 조회·등록·수정 + - 크리에이터 채널 SNS URL, 크리에이터 태그와 후원 랭킹 공개 설정 조회·수정 +- 신규 관리자 Endpoint의 Request와 Response 계약을 문서로 고정한다. +- v2 내부에서 재사용할 도메인 기능과 v2 각 도메인에 새로 구현할 command 기능을 구분한다. +- legacy 비즈니스 로직은 실행 의존성이 아니라 현행 동작을 파악하기 위한 참고 근거와 회귀 테스트 기준으로만 사용한다. +- 현재 크리에이터 기능 중 요구 목록에서 빠진 기능을 식별하고 이번 범위 포함 여부를 확정한다. +- v2 관리자 페이지 메뉴의 소유권과 제공 방식을 확정한다. + +## 4. Non-Goals + +- AI 캐릭터 또는 연결된 `creatorMember`의 로그인 허용 +- AI 캐릭터를 가장하는 JWT, 세션 또는 토큰 발급 +- `CONTENT_MANAGER`, `AGENT`, `CREATOR`, 일반 사용자의 AI 캐릭터 관리 허용 +- 기존 공개 크리에이터 API의 인증·권한 계약 변경 +- 기존 `/admin/chat/character/**` Endpoint의 즉시 삭제 또는 호환성 파괴 +- 기존 `/admin/chat/original/**` Endpoint의 Method·Path·Request·성공 Response 호환성 파괴. 데이터 불변식을 지키기 위한 mutation 정책 수렴은 이번 범위에 포함한다. +- 일반 사용자용 `/api/chat/original/**` 계약 변경 +- 캐릭터 및 연관 리소스의 물리 삭제 +- 삭제한 캐릭터의 복구 기능 +- 삭제한 원작의 복구 기능 +- 좋아요, 구매, 팔로우, 후원, DM 등 일반 사용자 행동의 관리자 대행 +- AI 캐릭터의 라이브 방송 운영, 예약 방송, 라이브 메뉴, 룰렛 +- AI 캐릭터의 정산·매출 조회 및 지급 처리 +- 시그니처 후원 설정 +- 관리자 메뉴 시스템 전체 개편 또는 신규 capability 시스템 도입 +- FanTalk 답글 전용 조회 Endpoint +- 기존 AWS S3 Trigger 기반 오디오 가공 worker의 코드·스케줄·설정 변경 +- 기존 upload-complete 계약을 대체하는 신규 내부 callback Endpoint의 선제 구현 +- 기존 `content` 테이블의 컬럼 추가·backfill 또는 v2 전용 콘텐츠 테이블 생성 +- V1/V2 upload pipeline 분기와 v2 전용 예약 공개 scheduler 생성 +- 관리자 화면 UI 구현 +- 이미 적용된 27.8 Frontend baseline 프롬프트의 수정 +- 구현 계획 또는 서버 코드 구현 + +## 5. Target Users + +### Primary User + +- `MemberRole.ADMIN`을 가진 내부 운영 관리자 + +### 접근 불가 사용자 + +- 비로그인 사용자 +- `CONTENT_MANAGER` +- `AGENT` +- 일반 `CREATOR` +- `USER` +- `memberKind=AI_CHARACTER`인 연결 크리에이터 Member + +## 6. Current-State Investigation + +### 6.1 AI 캐릭터와 크리에이터 Member + +- `ChatCharacter.creatorMember`는 non-null, unique 1:1 관계다. +- AI 캐릭터 등록 시 연결 Member는 다음 값으로 생성된다. + - `role=CREATOR` + - `memberKind=AI_CHARACTER` + - `email=null` + - `password=""` +- 캐릭터의 이름, 프로필 이미지, 소개는 연결 Member의 `nickname`, `profileImage`, `introduce`와 동기화된다. +- 기존 크리에이터 공개 API의 `creatorId`는 계속 `Member.id`를 의미한다. +- AI 캐릭터 Member는 일반 로그인과 크리에이터 관리자 로그인 모두에서 차단된다. + +따라서 관리자 대행 API는 `characterId`로 대상 AI 캐릭터와 연결 `creatorMemberId`를 해석하고, 이를 v2 각 도메인 use case의 명시적인 작업 대상 ID로 전달해야 한다. + +### 6.2 현재 관리자 인증 + +현재 관리자 로그인은 이미 존재한다. + +```http +POST /admin/member/login +Content-Type: application/json +``` + +`ADMIN`과 `CONTENT_MANAGER` 계정이 로그인할 수 있고 JWT를 반환한다. AI 캐릭터 관리 API는 이 중 `ADMIN`만 허용한다. + +별도 AI 캐릭터 관리자 로그인 Endpoint는 같은 Member 조회, 비밀번호 검증, JWT 발급 및 보안 설정을 중복할 뿐 새로운 보안 경계를 만들지 못한다. + +### 6.3 현재 AI 캐릭터 관리자 API + +기존 `/admin/chat/character`에는 다음 `ADMIN` 전용 기능이 있다. + +| Method | Endpoint | 기능 | +|---|---|---| +| `GET` | `/admin/chat/character/list` | 활성 캐릭터 목록 | +| `GET` | `/admin/chat/character/search` | 활성 캐릭터 검색 | +| `GET` | `/admin/chat/character/{characterId}` | 캐릭터 상세 | +| `POST` | `/admin/chat/character/register` | 캐릭터 등록 | +| `PUT` | `/admin/chat/character/update` | 캐릭터 수정 및 활성 상태 변경 | + +명시적인 `DELETE` Endpoint는 없으며 수정 요청의 `isActive=false`가 삭제 역할을 한다. 등록·수정 성공 응답은 현재 `data=null`이다. + +### 6.4 현재 메뉴 + +현재 관리자 메뉴는 다음 흐름으로 서버가 결정한다. + +```text +JWT principal Member + -> MenuService.getMenus(member) + -> MenuRepository.getMenu(member.role) + -> role, isActive, orders 기준 조회 + -> title, route, items 반환 +``` + +`GET /menu`는 요청한 관리 화면 또는 애플리케이션을 식별하지 않고 로그인 Member의 `role`만 사용한다. 따라서 같은 `ADMIN`이 여러 관리자 페이지를 사용할 때 페이지별로 다른 메뉴를 반환할 수 없다. v2 AI 캐릭터 관리자 메뉴를 이 Endpoint에 추가하면 모든 legacy ADMIN 화면에도 같은 메뉴가 노출된다. + +관리 화면은 다음 세 surface로 구분한다. + +| Admin surface | 로그인 주체 | 메뉴 판단 | +|---|---|---| +| 전체 플랫폼 관리자 | `ADMIN`, `CONTENT_MANAGER` | legacy `GET /menu` 현행 유지 | +| AI 캐릭터 관리자 v2 | `ADMIN` | 신규 클라이언트 정적 메뉴 | +| 크리에이터 관리자 | `CREATOR`, `AGENT` | legacy `GET /menu` 현행 유지 | + +전체 플랫폼 관리자와 AI 캐릭터 관리자는 역할이 같으므로 기존 role만으로 메뉴를 분리할 수 없다. + +### 6.5 v2 구현 및 재사용 조사 + +legacy 코드는 요구 기능의 데이터 모델과 현행 동작을 확인하는 근거로 사용하되 런타임 비즈니스 로직으로 호출하지 않는다. + +| 영역 | 현행 근거 | v2 판단 | v2에 필요한 기능 | +|---|---|---|---| +| 캐릭터 | legacy `AdminChatCharacterService`, `ChatCharacterService` | 신규 구현 | 캐릭터 CRUD use case, 연결 creator 생성·동기화 port, 외부 캐릭터 API·파일 port | +| 원작 | legacy `AdminOriginalWorkService`, `OriginalWorkRepository` | 신규 구현 | 원작 CRUD·검색, 이미지·번역 port, 캐릭터 연결 조회·배정·해제 use case | +| 콘텐츠 | legacy `AudioContentService`, `CreatorAdminContentService` | 신규 구현 | 관리자용 content command/query use case와 소유권 정책 | +| 콘텐츠 가공 완료 연동 | AWS S3 Trigger worker, 기존 `PUT /audio-content/upload-complete` | 기존 외부 계약과 처리 흐름 유지 | v2 생성도 기존 S3 key·metadata·DB 필드 계약을 따르고 별도 분기·worker·scheduler를 만들지 않음 | +| 콘텐츠 댓글 | legacy `AudioContentCommentService` | 신규 구현 | comment command/query use case, 작성자·콘텐츠 소유자 삭제 정책, 부모 귀속 정책 | +| 콘텐츠 카테고리 | legacy `CategoryService`, `CreatorAdminCategoryService` | 신규 구현 | category command/query use case, 카테고리와 포함 콘텐츠의 동일 소유자 정책 | +| 시리즈 | legacy `CreatorAdminContentSeriesService` | 신규 구현 | series command/query use case, 콘텐츠 구성과 소유자 한정 순서 정책 | +| 커뮤니티 조회 | 기존 v2 `CreatorChannelCommunityQueryService`와 query port | v2 내부 확장 가능 | 관리자 전용 projection 또는 admin query use case | +| 커뮤니티 변경 | legacy `CreatorCommunityService` | 신규 구현 | v2 community command use case와 게시글·댓글 소유권 정책 | +| FanTalk 조회 | 기존 v2 `CreatorChannelFanTalkQueryService`와 query port | v2 내부 재사용·확장 가능 | 관리자 조회 use case와 관리자 API DTO 매핑 | +| FanTalk 답글 | legacy `ExplorerService` | 신규 구현 | v2 FanTalk reply command use case와 루트·답글 귀속 정책 | +| 채널 공지 | legacy `ExplorerService.saveNotice` | 신규 구현 | v2 creator channel notice upsert/query use case와 알림 port | +| 채널 프로필·크리에이터 태그 | legacy `MemberService.profileUpdate`, `MemberTagService` | 신규 구현 | v2 channel profile query/update use case와 활성 creator tag metadata query | + +결론은 다음과 같다. + +- legacy Controller, `*Service`, Request/Response DTO는 호출하거나 import하지 않는다. +- 기존 v2 domain/application/port는 계약이 맞는 경우에만 v2 내부에서 재사용하거나 확장한다. +- 없는 command 기능은 콘텐츠, 댓글, 시리즈, 커뮤니티, FanTalk 등 각 v2 도메인 패키지에 구현한다. +- 원작은 캐릭터 등록·수정과 관리자 원작 화면에서 함께 사용되므로 `v2.originalwork` 독립 도메인이 소유하고, 캐릭터 도메인은 원작의 `isDeleted=false` 참조 계약만 사용한다. +- 기존 `PUT /audio-content/upload-complete`는 AWS 연동 계약과 현재 처리 흐름을 그대로 사용한다. v2 콘텐츠 생성 use case가 기존과 같은 `content` row와 S3 입력 계약을 만들면 callback은 생성 경로를 구분하지 않고 동일하게 처리할 수 있다. +- 관리자 API 계층은 대상 AI 캐릭터를 해석하고 각 v2 use case를 조정한다. +- Controller 간 호출과 내부 HTTP 호출은 하지 않는다. +- 기존 테이블과 JPA 매핑을 복제하지 않는다. v2 persistence adapter가 기존 스키마에 접근해 v2 port의 record/domain model로 변환한다. + +### 6.6 현재 원작 관리자 API + +legacy `/admin/chat/original`에는 `ROLE_ADMIN` 전용 원작 등록·수정·논리 삭제, 목록, 검색, 상세, 연결 캐릭터 목록, 캐릭터 일괄 배정·해제 기능이 있다. 캐릭터는 한 원작에만 연결될 수 있고 새 원작 배정은 기존 연결을 교체한다. + +다만 신규 v2 계약에서는 다음 legacy 동작을 계승하지 않는다. + +- 목록과 검색을 별도 Endpoint로 나누고 검색만 비페이징으로 전체 반환하는 동작 +- 존재하지 않거나 비활성인 캐릭터 ID를 조용히 무시하는 부분 성공 +- 요청 경로의 원작 소속인지 확인하지 않고 다른 원작의 캐릭터까지 해제하는 동작 +- 연결 캐릭터를 남긴 채 원작을 삭제하고, 삭제 후에는 해당 연결을 해제할 수도 없게 만드는 동작 +- 수정의 nullable field에서 `null`을 값 삭제가 아닌 “변경 없음”으로 처리하는 부분 수정 의미 +- 생성 후 이미지 업로드가 실패하면 이미지 없는 원작 row를 남기고, DB 실패 시 업로드 이미지를 보상하지 않는 실행 순서 +- mutation 성공 응답의 `data=null` + +v2는 legacy 기능을 1:1 URL 복사하지 않는다. 목록의 `search` Query로 목록·검색을 합치고, 나머지 CRUD·상세·연결 관리 능력을 11.7의 8개 Operation으로 제공한다. legacy Endpoint의 Method·Path·Request·성공 Response는 기존 클라이언트 호환을 위해 유지하되, 원작을 변경하는 요청은 같은 v2 command와 잠금 정책으로 수렴해 공존 중 불변식을 우회하지 못하게 한다. + +## 7. Product Decisions + +### 7.1 로그인 + +- 별도의 로그인 기능을 만들지 않는다. +- 기존 `POST /admin/member/login`과 Bearer JWT를 재사용한다. +- 로그인 Endpoint 자체는 `CONTENT_MANAGER`에도 토큰을 발급할 수 있지만 AI 캐릭터 관리 Endpoint는 `ROLE_ADMIN`만 허용한다. +- AI 캐릭터 Member로 로그인하거나 AI 캐릭터 사칭 토큰을 발급하지 않는다. +- 모든 관리자 대행 변경 작업의 인증 주체는 실제 사람 관리자이며, 도메인 작업 대상만 AI 캐릭터의 `creatorMember`다. 기존 AWS worker callback은 관리자 대행 API가 아니며 현재 인증·응답 계약을 유지한다. + +### 7.2 v2 관리자 메뉴 + +- v2 AI 캐릭터 관리자 메뉴는 해당 클라이언트가 정적으로 소유한다. +- legacy `GET /menu`를 v2 AI 캐릭터 관리자에서 호출하지 않는다. +- 신규 v2 menu Endpoint도 만들지 않는다. +- 전체 플랫폼 관리자와 크리에이터 관리자의 기존 메뉴 방식은 변경하지 않는다. +- 서버 방식으로 분리하려면 클라이언트가 `surface=PLATFORM_ADMIN|AI_CHARACTER_ADMIN|CREATOR_ADMIN` 같은 값을 보내야 한다. 클라이언트가 이미 알고 있는 UI 문맥을 서버가 다시 route로 돌려주는 구조이고 인가에도 사용할 수 없으므로 이번 v2에는 채택하지 않는다. +- 클라이언트는 현재 관리자 surface가 `AI_CHARACTER_V2`임을 알고 있으므로 해당 surface의 제목, 순서, 계층, route와 화면 컴포넌트를 함께 관리한다. + +대안별 판단은 다음과 같다. + +| 방식 | 판단 | 이유 | +|---|---|---| +| legacy `GET /menu` 그대로 재사용 | 불가 | 입력이 인증 Member의 `role`뿐이라 같은 `ADMIN`의 전체 플랫폼 관리자와 AI 캐릭터 관리자 surface를 구분할 수 없음 | +| `GET /menu?surface=...`로 확장 | 이번 v2에서 미채택 | 호출 클라이언트가 이미 아는 화면 문맥으로 정적 route를 다시 조회하며, 메뉴 응답이 Backend 인가를 대신할 수도 없음 | +| surface별 신규 menu Endpoint | 이번 v2에서 미채택 | 현재 요구에는 사용자별 메뉴 차이, 운영 중 메뉴 토글 또는 세부 capability가 없어 서버 계약과 저장소만 추가됨 | +| v2 클라이언트 정적 메뉴 | 채택 | 신규 AI 캐릭터 관리자 surface의 route·컴포넌트와 메뉴를 한 곳에서 함께 변경할 수 있음 | + +- 최소 전역 진입 메뉴는 다음과 같다. + +```json +[ + { + "key": "ai-characters", + "title": "AI 캐릭터 관리", + "route": "/ai-characters" + }, + { + "key": "original-works", + "title": "원작 관리", + "route": "/ai-characters/original-works" + } +] +``` + +- 원작 관리는 캐릭터 선택이 필요 없는 global route다. 캐릭터 선택 후 콘텐츠, 시리즈, 커뮤니티, FanTalk, 채널 설정은 클라이언트의 하위 route 또는 tab으로 구성한다. +- 로그인 응답의 `role=ADMIN`은 클라이언트 route guard에 사용할 수 있지만 보안 경계가 아니다. +- 백엔드의 모든 관리 API가 `ROLE_ADMIN`을 독립적으로 검증한다. +- 역할별 세부 권한, 서버 feature flag 또는 운영 중 메뉴 활성·비활성 전환이 필요해지는 시점에는 UI route를 내려주는 메뉴 API보다 v2 capability API를 별도 PRD로 검토한다. + +### 7.3 신규 API 형태 + +- 신규 API는 v2 패키지에 구현하되 현재 관리자 URL 관례에 맞춰 `/admin/ai-characters`를 base path로 사용한다. +- 기존 `/admin/chat/character/**`는 호환성을 위해 유지한다. +- 기존 `/admin/chat/original/**`도 호환성을 위해 유지한다. +- legacy 원작 관리자 mutation인 `POST /register`, `PUT /update`, `DELETE /{id}`, `POST /{id}/assign-characters`, `POST /{id}/unassign-characters`는 기존 URL·Request·성공 Response를 유지한 채 같은 v2 원작 command를 호출한다. legacy 캐릭터 `POST /admin/chat/character/register`, `PUT /admin/chat/character/update`는 외부 캐릭터·이미지·DB 작업 뒤 원작 최종 검증만 실패하는 부분 성공을 피하기 위해 전체 mutation을 같은 v2 캐릭터 command로 호출하고, 그 command가 v2 원작 참조·잠금 정책을 사용한다. legacy web adapter가 legacy DTO를 v2 command로 변환하며 v2가 legacy Controller·Service·DTO를 호출하는 역방향 의존은 만들지 않는다. +- legacy 원작 `PUT /update`는 기존 nullable 부분 수정 의미를 유지한다. legacy adapter는 non-null 입력만 v2 호환 patch command로 변환하고, v2 application이 원작 row를 잠근 같은 transaction 안에서 현재 값에 병합한다. adapter가 먼저 읽은 stale snapshot으로 전체 교체하지 않는다. legacy DTO는 누락과 명시적 `null`을 구분하지 못하므로 legacy 경로의 `null`은 계속 “변경 없음”이며, 명시적 값 삭제는 신규 v2 API에서만 지원한다. +- legacy 캐릭터 등록·수정 Request의 nullable 부분 수정과 body `id`는 호환 adapter에서 v2 캐릭터 create/patch/delete command로 변환한다. 원작 값의 정확한 호환 의미는 다음 표와 같고, 신규 `CHAR-03`·`CHAR-04`는 계속 0을 400으로 거부한다. + +| 경로 | `originalWorkId` 누락 또는 `null` | `originalWorkId=0` | 양수 ID | +|---|---|---|---| +| legacy 캐릭터 등록 | 원작 미연결 생성 | 원작 미연결 생성 | 활성 원작에 연결 | +| legacy 캐릭터 수정 | 기존 연결 변경 없음 | 기존 연결 해제 | 활성 원작으로 연결·이동 | +| 신규 `CHAR-03` | 원작 미연결 생성 | 400 | 활성 원작에 연결 | +| 신규 `CHAR-04` | 기존 연결 해제 | 400 | 활성 원작으로 연결·이동 | +- legacy 원작 목록·검색·상세·연결 캐릭터 목록과 일반 사용자용 `/api/chat/original/**` 조회 계약은 그대로 유지한다. legacy mutation의 오류는 기존 legacy 오류 envelope를 유지하되 연결된 원작 삭제, 잘못된 배정·해제처럼 데이터 불변식을 깨는 요청은 더 이상 성공시키지 않는다. +- 신규 API는 REST resource 형태를 사용하고 삭제는 `DELETE`로 표현한다. +- 캐릭터와 creator 작업 리소스는 기존 `isActive=false`, 원작은 기존 `isDeleted=true`인 논리 삭제로 처리한다. +- 원작 관리의 global base path는 `/admin/ai-characters/original-works`다. 원작 목록의 `search` Query가 legacy 목록과 검색 능력을 하나로 합치며 별도 `/search` Endpoint는 만들지 않는다. +- 캐릭터 범위 대행 작업은 Path의 `characterId`가 명시적인 대상이며 Request body에는 `creatorId`를 받지 않는다. global 원작 CRUD는 `characterId`를 요구하지 않는다. +- 신규 Endpoint는 v2 외부의 legacy Controller, Service 또는 DTO를 호출하지 않는다. + +### 7.4 삭제 + +- 관리 API에서 독립 업무 리소스로 다루는 캐릭터, 콘텐츠, 댓글, 카테고리, 시리즈, 게시글, FanTalk 원문과 답글은 물리 삭제하지 않는다. +- 원작 삭제는 기존 `OriginalWork.isDeleted=true`로 처리하고 링크, 태그, 이미지와 번역 이력을 보존한다. +- 활성·비활성 여부와 관계없이 연결 캐릭터가 하나라도 남은 원작 삭제는 409를 반환한다. 관리자가 11.7의 연결 목록과 해제 API로 관계를 명시적으로 정리한 뒤 삭제하게 하며, 삭제 요청이 캐릭터를 암묵적으로 일괄 해제하지 않는다. +- 삭제된 원작은 목록·검색·상세와 신규 배정 대상에서 제외한다. 삭제 command는 `isDeleted`를 연결 수보다 먼저 판정하므로, 과거 불일치로 연결 캐릭터가 남아 있더라도 이미 삭제된 같은 원작의 반복 삭제는 멱등하게 `isDeleted=true`를 반환한다. 복구는 제공하지 않는다. +- legacy 삭제로 이미 `isDeleted=true` 원작을 가리키는 캐릭터가 있는지는 배포 전에 읽기 전용으로 건수를 확인한다. 이번 구현이 이를 자동 해제하거나 data migration하지 않는다. 결과가 0건이면 배포를 진행하고, 1건 이상이면 mutation 전환을 활성화하기 전에 대상과 영향 범위를 보고해 별도 승인된 데이터 보정 계획을 먼저 완료한다. +- 시리즈-콘텐츠와 creator-Member-tag 연결은 독립 업무 리소스나 보존 대상 이력이 아니므로 관계 해제 시 join row만 물리 제거한다. 해당 관계 해제 작업에서는 시리즈·콘텐츠·Member·creator tag 자체를 변경하지 않는다. 콘텐츠 카테고리 연결은 기존 `CategoryContent.isActive` 모델을 유지한다. +- 캐릭터 삭제는 `ChatCharacter.isActive=false`와 연결 `creatorMember.isActive=false`를 같은 작업으로 처리한다. +- 연결 `creatorMember`와 기존 콘텐츠·정산·구매 이력 데이터는 삭제하지 않는다. +- 캐릭터 삭제 시 연결 Member의 이름을 `inactive_*`로 변경하거나 기존 표시 정보를 덮어쓰지 않는다. +- 삭제한 캐릭터의 이름은 재사용하지 않고 예약 상태로 유지한다. +- 외부 캐릭터 레코드는 물리 삭제하거나 rename하지 않는다. 모든 채팅 진입점이 로컬 `ChatCharacter.isActive=false`를 확인해 사용을 차단한다. +- 비활성 캐릭터는 관리자 목록에서 상태 필터로 조회할 수 있다. +- 비활성 캐릭터에 대한 신규 등록·수정·답글 작업은 거부한다. 조회와 기존 리소스의 논리 삭제만 허용한다. +- 캐릭터 삭제 시 소유 콘텐츠 row는 raw `content.isActive=false`로 전환하되 `releaseDate`, 가공 경로, `duration`과 구매 이력은 변경하지 않는다. 따라서 기존에 삭제되지 않은 콘텐츠의 계산 상태는 `SUSPENDED`가 되고, 이미 `isActive=false && releaseDate=null`인 `DELETED` 콘텐츠도 그대로 유지한다. 활성 시리즈·커뮤니티 게시글·콘텐츠 카테고리도 비활성 상태로 전환하며 데이터와 관계는 보존한다. +- 예약 콘텐츠와 처리 중 콘텐츠는 삭제된 캐릭터 명의로 공개되지 않는다. +- 기존 upload-complete는 `releaseDate=null`인 삭제 콘텐츠 또는 비활성 creator의 콘텐츠를 다시 활성화하지 않고, 기존 예약 공개 조회도 활성 creator의 콘텐츠만 대상으로 한다. scheduler component의 cron·lock과 AWS worker는 변경하지 않는다. +- 일반 사용자용 콘텐츠 목록·검색·추천·크리에이터 채널은 기존 `content.isActive=true` 공개 조건으로 삭제 캐릭터의 콘텐츠를 제외한다. 직접 상세 조회도 미구매 사용자에게는 거부하되, 기존 `KEEP`·`RENTAL` 구매자는 주문 이력 기반 상세 조회와 Signed URL 재생을 유지한다. +- 콘텐츠·크리에이터 랭킹 snapshot row는 이력으로 보존한다. latest/previous visible snapshot 조회 시 현재 콘텐츠와 creator Member의 활성 상태를 결합해 삭제 캐릭터와 해당 콘텐츠를 제외하며 snapshot을 삭제하거나 다시 생성하지 않는다. +- snapshot을 사용하지 않는 legacy creator ranking도 조회 시 현재 creator Member가 활성인지 확인한다. +- 기존 캐릭터·콘텐츠·시리즈 banner row는 보존하되 공개 언어별 banner 조회에서 연결 캐릭터, creator 또는 시리즈와 시리즈 소유자가 모두 활성인 대상만 반환한다. 운영 관리자용 banner 목록은 기존 row를 계속 조회할 수 있다. +- 삭제 캐릭터 콘텐츠의 공개 댓글·답글 조회와 신규 등록·본문 수정·재활성화는 차단한다. 작성자 또는 콘텐츠 소유자의 기존 댓글 논리 삭제는 허용한다. 기존 구매 유지 예외는 오디오 재생에만 적용하며 댓글·답글 또는 다른 소셜 작업을 허용하는 근거가 아니다. +- 삭제 commit 후 기존 공개 DTO가 남지 않도록 `default`, `cache_ttl_3_days`, `popularCharacters_24h_locale` cache namespace를 1회 clear한다. 동적 key를 전부 열거하거나 Redis key scan을 추가하지 않는다. rollback과 이미 비활성인 캐릭터의 반복 삭제에서는 clear하지 않으며 legacy 비활성화도 같은 v2 삭제 use case를 통해 동일하게 처리한다. +- FanTalk 원문과 기존 AI 답글은 사용자 작성 이력 보존을 위해 변경하지 않지만 비활성 creator 채널은 공개 조회와 새 FanTalk 수신을 거부한다. +- 이미 구매한 콘텐츠는 구매 이력 기반 재생을 유지한다. +- 캐릭터 복구 기능은 이번 범위에 없으므로 비활성화한 연관 리소스를 자동 복구하지 않는다. +- 공존 기간의 legacy `PUT /admin/chat/character/update`가 활성 캐릭터를 `isActive=false`로 바꾸는 경우에도 동일한 v2 캐릭터 삭제 use case를 호출해 위 cascade를 적용한다. +- legacy 경로를 포함해 비활성 캐릭터의 `isActive=true` 전환은 거부한다. 복구가 필요하면 별도 PRD에서 데이터·외부 시스템 복구 정책을 먼저 정의한다. + +### 7.5 “모든 크리에이터 기능”의 범위 + +현재 요구에 열거된 기능만으로는 실제 크리에이터 기능 전체와 일치하지 않는다. + +이번 PRD는 AI 캐릭터의 비동기 발행·커뮤니티 운영 기능을 완성하는 범위로 정의한다. 요구 목록과 직접 결합된 다음 누락 기능은 이번 범위에 포함한다. + +- 콘텐츠 상단 고정·고정 해제 +- 콘텐츠 카테고리 조회·등록·수정·논리 삭제·순서 변경 및 콘텐츠 구성 +- 콘텐츠 테마 조회 +- 시리즈 콘텐츠 조회·검색·추가·제거 +- 시리즈 순서 변경 +- 시리즈 장르 조회 +- 커뮤니티 게시글 고정·고정 해제 +- 크리에이터 채널 공지 조회·등록·수정 +- 크리에이터 채널 SNS·크리에이터 태그·후원 랭킹 공개 설정 조회·수정 +- 크리에이터 소유자 권한의 FanTalk 원문 논리 삭제 + +조사에서 추가로 확인한 주요 기능의 포함·제외 판단은 다음과 같다. + +| 빠진 기능 | 이번 범위 | 판단 | +|---|---|---| +| 콘텐츠 카테고리 조회·편집·순서 변경 | 포함 | 콘텐츠 발행 화면과 직접 결합된 개인 분류이며 콘텐츠 소유권 검증을 v2에 구현함 | +| 콘텐츠 테마·시리즈 장르 조회 | 포함 | legacy API를 호출하지 않고 v2 content·series query port로 같은 기준정보를 조회함 | +| 크리에이터 채널 공지 수정 | 포함 | 기존 채널 notice 저장소를 v2 port로 연결하고 관리자용 조회·upsert API를 제공함 | +| 채널 SNS URL·후원 랭킹 공개 설정 | 포함 | 캐릭터 원본 정보와 겹치지 않는 Member 채널 설정을 별도 v2 API로 관리함 | +| 크리에이터 Member 태그 조회·편집 | 포함 | `ChatCharacter.tags`와 다른 채널 탐색용 데이터이며 태그가 없으면 일부 크리에이터 탐색 조회에서 제외되므로 별도 관리함 | +| FanTalk 원문 moderation | 포함 | 대상 크리에이터가 타인 작성 FanTalk를 비활성화할 수 있는 현재 권한을 보존함 | +| 팔로워 목록·후원 내역·후원 랭킹 조회 | 제외 | 대행 mutation이 아닌 운영 분석 조회이며 개인정보·재무 권한과 함께 별도 관리자 분석 범위로 다룸 | +| 채널 후원 메시지 조회 | 제외 | 비밀 메시지 시야와 재무 권한 정책이 필요함 | +| 시그니처 후원 설정 | 제외 | 후원 상품·정산 책임 정책 필요 | +| 라이브 방 생성·예약·메뉴·룰렛 | 제외 | 실시간 진행 주체와 장애 대응 정책 필요 | +| 라이브·콘텐츠·후원·커뮤니티 매출/정산 조회 | 제외 | 관리자 전역 정산 기능과 권한·개인정보 정책으로 다뤄야 함 | +| 크리에이터 로그인·로그아웃·메뉴 | 제외 | AI 캐릭터 로그인 금지 정책을 유지함 | +| 이름·이미지·소개 수정 | 캐릭터 수정으로 대체 | `ChatCharacter`가 원본이고 연결 Member로 동기화함 | +| Member 성별 수정 | 캐릭터 수정으로 대체 | AI의 성별 원본은 `ChatCharacter.gender`로 유지하고, 라이브가 제외된 AI creator의 `Member.gender`를 별도 관리하지 않음 | +| 프로필 요청의 `container` | 제외 | 클라이언트 실행 환경 값이지 채널의 관리 대상 설정이 아님 | +| 좋아요·구매·팔로우·후원·DM | 제외 | 크리에이터 운영 작업이 아닌 소비자 행동이며 AI 캐릭터 DM은 금지됨 | + +따라서 이번 범위 완료만으로 라이브·정산을 포함한 문자 그대로의 “크리에이터 전체 기능 동등성”을 선언하지 않는다. 비동기 콘텐츠·커뮤니티 운영 범위의 기능 동등성을 완료 기준으로 삼는다. + +### 7.6 v2 의존성 경계 + +“v2 외부 로직을 재사용하지 않는다”는 비즈니스 로직 경계로 확정한다. + +| 구분 | 정책 | +|---|---| +| legacy Controller | 사용 금지 | +| legacy application/domain Service | 사용 금지 | +| legacy Request/Response DTO | 사용 금지 | +| 기존 v2 domain/application/port | 계약이 맞으면 v2 내부 재사용·확장 허용 | +| 공통 인증과 `ApiResponse` | 플랫폼 공통 기능이므로 재사용 허용 | +| `Member` security principal | web adapter에서 `adminMemberId`로 변환하는 용도로만 허용 | +| 기존 DB 테이블·JPA entity·QueryDSL Q type | persistence adapter에서만 재사용 허용 | +| S3, CDN, 외부 캐릭터 API client | v2 outbound port 뒤의 infrastructure adapter에서 사용 허용 | + +v2 domain과 application은 legacy JPA entity, legacy Repository, security principal 및 web DTO를 public signature에 노출하지 않는다. persistence adapter는 기존 테이블을 읽고 쓰되 v2 port record 또는 v2 domain model로 변환한다. + +### 7.7 원작 관리와 캐릭터 연결 + +- 원작은 특정 캐릭터의 소유 리소스가 아닌 global 관리자 기준정보다. 원작 CRUD 화면에 들어가기 전에 캐릭터를 선택하지 않는다. +- 캐릭터 등록·수정 화면에서는 11.7의 원작 페이징 검색을 사용해 원작을 선택한다. +- 신규 `CHAR-03`의 `originalWorkId=null`은 미연결 생성이고, 신규 `CHAR-04`의 명시적 `originalWorkId=null`은 기존 연결 해제다. 신규 계약은 legacy의 `originalWorkId=0` sentinel을 사용하지 않으며 0 이하는 400이다. +- 양수 `originalWorkId`는 `OriginalWork.isDeleted=false`인 원작만 허용한다. +- 한 캐릭터는 최대 한 원작에만 연결된다. 배정 API로 이미 다른 원작에 연결된 활성 캐릭터를 선택하면 새 원작으로 원자적으로 이동한다. +- 배정은 연결 `creatorMember`까지 유효한 활성 AI 캐릭터만 허용한다. 해제는 삭제 전 관계 정리를 위해 현재 원작에 연결된 활성·비활성 캐릭터를 허용하며, legacy 불일치 관계도 정리할 수 있도록 연결 `creatorMember`의 누락·상태·종류를 해제 조건으로 사용하지 않는다. +- 배정·해제 Request의 ID는 중복 없이 하나 이상이어야 한다. 배정은 모든 ID의 활성 AI 캐릭터 상태를, 해제는 모든 ID의 존재와 현재 원작 귀속을 먼저 검증하며 하나라도 실패하면 전체를 rollback한다. +- 해제는 각 캐릭터가 Path의 원작에 실제 연결되어 있을 때만 수행한다. 다른 원작 소속 또는 미연결 캐릭터를 해제하려는 요청은 `characterIds` 400이다. +- 원작 삭제, 배정, 해제와 `CHAR-03`·`CHAR-04`의 양수 연결 변경은 추가 연결 대상 또는 원작 Path row를 `PESSIMISTIC_WRITE`로 먼저 잠그고, 대상 캐릭터 row를 ID 오름차순으로 잠근 뒤 상태와 귀속을 다시 검증한다. `CHAR-04`의 `null` 해제와 legacy 수정의 `0` 해제는 양수 target 원작이 없으므로 캐릭터 row만 잠그고 현재 귀속을 다시 확인한다. 따라서 삭제의 연결 수 확인과 새 연결 사이에 삭제 원작 참조가 생기지 않는다. +- 삭제되지 않은 동일 제목의 동시 생성을 처리하는 원작 생성과 제목이 실제 바뀌는 수정 command만 DB schema 변경 없이 MySQL `SERIALIZABLE` transaction에서 실행한다. deadlock·serialization 실패는 한 번만 새 transaction으로 재시도한다. 이미지가 저장된 attempt가 실패하면 해당 attempt의 object를 먼저 보상하고, 보상 성공 후에만 다음 transaction을 시작한다. 재조회에서 실제 중복이 확인된 경우에만 `title` 409를 반환한다. +- 원작 생성·수정은 9.5의 전체 교체·nullable 규칙을 따른다. 이미지 생략만 기존 이미지를 유지하고 nullable JSON field의 명시적 `null`과 빈 목록은 값을 지운다. +- 원작 생성은 언어 감지를, 정규화된 `title`, `contentType`, `category`, `description`, `tags` 중 하나 이상이 실제 변경된 수정은 번역 갱신을 commit 후 한 번 예약한다. `writer`, `studio`, URL, 이미지 변경과 단순 캐릭터 배정·해제는 원작 번역 작업을 만들지 않는다. +- 기존 `OriginalWork`, link, tag, `ChatCharacter.originalWork` 테이블·관계를 그대로 사용하며 schema, JPA mapping 또는 데이터 migration을 추가하지 않는다. + +## 8. User Stories + +- 관리자로서 기존 관리자 계정으로 로그인해 AI 캐릭터 관리 메뉴에 접근하고 싶다. +- 관리자로서 활성·비활성 AI 캐릭터를 검색하고 상세 상태를 확인하고 싶다. +- 관리자로서 AI 캐릭터를 등록·수정·논리 삭제하고 싶다. +- 관리자로서 원작을 검색·조회·등록·수정·논리 삭제하고, 원작에 연결된 AI 캐릭터를 배정·해제하고 싶다. +- 관리자로서 캐릭터 등록·수정 화면에서 원작을 이름으로 검색해 선택하거나 연결을 해제하고 싶다. +- 관리자로서 선택한 AI 캐릭터 명의로 콘텐츠와 시리즈를 관리하고 싶다. +- 관리자로서 선택한 AI 캐릭터의 콘텐츠 카테고리 순서와 포함 콘텐츠를 관리하고 싶다. +- 관리자로서 AI 캐릭터 명의로 콘텐츠 및 커뮤니티 댓글을 작성하고 수정하고 싶다. +- 관리자로서 AI 캐릭터 소유 콘텐츠나 게시글에 달린 부적절한 타인의 댓글을 삭제하고 싶다. +- 관리자로서 선택한 AI 캐릭터 명의로 커뮤니티 게시글을 관리하고 싶다. +- 관리자로서 팬이 남긴 FanTalk를 확인하고 AI 캐릭터 명의의 답글을 관리하고 싶다. +- 관리자로서 선택한 AI 캐릭터의 채널 공지를 조회하고 변경하고 싶다. +- 관리자로서 선택한 AI 캐릭터의 채널 SNS, creator tag와 후원 랭킹 공개 설정을 관리하고 싶다. +- 운영 책임자로서 실제 작업 관리자와 대행 대상 AI 캐릭터를 로그에서 구분하고 싶다. + +## 9. Common API Contract + +### 9.1 Authentication + +AI 캐릭터 관리 Endpoint에는 다음 Header가 필요하다. + +```http +Authorization: Bearer +``` + +### 9.2 Response Envelope + +모든 응답은 기존 `ApiResponse` 형식을 사용한다. + +```json +{ + "success": true, + "message": null, + "data": {}, + "errorProperty": null +} +``` + +오류 응답은 다음 형식을 사용한다. + +```json +{ + "success": false, + "message": "요청을 처리할 수 없습니다.", + "data": null, + "errorProperty": "characterId" +} +``` + +### 9.3 Pagination + +신규 목록 API의 Query 기본값과 보정 규칙은 다음과 같다. + +| Field | Type | Rule | +|---|---|---| +| `page` | `Int?` | 0부터 시작, null 또는 음수는 0 | +| `size` | `Int?` | 기본 20, 1 미만은 20, 50 초과는 50 | + +공통 목록 응답은 다음 형식이다. + +```json +{ + "items": [], + "page": 0, + "size": 20, + "totalCount": 0, + "hasNext": false +} +``` + +콘텐츠 테마, 시리즈 장르와 creator tag는 운영 기준정보 전체를 한 번에 선택해야 하고 데이터 수가 제한되므로 페이징하지 않는 예외다. 그 외 관리자 리소스 목록은 공통 페이징 계약을 사용한다. + +### 9.4 Common Mutation Response + +등록·수정·논리 삭제는 `data=null` 대신 변경된 리소스 식별자와 변경 후 상태 또는 표현을 반환한다. Endpoint Summary에서 전용 Response를 선언한 경우 해당 계약이 우선하며, `AdminMutationResponse`를 선언한 Endpoint만 다음 공통 형식을 사용한다. + +| Field | Type | Nullable | Description | +|---|---:|---:|---| +| `id` | `Long` | No | 변경된 리소스 ID | +| `isActive` | `Boolean` | No | 변경 후 활성 상태 | + +### 9.5 Common Representation Rules + +- 모든 ID는 JSON number 형식의 `Long`이다. +- 모든 절대 날짜·시간 Request/Response는 ISO-8601 UTC 문자열로 전송하고 필드명은 `*AtUtc`를 사용한다. 값은 반드시 UTC를 뜻하는 `Z` suffix를 포함한다. +- 프론트엔드는 UTC 원문 또는 epoch millisecond로 저장·비교하고 화면 표시에서만 `Asia/Seoul`로 변환한다. 브라우저·운영체제의 local timezone에 표시 결과를 맡기지 않는다. +- `duration`과 콘텐츠 생성 Request의 `previewStartTime`, `previewEndTime`은 `HH:mm:ss` 형식의 재생 길이·미디어 offset이므로 날짜 객체로 만들거나 KST로 변환하지 않는다. +- 목록이 없으면 `null`이 아니라 빈 배열을 반환한다. +- 선택 필드에 값이 없으면 `null`을 반환한다. +- 리소스 본문을 수정하는 `PUT`은 해당 리소스의 수정 가능한 JSON 표현을 전체 교체한다. 각 API에서 선택 File part로 명시한 항목만 생략 시 기존 값을 유지한다. +- 리소스 수정 `PUT`의 nullable JSON field도 key 자체는 필수이며 값을 지울 때 명시적으로 `null`을 전송한다. 필수 key 누락은 400이다. +- `/pin`, `/fixed`, `/orders` 같은 명령형 `PUT`은 각 Endpoint에 명시한 Request 계약을 우선한다. +- 클라이언트가 `creatorId`나 writer ID를 지정해 작업 주체를 바꿀 수 없게 한다. +- Path resource가 선택한 `characterId`의 소유가 아니면 존재 여부를 노출하지 않는 동일한 not-found 오류로 처리한다. + +### 9.6 Operation ID and Contract Reading Rule + +- 모든 신규 API에는 문서와 클라이언트 구현에서 공통으로 참조할 고유 `Operation ID`를 부여한다. +- `Operation ID`는 문서 식별자이며 Request field나 HTTP Header로 전송하지 않는다. +- Endpoint Summary의 한 행은 하나의 HTTP Endpoint, 하나의 Request 계약, 하나의 Response Data 계약만 나타낸다. +- `Request` 열의 DTO 이름은 해당 도메인 절에 있는 동일 이름의 field 표와 validation을 따른다. +- `Response Data`는 항상 9.2의 `ApiResponse.data`에 들어가는 타입이다. 표에 envelope 전체가 명시된 로그인 예외를 제외하고 클라이언트가 `data`를 한 번 더 중첩하지 않는다. +- 같은 DTO를 여러 Endpoint가 사용해도 각 행에서 Request와 Response를 생략하지 않는다. +- 이 PRD의 신규 Operation은 모두 AI 캐릭터 관리자 웹 클라이언트가 호출할 수 있다. 기존 AWS worker callback은 신규 Operation에 포함하지 않는다. + +## 10. Authentication and v2 Navigation Contract + +### 10.1 Admin Login + +#### Endpoint + +```http +POST /admin/member/login +Content-Type: application/json +``` + +Operation ID: `AUTH-01` + +#### Request + +| Field | Type | Required | Description | +|---|---|---:|---| +| `email` | `String` | Yes | 관리자 Member 이메일 | +| `password` | `String` | Yes | 관리자 Member 비밀번호 | + +```json +{ + "email": "admin@example.com", + "password": "password" +} +``` + +#### Response Data + +| Field | Type | Nullable | Description | +|---|---|---:|---| +| `token` | `String` | No | Bearer JWT | +| `role` | `String` | No | 로그인 Member 역할 | + +```json +{ + "success": true, + "message": null, + "data": { + "token": "", + "role": "ADMIN" + }, + "errorProperty": null +} +``` + +### 10.2 v2 Client Navigation + +- Backend Endpoint 없음 +- Request/Response 없음 +- legacy `GET /menu` 사용 안 함 +- 클라이언트 route root: `/ai-characters` +- route 진입 조건: 로그인 응답의 `role == "ADMIN"` +- 최종 권한 판단: 각 Backend API의 `ROLE_ADMIN` + +## 11. AI Character API + +Base path는 `/admin/ai-characters`이며 모든 Endpoint는 `ROLE_ADMIN` 전용이다. + +### 11.1 Endpoint Summary + +| Operation ID | Method | Endpoint | Request | Response Data | +|---|---|---|---|---| +| `CHAR-01` | `GET` | `/admin/ai-characters` | Query: `page`, `size`, `search`, `isActive` | `AdminPageResponse` | +| `CHAR-02` | `GET` | `/admin/ai-characters/{characterId}` | Path: `characterId` | `AdminAiCharacterDetailResponse` | +| `CHAR-03` | `POST` | `/admin/ai-characters` | multipart: `image`, `request=AdminAiCharacterCreateRequest` | `AdminAiCharacterMutationResponse` | +| `CHAR-04` | `PUT` | `/admin/ai-characters/{characterId}` | Path: `characterId`; multipart: `image?`, `request=AdminAiCharacterUpdateRequest` | `AdminAiCharacterMutationResponse` | +| `CHAR-05` | `DELETE` | `/admin/ai-characters/{characterId}` | Path: `characterId`; Body 없음 | `AdminAiCharacterDeleteResponse` | + +### 11.2 CHAR-01 · Character List + +#### Request + +| Field | In | Type | Required | Description | +|---|---|---|---:|---| +| `page` | Query | `Int` | No | 기본 0 | +| `size` | Query | `Int` | No | 기본 20, 최대 50 | +| `search` | Query | `String` | No | 이름 검색어, trim 후 빈 값은 미적용 | +| `isActive` | Query | `Boolean` | No | null이면 전체, true/false면 상태 필터 | + +#### Response Data + +`AdminAiCharacterSummaryResponse` + +| Field | Type | Nullable | +|---|---|---:| +| `characterId` | `Long` | No | +| `creatorId` | `Long` | No | +| `name` | `String` | No | +| `imageUrl` | `String` | Yes | +| `description` | `String` | No | +| `gender` | `String` | Yes | +| `age` | `Int` | Yes | +| `mbti` | `String` | Yes | +| `region` | `String` | No | +| `tags` | `List` | No | +| `isActive` | `Boolean` | No | +| `createdAtUtc` | `String` | Yes | +| `updatedAtUtc` | `String` | Yes | + +기본 정렬은 응답 기준 `createdAtUtc DESC, characterId DESC`다. + +### 11.3 CHAR-02 · Character Detail + +#### Request + +- Path `characterId: Long` + +#### Response Data + +`AdminAiCharacterDetailResponse` + +| Field | Type | Nullable | +|---|---|---:| +| `characterId` | `Long` | No | +| `creatorId` | `Long` | No | +| `characterUuid` | `String` | No | +| `name` | `String` | No | +| `imageUrl` | `String` | Yes | +| `description` | `String` | No | +| `systemPrompt` | `String` | No | +| `characterType` | `String` | No | +| `age` | `Int` | Yes | +| `gender` | `String` | Yes | +| `mbti` | `String` | Yes | +| `speechPattern` | `String` | Yes | +| `speechStyle` | `String` | Yes | +| `appearance` | `String` | Yes | +| `region` | `String` | No | +| `isActive` | `Boolean` | No | +| `tags` | `List` | No | +| `hobbies` | `List` | No | +| `values` | `List` | No | +| `goals` | `List` | No | +| `relationships` | `List` | No | +| `personalities` | `List` | No | +| `backgrounds` | `List` | No | +| `memories` | `List` | No | +| `originalWork` | `OriginalWorkBrief` | Yes | +| `createdAtUtc` | `String` | Yes | +| `updatedAtUtc` | `String` | Yes | + +중첩 객체 필드는 기존 캐릭터 상세 응답의 다음 계약을 유지한다. + +- `CharacterRelationship`: `personName`, `relationshipName`, `description`, `importance`, `relationshipType`, `currentStatus` +- `CharacterPersonality`: `trait`, `description` +- `CharacterBackground`: `topic`, `description` +- `CharacterMemory`: `title`, `content`, `emotion` +- `OriginalWorkBrief`: `id: Long`, `imageUrl: String?`, `title: String` + +### 11.4 CHAR-03 · Character Create + +#### Request + +```http +Content-Type: multipart/form-data +``` + +| Part | Type | Required | Description | +|---|---|---:|---| +| `image` | File | Yes | 캐릭터 대표 이미지 | +| `request` | JSON string | Yes | `AdminAiCharacterCreateRequest` | + +`AdminAiCharacterCreateRequest` + +| Field | Type | Required | Default | +|---|---|---:|---| +| `name` | `String` | Yes | - | +| `systemPrompt` | `String` | Yes | - | +| `description` | `String` | Yes | - | +| `age` | `Int` | No | `null` | +| `gender` | `String` | No | `null` | +| `mbti` | `String` | No | `null` | +| `speechPattern` | `String` | No | `null` | +| `speechStyle` | `String` | No | `null` | +| `appearance` | `String` | No | `null` | +| `region` | `String` | No | `"KR"` | +| `originalWorkId` | `Long` | No | `null` | +| `characterType` | `String` | No | `"Character"` | +| `tags` | `List` | No | `[]` | +| `hobbies` | `List` | No | `[]` | +| `values` | `List` | No | `[]` | +| `goals` | `List` | No | `[]` | +| `relationships` | `List` | No | `[]` | +| `personalities` | `List` | No | `[]` | +| `backgrounds` | `List` | No | `[]` | +| `memories` | `List` | No | `[]` | + +- `characterType`은 `Character`, `Clone`만 허용한다. +- `age`는 0 이상의 정수다. +- `region`은 대문자 ISO 3166-1 alpha-2 국가 코드다. +- `originalWorkId`는 양수이고 `OriginalWork.isDeleted=false`인 원작을 가리켜야 한다. `null`이면 원작 연결 없이 생성하고 legacy의 해제 sentinel인 `0`은 허용하지 않는다. 0 이하는 400, 미존재 양수 ID는 404, 삭제된 원작 ID는 409이며 모두 `errorProperty="originalWorkId"`다. + +중첩 Request 계약은 다음과 같다. + +| Type | Required fields | +|---|---| +| `CharacterRelationship` | `personName: String`, `relationshipName: String`, `description: String`, `importance: Int`, `relationshipType: String`, `currentStatus: String` | +| `CharacterPersonality` | `trait: String`, `description: String` | +| `CharacterBackground` | `topic: String`, `description: String` | +| `CharacterMemory` | `title: String`, `content: String`, `emotion: String` | + +중첩 객체의 String field는 trim 후 빈 값일 수 없다. + +#### Response Data + +`AdminAiCharacterMutationResponse` + +| Field | Type | Nullable | Description | +|---|---|---:|---| +| `characterId` | `Long` | No | 생성된 캐릭터 ID | +| `creatorId` | `Long` | No | 함께 생성된 AI 크리에이터 Member ID | +| `isActive` | `Boolean` | No | 변경 후 캐릭터 활성 상태 | + +```json +{ + "characterId": 101, + "creatorId": 10001, + "isActive": true +} +``` + +등록 성공 시 같은 작업 흐름에서 연결 `creatorMember`를 생성하고 그 ID를 응답의 `creatorId`로 반환한다. + +### 11.5 CHAR-04 · Character Update + +#### Request + +```http +Content-Type: multipart/form-data +``` + +| Part | Type | Required | Description | +|---|---|---:|---| +| `image` | File | No | 새 대표 이미지 | +| `request` | JSON string | Yes | `AdminAiCharacterUpdateRequest` | + +`AdminAiCharacterUpdateRequest`는 `region`을 제외한 `AdminAiCharacterCreateRequest`의 모든 field key를 필수로 받는다. `age`, `gender`, `mbti`, `speechPattern`, `speechStyle`, `appearance`, `originalWorkId`는 명시적 `null`로 값을 지울 수 있고, 목록은 빈 배열로 전체 삭제할 수 있다. `characterType`은 non-null이며 `Character`, `Clone` 중 하나여야 한다. `characterId`, `isActive`는 body에서 받지 않는다. + +`region`은 캐릭터 생성 후 변경할 수 없는 값으로 유지한다. 이미지 File part를 생략한 경우에만 기존 이미지를 유지한다. +양수 `originalWorkId`는 `OriginalWork.isDeleted=false`인지 검증하고, 명시적 `null`은 기존 원작 연결을 해제한다. key 누락과 `0` 이하는 400, 미존재 양수 ID는 404, 삭제된 원작 ID는 409이며 모두 `errorProperty="originalWorkId"`다. + +#### Response Data + +`AdminAiCharacterMutationResponse` + +```json +{ + "characterId": 101, + "creatorId": 10001, + "isActive": true +} +``` + +이름, 이미지 또는 소개가 바뀌면 연결 Member의 `nickname`, `profileImage`, `introduce`도 같은 작업 안에서 동기화한다. + +### 11.6 CHAR-05 · Character Delete + +#### Request + +- Path `characterId: Long` +- Body 없음 + +#### Response Data + +`AdminAiCharacterDeleteResponse` + +```json +{ + "characterId": 101, + "creatorId": 10001, + "characterIsActive": false, + "creatorIsActive": false +} +``` + +동일 캐릭터에 대한 반복 삭제는 멱등하게 같은 결과를 반환한다. + +### 11.7 Original Work Management API + +원작은 특정 캐릭터를 먼저 선택하지 않는 global 관리자 리소스다. Base path는 `/admin/ai-characters/original-works`이며 모든 Endpoint는 `ROLE_ADMIN` 전용이다. + +#### 11.7.1 Endpoint Summary + +| Operation ID | Method | Endpoint | Request | Response Data | +|---|---|---|---|---| +| `ORIGINAL-WORK-01` | `GET` | `/admin/ai-characters/original-works` | Query: `page`, `size`, `search` | `AdminPageResponse` | +| `ORIGINAL-WORK-02` | `GET` | `/admin/ai-characters/original-works/{originalWorkId}` | Path: `originalWorkId` | `AdminOriginalWorkDetailResponse` | +| `ORIGINAL-WORK-03` | `POST` | `/admin/ai-characters/original-works` | multipart: `image`, `request=AdminOriginalWorkCreateRequest` | `AdminOriginalWorkMutationResponse` | +| `ORIGINAL-WORK-04` | `PUT` | `/admin/ai-characters/original-works/{originalWorkId}` | Path: `originalWorkId`; multipart: `image?`, `request=AdminOriginalWorkUpdateRequest` | `AdminOriginalWorkMutationResponse` | +| `ORIGINAL-WORK-05` | `DELETE` | `/admin/ai-characters/original-works/{originalWorkId}` | Path: `originalWorkId`; Body 없음 | `AdminOriginalWorkMutationResponse` | +| `ORIGINAL-WORK-06` | `GET` | `/admin/ai-characters/original-works/{originalWorkId}/characters` | Path: `originalWorkId`; Query: `page`, `size`, `search`, `isActive` | `AdminPageResponse` | +| `ORIGINAL-WORK-07` | `POST` | `/admin/ai-characters/original-works/{originalWorkId}/characters` | Path: `originalWorkId`; JSON: `AdminOriginalWorkCharacterIdsRequest` | `AdminOriginalWorkCharacterAssignmentResponse` | +| `ORIGINAL-WORK-08` | `DELETE` | `/admin/ai-characters/original-works/{originalWorkId}/characters` | Path: `originalWorkId`; JSON: `AdminOriginalWorkCharacterIdsRequest` | `AdminOriginalWorkCharacterAssignmentResponse` | + +`ORIGINAL-WORK-01`의 `search`가 legacy 목록과 검색 기능을 합친다. 별도 `/search` Endpoint와 비페이징 전체 검색은 만들지 않는다. + +#### 11.7.2 Common Fields and Validation + +원작 생성·수정의 JSON field는 다음과 같다. + +| Field | Type | Create | Update | Default / Null meaning | +|---|---|---:|---:|---| +| `title` | `String` | Required | Required | trim 후 빈 값 불가 | +| `contentType` | `String` | Required | Required | trim 후 빈 값 불가 | +| `category` | `String` | Required | Required | trim 후 빈 값 불가 | +| `isAdult` | `Boolean` | Optional | Required | 생성 기본 `false` | +| `description` | `String` | Optional | Required | 생성 기본 `""`, 빈 문자열 허용 | +| `originalWork` | `String?` | Optional | Required | 생성 기본 `null`, 명시적 `null`은 값 삭제 | +| `originalLink` | `String?` | Optional | Required | 생성 기본 `null`, 명시적 `null`은 값 삭제 | +| `writer` | `String?` | Optional | Required | 생성 기본 `null`, 명시적 `null`은 값 삭제 | +| `studio` | `String?` | Optional | Required | 생성 기본 `null`, 명시적 `null`은 값 삭제 | +| `originalLinks` | `List` | Optional | Required | 생성 기본 `[]`, 빈 배열은 전체 삭제 | +| `tags` | `List` | Optional | Required | 생성 기본 `[]`, 빈 배열은 전체 삭제 | + +- 수정 JSON은 `PUT` 전체 교체이므로 위 11개 key를 모두 전송한다. nullable key 누락은 400이고 값 삭제는 명시적 `null`로 표현한다. +- `title`, `contentType`, `category`, nullable 문자열, 링크와 태그는 trim한다. nullable 문자열의 trim 결과가 빈 값이면 `null`로 정규화한다. +- `originalLink`와 `originalLinks`의 값은 `http` 또는 `https` 절대 URL이어야 한다. +- `originalLinks`와 `tags`는 trim 후 빈 값을 제거하고 첫 등장 순서를 유지한 채 중복을 제거한다. +- 신규 v2 저장은 정규화된 `originalLinks`와 `tags`를 요청 순서대로 다시 만들고, 조회 projection은 link ID와 tag-mapping ID 오름차순으로 명시적으로 정렬한다. JPA collection의 암묵적 조회 순서에는 의존하지 않는다. +- `title` 중복은 양쪽 값을 trim한 뒤 대소문자를 무시해 비교한다. 따라서 `"Moon"`과 `" moon "`은 같은 제목이다. 삭제되지 않은 동일 제목이 있으면 생성과 수정 모두 409, `errorProperty="title"`이며 현재 리소스 자신의 ID는 충돌에서 제외한다. +- 생성 이미지 part는 필수이고 수정 이미지는 선택이다. 실제 MIME이 이미지인지 검증하며 GIF는 거부한다. 수정에서 이미지를 생략한 경우에만 기존 이미지를 유지한다. +- 목록·검색·상세는 `isDeleted=false`인 원작만 반환한다. 삭제된 원작의 수정·배정·해제는 409이고 반복 삭제만 허용한다. +- `characterCount`는 활성·비활성 또는 연결 creator 상태와 관계없이 현재 원작을 참조하는 `ChatCharacter` row 수다. 원작 삭제 가능 여부도 같은 기준을 사용한다. +- Path와 캐릭터 요청의 `originalWorkId`는 양수여야 한다. 0 이하는 400, 미존재 양수 ID는 404다. 삭제된 ID는 조회에서 404, 수정·배정·해제에서 409이며 DELETE 재시도만 200이다. +- 모든 날짜·시간은 UTC `Z` 문자열이다. + +`AdminOriginalWorkSummaryResponse` + +| Field | Type | Nullable | +|---|---|---:| +| `originalWorkId` | `Long` | No | +| `title` | `String` | No | +| `contentType` | `String` | No | +| `category` | `String` | No | +| `isAdult` | `Boolean` | No | +| `imageUrl` | `String` | Yes | +| `characterCount` | `Long` | No | +| `createdAtUtc` | `String` | Yes | +| `updatedAtUtc` | `String` | Yes | + +`AdminOriginalWorkDetailResponse`는 summary field에 다음 field를 추가한다. + +| Field | Type | Nullable | +|---|---|---:| +| `description` | `String` | No | +| `originalWork` | `String` | Yes | +| `originalLink` | `String` | Yes | +| `writer` | `String` | Yes | +| `studio` | `String` | Yes | +| `originalLinks` | `List` | No | +| `tags` | `List` | No | + +`AdminOriginalWorkMutationResponse` + +| Field | Type | Nullable | +|---|---|---:| +| `originalWorkId` | `Long` | No | +| `isDeleted` | `Boolean` | No | + +`AdminOriginalWorkCharacterResponse` + +| Field | Type | Nullable | +|---|---|---:| +| `characterId` | `Long` | No | +| `creatorId` | `Long` | Yes | +| `name` | `String` | No | +| `imageUrl` | `String` | Yes | +| `isActive` | `Boolean` | No | +| `createdAtUtc` | `String` | Yes | + +`creatorId`는 정상 AI 캐릭터에서는 항상 값이 있지만, legacy에서 이미 생긴 잘못된 연결도 목록에 노출하고 해제할 수 있도록 nullable이다. + +`AdminOriginalWorkCharacterIdsRequest` + +| Field | Type | Nullable | Validation | +|---|---|---:|---| +| `characterIds` | `List` | No | 양수 ID 1개 이상, 중복 불가 | + +`AdminOriginalWorkCharacterAssignmentResponse` + +| Field | Type | Nullable | +|---|---|---:| +| `originalWorkId` | `Long` | No | +| `characterIds` | `List` | No | +| `characterCount` | `Long` | No | + +배정·해제 Response의 `characterIds`는 실제 변경 여부와 관계없이 검증을 통과한 요청 ID 전체를 요청 순서대로 반환한다. 따라서 같은 원작 반복 배정의 ID도 포함된다. `characterCount`는 작업 완료 후 해당 원작을 참조하는 전체 `ChatCharacter` row 수이며 처리 건수가 아니다. + +#### 11.7.3 ORIGINAL-WORK-01 · Original Work List and Search + +Request: + +- Path 없음 +- Request JSON 없음 +- Query `page: Int?`, `size: Int?`, `search: String?` +- `search`는 trim 후 빈 값이면 적용하지 않고, 값이 있으면 `title`, `contentType`, `category`의 대소문자 무시 부분 일치를 적용한다. +- 기본 정렬은 `createdAtUtc DESC, originalWorkId DESC`다. + +Response JSON: + +```json +{ + "success": true, + "message": null, + "data": { + "items": [ + { + "originalWorkId": 71, + "title": "달빛 도서관", + "contentType": "WEB_NOVEL", + "category": "FANTASY", + "isAdult": false, + "imageUrl": "https://cdn.example.com/originals/71/original.webp", + "characterCount": 2, + "createdAtUtc": "2026-07-20T01:00:00Z", + "updatedAtUtc": "2026-07-20T02:00:00Z" + } + ], + "page": 0, + "size": 20, + "totalCount": 1, + "hasNext": false + }, + "errorProperty": null +} +``` + +#### 11.7.4 ORIGINAL-WORK-02 · Original Work Detail + +Request: + +- Path `originalWorkId: Long` +- Request JSON 없음 + +Response JSON: + +```json +{ + "success": true, + "message": null, + "data": { + "originalWorkId": 71, + "title": "달빛 도서관", + "contentType": "WEB_NOVEL", + "category": "FANTASY", + "isAdult": false, + "description": "밤에만 문을 여는 도서관의 이야기", + "originalWork": "Moonlight Library", + "originalLink": "https://example.com/works/71", + "writer": "김작가", + "studio": "소다 스튜디오", + "originalLinks": [ + "https://example.com/works/71", + "https://example.com/works/71/official" + ], + "tags": ["힐링", "판타지"], + "imageUrl": "https://cdn.example.com/originals/71/original.webp", + "characterCount": 2, + "createdAtUtc": "2026-07-20T01:00:00Z", + "updatedAtUtc": "2026-07-20T02:00:00Z" + }, + "errorProperty": null +} +``` + +#### 11.7.5 ORIGINAL-WORK-03 · Original Work Create + +Request: + +```http +Content-Type: multipart/form-data +``` + +| Part | Type | Required | Description | +|---|---|---:|---| +| `image` | File | Yes | 원작 대표 이미지 | +| `request` | JSON string | Yes | 아래 Request JSON을 `JSON.stringify`한 값 | + +Request JSON: + +```json +{ + "title": "달빛 도서관", + "contentType": "WEB_NOVEL", + "category": "FANTASY", + "isAdult": false, + "description": "밤에만 문을 여는 도서관의 이야기", + "originalWork": "Moonlight Library", + "originalLink": "https://example.com/works/71", + "writer": "김작가", + "studio": "소다 스튜디오", + "originalLinks": [ + "https://example.com/works/71", + "https://example.com/works/71/official" + ], + "tags": ["힐링", "판타지"] +} +``` + +Response JSON: + +```json +{ + "success": true, + "message": null, + "data": { + "originalWorkId": 71, + "isDeleted": false + }, + "errorProperty": null +} +``` + +#### 11.7.6 ORIGINAL-WORK-04 · Original Work Update + +Request: + +```http +Content-Type: multipart/form-data +``` + +| Part | Type | Required | Description | +|---|---|---:|---| +| `image` | File | No | 새 원작 대표 이미지, 생략 시 기존 이미지 유지 | +| `request` | JSON string | Yes | 아래 11개 key를 모두 가진 Request JSON | + +Request JSON: + +```json +{ + "title": "달빛 도서관 개정판", + "contentType": "WEB_NOVEL", + "category": "FANTASY", + "isAdult": false, + "description": "개정된 작품 소개", + "originalWork": null, + "originalLink": null, + "writer": "김작가", + "studio": "소다 스튜디오", + "originalLinks": [], + "tags": ["판타지"] +} +``` + +Response JSON: + +```json +{ + "success": true, + "message": null, + "data": { + "originalWorkId": 71, + "isDeleted": false + }, + "errorProperty": null +} +``` + +#### 11.7.7 ORIGINAL-WORK-05 · Original Work Delete + +Request: + +- Path `originalWorkId: Long` +- Request JSON 없음 + +삭제되지 않은 원작에 연결된 `ChatCharacter`가 하나라도 있으면 409와 `errorProperty="originalWorkId"`를 반환한다. 연결이 없으면 `isDeleted=true`로 변경하며 링크, 태그, 이미지와 번역 이력은 보존한다. 이미 `isDeleted=true`이면 연결 수를 검사하지 않고 멱등하게 같은 Response를 반환한다. + +Response JSON: + +```json +{ + "success": true, + "message": null, + "data": { + "originalWorkId": 71, + "isDeleted": true + }, + "errorProperty": null +} +``` + +#### 11.7.8 ORIGINAL-WORK-06 · Assigned Character List + +Request: + +- Path `originalWorkId: Long` +- Request JSON 없음 +- Query `page: Int?`, `size: Int?`, `search: String?`, `isActive: Boolean?` +- `search`는 캐릭터 이름의 대소문자 무시 부분 검색이다. +- `isActive=null`이면 활성·비활성 캐릭터를 모두 반환한다. +- 기본 정렬은 `createdAtUtc DESC, characterId DESC`다. + +Response JSON: + +```json +{ + "success": true, + "message": null, + "data": { + "items": [ + { + "characterId": 101, + "creatorId": 10001, + "name": "루나", + "imageUrl": "https://cdn.example.com/characters/101.webp", + "isActive": true, + "createdAtUtc": "2026-07-20T01:30:00Z" + } + ], + "page": 0, + "size": 20, + "totalCount": 1, + "hasNext": false + }, + "errorProperty": null +} +``` + +#### 11.7.9 ORIGINAL-WORK-07 · Assign Characters + +`CHAR-01`에서 조회한 활성 AI 캐릭터를 배정한다. 이미 다른 원작에 연결된 캐릭터는 이 원작으로 이동하고, 이미 같은 원작에 연결된 캐릭터의 반복 배정은 멱등하다. + +Request JSON: + +```json +{ + "characterIds": [101, 102] +} +``` + +- `characterIds`는 중복 없는 양수 ID를 하나 이상 포함해야 한다. +- 모든 ID가 존재하고 활성 AI 캐릭터인지 먼저 검증한다. +- 하나라도 잘못되면 아무 캐릭터도 이동하지 않는다. + +Response JSON: + +```json +{ + "success": true, + "message": null, + "data": { + "originalWorkId": 71, + "characterIds": [101, 102], + "characterCount": 2 + }, + "errorProperty": null +} +``` + +#### 11.7.10 ORIGINAL-WORK-08 · Unassign Characters + +HTTP `DELETE` 요청에 JSON body를 전송하며 `Content-Type: application/json`을 사용한다. + +Request JSON: + +```json +{ + "characterIds": [101, 102] +} +``` + +- `characterIds`는 중복 없는 양수 ID를 하나 이상 포함해야 한다. +- 활성·비활성 및 연결 creator 상태와 관계없이 기존 `ChatCharacter`를 해제할 수 있지만, 모든 캐릭터가 Path의 원작에 실제 연결되어 있어야 한다. +- 미존재, 미연결 또는 다른 원작 소속 ID가 하나라도 있으면 400과 `errorProperty="characterIds"`를 반환하고 전체를 rollback한다. + +Response JSON: + +```json +{ + "success": true, + "message": null, + "data": { + "originalWorkId": 71, + "characterIds": [101, 102], + "characterCount": 0 + }, + "errorProperty": null +} +``` + +## 12. Content API + +Base path는 `/admin/ai-characters/{characterId}/contents`다. + +### 12.1 Endpoint Summary + +| Operation ID | Caller | Method | Endpoint | Request | Response Data | +|---|---|---|---|---|---| +| `CONTENT-01` | Web | `GET` | `/admin/ai-characters/{characterId}/contents` | Path: `characterId`; Query: `page`, `size`, `search`, `isActive`, `status` | `AdminPageResponse` | +| `CONTENT-02` | Web | `GET` | `/admin/ai-characters/{characterId}/contents/{contentId}` | Path: `characterId`, `contentId` | `AdminAiContentResponse` | +| `CONTENT-03` | Web | `POST` | `/admin/ai-characters/{characterId}/contents` | Path: `characterId`; multipart: `contentFile`, `coverImage`, `request=AdminAiContentCreateRequest` | `AdminAiContentCreateResponse` | +| `CONTENT-04` | Web | `PUT` | `/admin/ai-characters/{characterId}/contents/{contentId}` | Path: `characterId`, `contentId`; multipart: `coverImage?`, `request=AdminAiContentUpdateRequest` | `AdminMutationResponse` | +| `CONTENT-05` | Web | `DELETE` | `/admin/ai-characters/{characterId}/contents/{contentId}` | Path: `characterId`, `contentId`; Body 없음 | `AdminMutationResponse` | +| `CONTENT-06` | Web | `PUT` | `/admin/ai-characters/{characterId}/contents/{contentId}/pin` | Path: `characterId`, `contentId`; JSON: `AdminAiContentPinRequest` | `AdminAiContentPinResponse` | +| `CONTENT-07` | Web | `GET` | `/admin/ai-characters/metadata/content-themes` | 없음 | `List` | + +### 12.2 CONTENT-01 · Content List + +#### Request + +| Field | In | Type | Required | Description | +|---|---|---|---:|---| +| `page` | Query | `Int` | No | 기본 0 | +| `size` | Query | `Int` | No | 기본 20, 최대 50 | +| `search` | Query | `String` | No | 제목·설명 검색 | +| `isActive` | Query | `Boolean` | No | null이면 전체 | +| `status` | Query | `String` | No | `PROCESSING`, `SCHEDULED`, `PUBLISHED`, `SUSPENDED`, `DELETED`; null이면 전체 | + +#### Response Data + +`AdminAiContentResponse` + +| Field | Type | Nullable | +|---|---|---:| +| `contentId` | `Long` | No | +| `title` | `String` | No | +| `detail` | `String` | No | +| `coverImageUrl` | `String` | Yes | +| `contentUrl` | `String` | Yes | +| `contentUrlExpiresAtUtc` | `String` | Yes | +| `themeId` | `Long` | No | +| `theme` | `String` | No | +| `price` | `Int` | No | +| `purchaseOption` | `String` | No | +| `limited` | `Int` | Yes | +| `totalContentCount` | `Int` | Yes | +| `remainingContentCount` | `Int` | Yes | +| `isAdult` | `Boolean` | No | +| `isActive` | `Boolean` | No | +| `isPointAvailable` | `Boolean` | No | +| `isCommentAvailable` | `Boolean` | No | +| `isGeneratePreview` | `Boolean` | No | +| `isOnlyRental` | `Boolean` | No | +| `isFullDetailVisible` | `Boolean` | No | +| `languageCode` | `String` | Yes | +| `isPinned` | `Boolean` | No | +| `status` | `String` | No | +| `duration` | `String` | Yes | +| `releaseAtUtc` | `String` | Yes | +| `tags` | `List` | No | +| `createdAtUtc` | `String` | Yes | +| `updatedAtUtc` | `String` | Yes | + +기본 정렬은 고정 콘텐츠 우선, 그다음 응답 기준 `createdAtUtc DESC, contentId DESC`다. +`status`는 DB에 저장하는 컬럼이 아니라 기존 `content` row와 연결 creator 상태에서 계산하는 `PROCESSING`, `SCHEDULED`, `PUBLISHED`, `SUSPENDED`, `DELETED` 중 하나다. Query application은 요청마다 하나의 UTC 기준 시각을 얻고 다음 우선순위를 목록 필터와 응답에 동일하게 적용한다. + +1. `isActive=false && releaseDate=null`이면 `DELETED` +2. 연결 creator Member가 비활성이면 `SUSPENDED` +3. `isActive=true`이면 `PUBLISHED` +4. `duration=null`이면 `PROCESSING` +5. `isActive=false && duration!=null && releaseDate>now`이면 `SCHEDULED` +6. 나머지 비활성 콘텐츠는 `SUSPENDED` + +저장된 `content` 경로는 상태 계산 컬럼이 아니라 Signed URL 발급 가능성을 판단하는 canonical output key로만 사용한다. +`AdminAiContentResponse.isActive`와 CONTENT-01의 `isActive` 필터는 `content.isActive && creatorMember.isActive`인 유효 활성 상태를 사용한다. 신규·legacy 캐릭터 삭제 cascade는 raw `content.isActive=false`로 전환한다. 과거 데이터 불일치로 비활성 creator row에 raw `content.isActive=true`가 남아 있어도 관리자 응답은 `isActive=false`, `status=SUSPENDED`로 일관되게 반환한다. + +`coverImageUrl`과 콘텐츠 재생 URL의 계약은 다음과 같다. + +| Field | Contract | +|---|---| +| `coverImageUrl` | 커버 저장 경로가 있을 때 반환하는 일반 CDN 절대 URL이다. Signed URL이 아니며 저장 경로나 빈 문자열을 반환하지 않는다. | +| `contentUrl` | 가공 완료된 전체 오디오의 CloudFront Signed URL이다. DB/S3 원시 경로, `input/*` 원본 업로드 경로 또는 `preview/*` URL을 반환하지 않는다. | +| `contentUrlExpiresAtUtc` | `contentUrl`의 만료 시각을 나타내는 ISO-8601 UTC 문자열이다. `contentUrl=null`이면 반드시 `null`이다. | + +Signed URL 발급 규칙은 다음과 같다. + +- `SCHEDULED` 또는 `PUBLISHED`이면서 12.9의 canonical `output/{contentId}/...` 상대 key와 유효한 `duration`이 모두 있을 때만 `contentUrl`을 발급한다. +- `PROCESSING`, `SUSPENDED`, `DELETED`에서는 저장 경로가 남아 있어도 `contentUrl=null`, `contentUrlExpiresAtUtc=null`을 반환한다. +- 목록과 상세는 응답을 만들 때마다 새로운 Signed URL을 발급한다. +- TTL은 legacy 크리에이터 관리자와 동일하게 `(duration의 HH 부분 + 2)시간`이다. +- URL policy의 실제 만료 시각과 `contentUrlExpiresAtUtc`는 하나의 기준 `Instant`에서 계산한 동일한 절대 시각이며 ISO-8601 표현 정밀도 안에서 일치해야 한다. +- URL 만료 또는 만료 임박 시 클라이언트는 `CONTENT-02`를 다시 호출해 갱신한다. 별도 URL 갱신 Endpoint는 만들지 않는다. +- 서명 실패 시 raw path, 일반 CDN URL 또는 빈 문자열로 fallback하지 않고 요청을 실패 처리한다. +- `CONTENT-01`, `CONTENT-02` 응답에는 `Cache-Control: private, no-store`를 적용한다. + +`AdminAiContentResponse` 예시는 다음과 같다. + +```json +{ + "contentId": 2001, + "title": "비 오는 밤", + "detail": "수면을 위한 빗소리", + "coverImageUrl": "https://cdn.example.com/audio_content_cover/2001/cover.webp", + "contentUrl": "https://audio.example.com/output/2001/audio.m4a?Expires=...", + "contentUrlExpiresAtUtc": "2026-07-20T14:00:00Z", + "themeId": 1, + "theme": "ASMR", + "price": 0, + "purchaseOption": "BOTH", + "limited": null, + "totalContentCount": null, + "remainingContentCount": null, + "isAdult": false, + "isActive": true, + "isPointAvailable": false, + "isCommentAvailable": true, + "isGeneratePreview": false, + "isOnlyRental": false, + "isFullDetailVisible": true, + "languageCode": "ko", + "isPinned": false, + "status": "PUBLISHED", + "duration": "00:10:30", + "releaseAtUtc": "2026-07-20T12:00:00Z", + "tags": ["수면", "빗소리"], + "createdAtUtc": "2026-07-20T11:00:00Z", + "updatedAtUtc": "2026-07-20T12:00:00Z" +} +``` + +### 12.3 CONTENT-02 · Content Detail + +#### Request + +- Path `characterId: Long` +- Path `contentId: Long` +- Query와 Body 없음 + +#### Response Data + +- `AdminAiContentResponse` +- field와 Signed URL 규칙은 12.2와 같다. +- 상세를 다시 조회할 때마다 새로운 `contentUrl`, `contentUrlExpiresAtUtc`를 반환한다. + +### 12.4 CONTENT-03 · Content Create + +#### Request + +```http +Content-Type: multipart/form-data +``` + +| Part | Type | Required | Description | +|---|---|---:|---| +| `contentFile` | File | Yes | 비동기 가공 파이프라인에 전달할 원본 오디오 파일 | +| `coverImage` | File | Yes | 커버 이미지 | +| `request` | JSON string | Yes | `AdminAiContentCreateRequest` | + +`AdminAiContentCreateRequest` + +| Field | Type | Required | Default | +|---|---|---:|---| +| `title` | `String` | Yes | - | +| `detail` | `String` | Yes | - | +| `tags` | `List` | Yes | - | +| `price` | `Int` | Yes | - | +| `purchaseOption` | `String` | No | `"BOTH"` | +| `limited` | `Int` | No | `null` | +| `releaseAtUtc` | `String` | No | `null` | +| `themeId` | `Long` | Yes | - | +| `isAdult` | `Boolean` | No | `false` | +| `isGeneratePreview` | `Boolean` | No | `false` | +| `isOnlyRental` | `Boolean` | No | `false` | +| `isPointAvailable` | `Boolean` | No | `false` | +| `isCommentAvailable` | `Boolean` | No | `false` | +| `isFullDetailVisible` | `Boolean` | No | `true` | +| `previewStartTime` | `String` | No | `null` | +| `previewEndTime` | `String` | No | `null` | +| `languageCode` | `String` | No | `null` | + +- `releaseAtUtc`는 ISO-8601 UTC 형식이며 `null`이면 가공 완료 후 즉시 공개한다. +- `themeId`는 0보다 커야 하고 활성 테마를 가리켜야 한다. +- `previewStartTime`, `previewEndTime`은 둘 다 보내거나 둘 다 생략하며 값 형식은 `HH:mm:ss`다. +- `purchaseOption`은 `BOTH`, `BUY_ONLY`, `RENT_ONLY`만 허용한다. +- `price`는 0 이상이며 1~4는 허용하지 않는다. +- `title`과 `detail`은 trim 후 빈 값일 수 없다. +- `limited`는 `null` 또는 1 이상이다. +- 테마 ID 12, 13, 14는 `price >= 5`여야 하고 `purchaseOption=BUY_ONLY`로 저장한다. +- 미리듣기 구간은 종료가 시작보다 늦고 길이가 15초 이상이어야 한다. +- `limited`가 설정되었거나 최종 `purchaseOption=BUY_ONLY`이면 `isOnlyRental=false`로 저장한다. 이 규칙이 우선하므로 `limited`와 `RENT_ONLY`를 함께 보내도 `false`다. +- 위 조건이 없고 `purchaseOption=RENT_ONLY`이면 `isOnlyRental=true`로 저장한다. `purchaseOption=BOTH`일 때만 Request의 `isOnlyRental` 값을 사용한다. +- `price < 50`이면 `isFullDetailVisible=true`로 저장하고, `price >= 50`일 때만 Request 값을 사용한다. +- 무료 콘텐츠는 `isGeneratePreview=false`로 저장하고 worker에도 같은 값을 전달한다. +- `previewStartTime`, `previewEndTime`은 생성 시 worker용 S3 object metadata로만 전달한다. DB 컬럼에 저장하거나 콘텐츠 목록·상세 Response로 반환하지 않는다. + +#### Response Data + +`AdminAiContentCreateResponse` + +| Field | Type | Nullable | Description | +|---|---|---:|---| +| `contentId` | `Long` | No | 생성된 콘텐츠 ID | +| `isActive` | `Boolean` | No | 생성 직후 `false` | +| `status` | `String` | No | 생성 직후 `PROCESSING` | + +```json +{ + "contentId": 2001, + "isActive": false, + "status": "PROCESSING" +} +``` + +원본 파일 저장 성공은 콘텐츠 공개 완료를 의미하지 않는다. 비동기 가공 완료 전까지 콘텐츠는 비활성 상태다. +응답의 `PROCESSING`은 저장된 status 값이 아니라 생성 직후의 `isActive=false`, `releaseDate!=null`, `duration=null`에서 계산한다. + +### 12.5 CONTENT-04 · Content Update + +#### Request + +```http +Content-Type: multipart/form-data +``` + +| Part | Type | Required | Description | +|---|---|---:|---| +| `coverImage` | File | No | 새 커버 이미지 | +| `request` | JSON string | Yes | `AdminAiContentUpdateRequest` | + +`AdminAiContentUpdateRequest` + +| Field | Type | Required | +|---|---|---:| +| `title` | `String` | Yes | +| `detail` | `String` | Yes | +| `tags` | `List` | Yes | +| `price` | `Int` | Yes | +| `isAdult` | `Boolean` | Yes | +| `isPointAvailable` | `Boolean` | Yes | +| `isCommentAvailable` | `Boolean` | Yes | + +`contentId`와 `isActive`는 body에서 받지 않는다. 활성 상태 변경은 `DELETE`로만 수행한다. + +수정 시에도 `title`·`detail`의 trim 후 빈 값 금지, 1~4 가격 금지 및 테마 12·13·14의 `price >= 5` 검증을 다시 적용한다. 기존 무료 콘텐츠는 `price=0`을 유지하거나 5 이상으로 변경할 수 있고, 기존 유료 콘텐츠의 가격은 5 이상만 허용해 무료 전환을 막는다. 변경 가격이 50 미만이면 `isFullDetailVisible=true`로 전환하고, 50 이상이면 기존 값을 유지한다. + +`purchaseOption`, `limited`, `releaseAtUtc`, `themeId`, `isGeneratePreview`, `isOnlyRental`, `isFullDetailVisible` 및 `languageCode`는 생성 후 직접 변경할 수 없다. `previewStartTime`, `previewEndTime`은 생성 시에만 전달하는 worker metadata이므로 조회하거나 수정하지 않는다. 이 값들의 변경이 필요하면 기존 콘텐츠를 논리 삭제하고 새 콘텐츠를 등록한다. + +#### Response Data + +`AdminMutationResponse` + +### 12.6 CONTENT-05 · Content Delete + +- Request: Path `characterId`, `contentId`; Body 없음 +- Response: `{ "id": 2001, "isActive": false }` +- 선택 캐릭터가 소유하지 않은 콘텐츠는 삭제할 수 없다. +- 계산 상태가 `PROCESSING`, `SCHEDULED`, `PUBLISHED`, `SUSPENDED`인 콘텐츠는 모두 `isActive=false`, `releaseDate=null`로 논리 삭제하며 이후 `DELETED`로 계산한다. +- 처리 중 삭제 후 도착한 upload callback은 콘텐츠를 다시 활성화하지 않는다. + +### 12.7 CONTENT-06 · Content Pin + +#### Request + +`AdminAiContentPinRequest` + +```json +{ + "isPinned": true +} +``` + +| Field | Type | Required | +|---|---|---:| +| `isPinned` | `Boolean` | Yes | + +#### Response Data + +`AdminAiContentPinResponse` + +```json +{ + "contentId": 2001, + "isPinned": true, + "replacedContentId": null +} +``` + +고정은 계산 상태가 `PUBLISHED`이고 공개 시각이 지난 콘텐츠에만 허용한다. 캐릭터별 최대 3개를 유지하며 네 번째 콘텐츠를 고정하면 가장 오래된 고정을 해제하고 그 ID를 `replacedContentId`로 반환한다. 고정 해제 응답의 `replacedContentId`는 `null`이다. + +### 12.8 CONTENT-07 · Content Theme Metadata + +#### Request + +- Body 없음 +- Query 없음 + +#### Response Data + +`AdminContentThemeResponse` + +| Field | Type | Nullable | +|---|---|---:| +| `themeId` | `Long` | No | +| `name` | `String` | No | +| `imageUrl` | `String` | Yes | + +legacy `/audio-content/theme` 또는 legacy service를 호출하지 않는다. v2 content query port가 같은 기준정보 테이블을 조회한다. + +### 12.9 Existing AWS Content Upload Completion Integration + +이번 범위에는 신규 upload-complete Endpoint를 만들지 않는다. 현재 AWS S3 Trigger 기반 가공 worker는 기존 계약을 그대로 사용한다. + +```http +PUT /audio-content/upload-complete +Authorization: Bearer +Content-Type: application/json +``` + +```json +{ + "contentId": 2001, + "contentPath": "output/2001/2001-content.m4a", + "duration": "00:10:30" +} +``` + +기존 성공 Response JSON은 다음과 같다. + +```json +{ + "success": true, + "message": null, + "data": {}, + "errorProperty": null +} +``` + +- 위 Method, Path, 인증, Request와 Response 계약은 기존 AWS 연동 계약이며 이번 범위에서 변경하지 않는다. +- 이 Endpoint는 기존 API이므로 신규 Operation ID를 부여하거나 12.1의 신규 Endpoint 수에 포함하지 않는다. +- v2 콘텐츠 생성도 기존과 같은 `content` 테이블 필드, S3 bucket의 `input/{contentId}/{contentId}-content-...` 경로와 object metadata 계약을 사용한다. object basename은 기존 `generateFileName(prefix = "${contentId}-content")` 규칙을 따르며 metadata는 기존 `generate_preview`, 선택 `preview_start_time`, `preview_end_time`만 전달한다. +- v2 생성 여부를 저장하는 DB 컬럼이나 worker metadata를 추가하지 않는다. 기존 callback Controller·Request·Response·Service 호출 흐름에도 V1/V2 dispatcher를 추가하지 않는다. +- callback은 기존과 같이 가공 완료 `contentPath`와 `duration`을 기록한다. 공개 활성화는 `releaseDate!=null && releaseDate<=now`이고 연결 creator Member가 활성인 경우에만 허용한다. +- `releaseDate=null`인 삭제 콘텐츠와 비활성 creator의 콘텐츠는 callback 이후에도 raw `content.isActive=false`를 유지한다. 과거 불일치 row가 raw `content.isActive=true`인 상태로 callback을 받으면 `false`로 보정한다. 두 경우 모두 구독자 공개 알림이나 home news를 발행하지 않는다. +- 기존 예약 공개 scheduler component의 cron·lock은 변경하지 않는다. 예약 공개 대상 query는 `isActive=false`, `releaseDate!=null`, `releaseDate<=now`, `duration!=null`과 활성 creator를 모두 만족하는 기존 테이블 row만 반환한다. +- `CONTENT-01`, `CONTENT-02`는 저장된 `contentPath`가 정확히 `output/{contentId}/`로 시작하는 canonical 상대 key인지 Signed URL 발급 직전에 검증한다. 각 후속 segment는 `[A-Za-z0-9][A-Za-z0-9._-]*`만 허용하며 빈 segment, `.`, `..`, URI scheme, host, 선행 `/` 또는 `\`, query, fragment, percent-encoding, `input/`, `raw/`, `preview/`와 다른 콘텐츠 ID를 거부한다. +- worker 코드·스케줄·AWS Trigger 설정 변경은 이번 범위가 아니다. +- 새 내부 callback API는 실제 AWS 전환 일정, 호출 주체와 배포 순서가 확정될 때 별도 PRD에서 Path, 인증 및 Request/Response를 정의한다. + +## 13. Content Comment API + +Base path는 `/admin/ai-characters/{characterId}/contents/{contentId}/comments`다. + +### 13.1 Endpoint Summary + +| Operation ID | Method | Endpoint | Request | Response Data | +|---|---|---|---|---| +| `CONTENT-COMMENT-01` | `GET` | `/admin/ai-characters/{characterId}/contents/{contentId}/comments` | Path: `characterId`, `contentId`; Query: `page`, `size`, `isActive` | `AdminPageResponse` | +| `CONTENT-COMMENT-02` | `GET` | `/admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId}/replies` | Path: `characterId`, `contentId`, `commentId`; Query: `page`, `size`, `isActive` | `AdminPageResponse` | +| `CONTENT-COMMENT-03` | `POST` | `/admin/ai-characters/{characterId}/contents/{contentId}/comments` | Path: `characterId`, `contentId`; JSON: `AdminContentCommentCreateRequest` | `AdminMutationResponse` | +| `CONTENT-COMMENT-04` | `PUT` | `/admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId}` | Path: `characterId`, `contentId`, `commentId`; JSON: `AdminCommentUpdateRequest` | `AdminMutationResponse` | +| `CONTENT-COMMENT-05` | `DELETE` | `/admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId}` | Path: `characterId`, `contentId`, `commentId`; Body 없음 | `AdminMutationResponse` | + +### 13.2 CONTENT-COMMENT-01/02 · List Request + +| Field | In | Type | Required | Description | +|---|---|---|---:|---| +| `page` | Query | `Int` | No | 기본 0 | +| `size` | Query | `Int` | No | 기본 20, 최대 50 | +| `isActive` | Query | `Boolean` | No | 기본 `true`; `false`이면 논리 삭제 댓글 조회 | + +- `CONTENT-COMMENT-01`은 Path `characterId`, `contentId`와 위 Query를 받는다. +- `CONTENT-COMMENT-02`는 Path `characterId`, `contentId`, 루트 `commentId`와 위 Query를 받는다. +- 루트 목록 Endpoint는 `parentCommentId=null`인 댓글만 반환하고 `createdAtUtc DESC, commentId DESC`로 정렬한다. +- 답글 목록 Endpoint는 지정한 루트의 직접 자식만 반환하고 `createdAtUtc ASC, commentId ASC`로 정렬한다. +- `replyCount`는 현재 필터와 관계없이 활성 직접 답글 수다. + +### 13.3 CONTENT-COMMENT-01/02 · Comment Response + +`AdminContentCommentResponse` + +| Field | Type | Nullable | +|---|---|---:| +| `commentId` | `Long` | No | +| `parentCommentId` | `Long` | Yes | +| `writerId` | `Long` | No | +| `writerNickname` | `String` | No | +| `writerProfileImageUrl` | `String` | Yes | +| `content` | `String` | No | +| `languageCode` | `String` | Yes | +| `donationCan` | `Int` | No | +| `isSecret` | `Boolean` | No | +| `isActive` | `Boolean` | No | +| `replyCount` | `Int` | No | +| `createdAtUtc` | `String` | No | +| `updatedAtUtc` | `String` | Yes | + +### 13.4 CONTENT-COMMENT-03 · Comment Create + +#### Request + +`AdminContentCommentCreateRequest` + +```json +{ + "content": "답변 내용", + "parentCommentId": null, + "isSecret": false, + "languageCode": "ko" +} +``` + +| Field | Type | Required | Default | +|---|---|---:|---| +| `content` | `String` | Yes | - | +| `parentCommentId` | `Long` | No | `null` | +| `isSecret` | `Boolean` | No | `false` | +| `languageCode` | `String` | No | `null` | + +작성자는 Request에서 받지 않고 선택 AI 캐릭터의 `creatorMember`로 고정한다. 답글이면 부모 댓글이 같은 `contentId`에 속하고 활성 상태이며 `parentCommentId=null`인 최상위 댓글인지 검증한다. 답글의 답글은 허용하지 않는다. + +#### Response Data + +`AdminMutationResponse` + +### 13.5 CONTENT-COMMENT-04 · Comment Update + +#### Request + +`AdminCommentUpdateRequest` + +```json +{ + "content": "수정한 답변" +} +``` + +| Field | Type | Required | +|---|---|---:| +| `content` | `String` | Yes | + +AI 캐릭터의 `creatorMember`가 직접 작성한 댓글만 본문을 수정할 수 있다. + +`parentCommentId`, `isSecret`, `languageCode`는 등록 후 변경할 수 없다. + +#### Response Data + +`AdminMutationResponse` + +### 13.6 CONTENT-COMMENT-05 · Comment Delete + +- Request: Path `characterId`, `contentId`, `commentId`; Body 없음 +- Response Data: `{ "id": 2101, "isActive": false }` +- AI 캐릭터가 작성한 댓글은 작성자 권한으로 논리 삭제할 수 있다. +- 선택 AI 캐릭터 소유 콘텐츠에 달린 댓글은 다른 사용자가 작성했어도 콘텐츠 소유자 권한으로 논리 삭제할 수 있다. +- 다른 크리에이터의 콘텐츠에 달린 댓글은 AI 캐릭터가 작성했더라도 이 관리자 경로에서 삭제할 수 없다. +- 삭제는 `isActive=false`이며 답글을 물리 삭제하지 않는다. + +## 14. Series API + +Base path는 `/admin/ai-characters/{characterId}/series`다. + +### 14.1 Endpoint Summary + +| Operation ID | Method | Endpoint | Request | Response Data | +|---|---|---|---|---| +| `SERIES-01` | `GET` | `/admin/ai-characters/{characterId}/series` | Path: `characterId`; Query: `page`, `size`, `search`, `isActive`, `state` | `AdminPageResponse` | +| `SERIES-02` | `GET` | `/admin/ai-characters/{characterId}/series/{seriesId}` | Path: `characterId`, `seriesId` | `AdminSeriesDetailResponse` | +| `SERIES-03` | `POST` | `/admin/ai-characters/{characterId}/series` | Path: `characterId`; multipart: `image`, `request=AdminSeriesCreateRequest` | `AdminMutationResponse` | +| `SERIES-04` | `PUT` | `/admin/ai-characters/{characterId}/series/{seriesId}` | Path: `characterId`, `seriesId`; multipart: `image?`, `request=AdminSeriesUpdateRequest` | `AdminMutationResponse` | +| `SERIES-05` | `DELETE` | `/admin/ai-characters/{characterId}/series/{seriesId}` | Path: `characterId`, `seriesId`; Body 없음 | `AdminMutationResponse` | +| `SERIES-06` | `GET` | `/admin/ai-characters/{characterId}/series/{seriesId}/contents` | Path: `characterId`, `seriesId`; Query: `page`, `size` | `AdminPageResponse` | +| `SERIES-07` | `GET` | `/admin/ai-characters/{characterId}/series/{seriesId}/available-contents` | Path: `characterId`, `seriesId`; Query: `page`, `size`, `search` | `AdminPageResponse` | +| `SERIES-08` | `POST` | `/admin/ai-characters/{characterId}/series/{seriesId}/contents` | Path: `characterId`, `seriesId`; JSON: `AdminSeriesContentsAddRequest` | `AdminSeriesContentsMutationResponse` | +| `SERIES-09` | `DELETE` | `/admin/ai-characters/{characterId}/series/{seriesId}/contents/{contentId}` | Path: `characterId`, `seriesId`, `contentId`; Body 없음 | `AdminSeriesContentsMutationResponse` | +| `SERIES-10` | `PUT` | `/admin/ai-characters/{characterId}/series/orders` | Path: `characterId`; JSON: `AdminSeriesOrderRequest` | `AdminSeriesOrderResponse` | +| `SERIES-11` | `GET` | `/admin/ai-characters/metadata/series-genres` | 없음 | `List` | + +### 14.2 SERIES-01 · Series List + +목록 Request는 다음 Query를 받는다. + +| Field | Type | Required | Description | +|---|---|---:|---| +| `page` | `Int` | No | 기본 0 | +| `size` | `Int` | No | 기본 20, 최대 50 | +| `search` | `String` | No | 제목·소개 검색 | +| `isActive` | `Boolean` | No | null이면 전체 | +| `state` | `String` | No | `PROCEEDING`, `SUSPEND`, `COMPLETE` | + +`AdminSeriesSummaryResponse` + +| Field | Type | Nullable | +|---|---|---:| +| `seriesId` | `Long` | No | +| `title` | `String` | No | +| `introduction` | `String` | No | +| `coverImageUrl` | `String` | Yes | +| `publishedDaysOfWeek` | `List` | No | +| `genreId` | `Long` | No | +| `isAdult` | `Boolean` | No | +| `state` | `String` | No | +| `isActive` | `Boolean` | No | +| `writer` | `String` | Yes | +| `studio` | `String` | Yes | + +기본 정렬은 `order ASC, seriesId ASC`다. + +### 14.3 SERIES-02 · Series Detail + +#### Request + +- Path `characterId: Long` +- Path `seriesId: Long` +- Query와 Body 없음 + +#### Response Data + +`AdminSeriesDetailResponse`는 목록 필드에 다음을 추가한다. + +| Field | Type | Nullable | +|---|---|---:| +| `genre` | `String` | No | +| `keyword` | `String` | No | +| `createdAtUtc` | `String` | Yes | +| `updatedAtUtc` | `String` | Yes | + +### 14.4 SERIES-03 · Series Create + +#### Request + +```http +Content-Type: multipart/form-data +``` + +| Part | Type | Required | +|---|---|---:| +| `image` | File | Yes | +| `request` | JSON string | Yes | + +`AdminSeriesCreateRequest` + +| Field | Type | Required | Default | +|---|---|---:|---| +| `title` | `String` | Yes | - | +| `introduction` | `String` | Yes | - | +| `publishedDaysOfWeek` | `Set` | Yes | - | +| `keyword` | `String` | Yes | - | +| `genreId` | `Long` | Yes | - | +| `isAdult` | `Boolean` | No | `false` | +| `writer` | `String` | No | `null` | +| `studio` | `String` | No | `null` | + +`title`, `introduction`, `keyword`는 trim 후 빈 값일 수 없고 `genreId`는 활성 장르를 가리켜야 한다. 요일 값은 `SUN`, `MON`, `TUE`, `WED`, `THU`, `FRI`, `SAT`, `RANDOM`만 허용하며 빈 집합은 허용하지 않는다. `RANDOM`은 다른 요일 값과 함께 사용할 수 없다. + +#### Response Data + +`AdminMutationResponse` + +```json +{ + "id": 3001, + "isActive": true +} +``` + +### 14.5 SERIES-04 · Series Update + +#### Request + +```http +Content-Type: multipart/form-data +``` + +| Part | Type | Required | +|---|---|---:| +| `image` | File | No | +| `request` | JSON string | Yes | + +`AdminSeriesUpdateRequest` + +| Field | Type | Required | +|---|---|---:| +| `title` | `String` | Yes | +| `introduction` | `String` | Yes | +| `keyword` | `String` | Yes | +| `publishedDaysOfWeek` | `Set` | Yes | +| `genreId` | `Long` | Yes | +| `isAdult` | `Boolean` | Yes | +| `state` | `String` | Yes | +| `writer` | `String?` | Yes | +| `studio` | `String?` | Yes | + +`seriesId`와 `isActive`는 body에서 받지 않는다. + +등록과 같은 제목·소개·키워드·장르·요일 검증을 적용한다. `state`는 `PROCEEDING`, `SUSPEND`, `COMPLETE`만 허용한다. + +#### Response Data + +`AdminMutationResponse` + +### 14.6 SERIES-05 · Series Delete + +- Request: Path `characterId`, `seriesId`; Body 없음 +- Response: `{ "id": 3001, "isActive": false }` +- 시리즈를 삭제해도 포함 콘텐츠는 삭제하지 않는다. + +### 14.7 SERIES-06~09 · Series Contents + +`AdminSeriesContentResponse` + +| Field | Type | Nullable | +|---|---|---:| +| `contentId` | `Long` | No | +| `title` | `String` | No | +| `coverImageUrl` | `String` | Yes | +| `isActive` | `Boolean` | No | +| `order` | `Int` | Yes | + +#### SERIES-06 · Included Content List + +Request: + +| Field | In | Type | Required | +|---|---|---|---:| +| `page` | Query | `Int` | No | +| `size` | Query | `Int` | No | + +- Path는 `characterId`, `seriesId`를 받는다. +- Response Data는 `AdminPageResponse`다. +- 기본 정렬은 `order ASC, contentId ASC`다. + +#### SERIES-07 · Available Content List + +Request: + +| Field | In | Type | Required | +|---|---|---|---:| +| `page` | Query | `Int` | No | +| `size` | Query | `Int` | No | +| `search` | Query | `String` | No | + +- Path는 `characterId`, `seriesId`를 받는다. +- Response Data는 `AdminPageResponse`다. +- 미포함 콘텐츠 검색은 `createdAtUtc DESC, contentId DESC`로 정렬한다. + +#### SERIES-08 · Add Contents + +Request `AdminSeriesContentsAddRequest`: + +```json +{ + "contentIds": [2001, 2002] +} +``` + +| Field | Type | Required | +|---|---|---:| +| `contentIds` | `List` | Yes | + +Response Data `AdminSeriesContentsMutationResponse`: + +```json +{ + "seriesId": 3001, + "affectedContentIds": [2001, 2002] +} +``` + +Response의 `affectedContentIds`는 이번 요청으로 추가된 ID다. 선택 캐릭터가 소유한 활성 콘텐츠만 추가할 수 있다. 중복 ID 또는 소유권·활성 조건을 충족하지 않는 ID가 하나라도 있으면 전체 요청을 rollback한다. + +이미 연결된 콘텐츠의 중복 추가는 409다. + +#### SERIES-09 · Remove Content + +- Request: Path `characterId`, `seriesId`, `contentId`; Body 없음 +- Response Data: `{ "seriesId": 3001, "affectedContentIds": [2001] }` +- 제거는 `series_content` join row만 물리 삭제하고 시리즈와 콘텐츠는 그대로 유지한다. +- 같은 제거 요청을 반복하면 `affectedContentIds=[]`인 200을 반환한다. +- 제거한 콘텐츠를 다시 추가하면 새 join row를 생성해 현재 마지막 순번 뒤에 배치한다. + +### 14.8 SERIES-10 · Series Order + +#### Request + +`AdminSeriesOrderRequest` + +```json +{ + "seriesIds": [3003, 3001, 3002] +} +``` + +#### Response Data + +`AdminSeriesOrderResponse` + +```json +{ + "seriesIds": [3003, 3001, 3002] +} +``` + +`seriesIds`는 선택 캐릭터가 소유한 활성 시리즈 전체를 중복·누락 없이 정확히 한 번씩 포함해야 한다. 조건을 충족하지 않으면 400을 반환하고 순서를 변경하지 않는다. 기존 `updateSeriesOrders(ids)`는 소유자 범위가 없으므로 호출하지 않는다. + +### 14.9 SERIES-11 · Series Genre Metadata + +#### Request + +- Body 없음 +- Query 없음 + +#### Response Data + +`AdminSeriesGenreResponse` + +| Field | Type | Nullable | +|---|---|---:| +| `genreId` | `Long` | No | +| `name` | `String` | No | + +legacy `/creator-admin/audio-content/series/genre` 또는 legacy service를 호출하지 않는다. v2 series query port가 같은 기준정보 테이블을 조회한다. + +## 15. Community Post API + +Base path는 `/admin/ai-characters/{characterId}/community-posts`다. + +### 15.1 Endpoint Summary + +| Operation ID | Method | Endpoint | Request | Response Data | +|---|---|---|---|---| +| `COMMUNITY-POST-01` | `GET` | `/admin/ai-characters/{characterId}/community-posts` | Path: `characterId`; Query: `page`, `size`, `isActive` | `AdminPageResponse` | +| `COMMUNITY-POST-02` | `GET` | `/admin/ai-characters/{characterId}/community-posts/{postId}` | Path: `characterId`, `postId` | `AdminCommunityPostResponse` | +| `COMMUNITY-POST-03` | `POST` | `/admin/ai-characters/{characterId}/community-posts` | Path: `characterId`; multipart: `audioFile?`, `postImage?`, `request=AdminCommunityPostCreateRequest` | `AdminMutationResponse` | +| `COMMUNITY-POST-04` | `PUT` | `/admin/ai-characters/{characterId}/community-posts/{postId}` | Path: `characterId`, `postId`; multipart: `postImage?`, `request=AdminCommunityPostUpdateRequest` | `AdminMutationResponse` | +| `COMMUNITY-POST-05` | `DELETE` | `/admin/ai-characters/{characterId}/community-posts/{postId}` | Path: `characterId`, `postId`; Body 없음 | `AdminMutationResponse` | +| `COMMUNITY-POST-06` | `PUT` | `/admin/ai-characters/{characterId}/community-posts/{postId}/fixed` | Path: `characterId`, `postId`; JSON: `AdminCommunityPostFixedRequest` | `AdminCommunityPostFixedResponse` | + +### 15.2 COMMUNITY-POST-01 · List Request + +| Field | In | Type | Required | Description | +|---|---|---|---:|---| +| `page` | Query | `Int` | No | 기본 0 | +| `size` | Query | `Int` | No | 기본 20, 최대 50 | +| `isActive` | Query | `Boolean` | No | null이면 전체 | + +기본 정렬은 고정 게시글 우선, 그다음 `createdAtUtc DESC, postId DESC`다. + +### 15.3 COMMUNITY-POST-01/02 · Community Post Response + +`COMMUNITY-POST-01`은 15.2의 Query를 받고 `AdminPageResponse`를 반환한다. +`COMMUNITY-POST-02`는 Path `characterId`, `postId`만 받고 `AdminCommunityPostResponse`를 반환한다. + +`AdminCommunityPostResponse` + +| Field | Type | Nullable | +|---|---|---:| +| `postId` | `Long` | No | +| `creatorId` | `Long` | No | +| `creatorNickname` | `String` | No | +| `creatorProfileImageUrl` | `String` | Yes | +| `imageUrl` | `String` | Yes | +| `audioUrl` | `String` | Yes | +| `content` | `String` | No | +| `price` | `Int` | No | +| `isCommentAvailable` | `Boolean` | No | +| `isAdult` | `Boolean` | No | +| `isFixed` | `Boolean` | No | +| `isActive` | `Boolean` | No | +| `likeCount` | `Int` | No | +| `commentCount` | `Int` | No | +| `createdAtUtc` | `String` | No | +| `updatedAtUtc` | `String` | Yes | + +관리자 응답은 유료 게시글의 본문을 축약하지 않고 전체 내용을 반환한다. + +### 15.4 COMMUNITY-POST-03 · Community Post Create + +#### Request + +```http +Content-Type: multipart/form-data +``` + +| Part | Type | Required | +|---|---|---:| +| `audioFile` | File | No | +| `postImage` | File | No | +| `request` | JSON string | Yes | + +`AdminCommunityPostCreateRequest` + +| Field | Type | Required | Default | +|---|---|---:|---| +| `content` | `String` | Yes | - | +| `isCommentAvailable` | `Boolean` | Yes | - | +| `isAdult` | `Boolean` | Yes | - | +| `price` | `Int` | No | `0` | + +- `content`는 trim 후 빈 값일 수 없고 `price`는 0 이상이다. +- `price > 0`인 유료 게시글은 `postImage`가 필수다. +- `audioFile`을 보내는 게시글은 가격과 관계없이 `postImage`가 필수다. +- 업로드 이미지의 실제 MIME type은 `image/*`여야 하며 GIF는 유료 게시글에서만 허용한다. +- `audioFile`은 빈 파일일 수 없다. v2 community web adapter가 파일명 확장자가 아니라 실제 bytes를 검사해 M4A/AAC 계열 MIME type인 `audio/mp4`, `audio/x-m4a`, `audio/aac`만 허용하고, 다른 codec이나 MIME type은 400으로 거부한다. + +#### Response Data + +`AdminMutationResponse` + +```json +{ + "id": 4001, + "isActive": true +} +``` + +### 15.5 COMMUNITY-POST-04 · Community Post Update + +#### Request + +| Part | Type | Required | +|---|---|---:| +| `postImage` | File | No | +| `request` | JSON string | Yes | + +`AdminCommunityPostUpdateRequest` + +| Field | Type | Required | +|---|---|---:| +| `content` | `String` | Yes | +| `isCommentAvailable` | `Boolean` | Yes | +| `isAdult` | `Boolean` | Yes | + +`postId`와 `isActive`는 body에서 받지 않는다. + +`price`와 `audioFile`은 등록 후 변경하거나 제거할 수 없다. 선택 `postImage`를 보내면 이미지를 교체하고, 생략하면 기존 이미지를 유지한다. 이미지 제거는 지원하지 않는다. +수정 시에도 `content`는 trim 후 빈 값일 수 없다. 교체 이미지는 실제 MIME type이 `image/*`여야 하며, 기존 게시글의 `price=0`이면 GIF를 허용하지 않는다. 기존 유료 또는 오디오 게시글은 이미지가 없는 상태로 변경할 수 없다. + +#### Response Data + +`AdminMutationResponse` + +### 15.6 COMMUNITY-POST-05 · Community Post Delete + +- Request: Path `characterId`, `postId`; Body 없음 +- Response: `{ "id": 4001, "isActive": false }` +- 게시글 삭제 시 댓글을 물리 삭제하지 않는다. + +### 15.7 COMMUNITY-POST-06 · Community Post Fixed + +#### Request + +`AdminCommunityPostFixedRequest` + +```json +{ + "isFixed": true +} +``` + +| Field | Type | Required | +|---|---|---:| +| `isFixed` | `Boolean` | Yes | + +#### Response Data + +`AdminCommunityPostFixedResponse` + +```json +{ + "postId": 4001, + "isFixed": true +} +``` + +활성 게시글만 고정할 수 있고 캐릭터별 최대 3개까지 허용한다. 이미 3개가 고정된 상태에서 다른 게시글을 고정하면 자동 교체하지 않고 409를 반환한다. + +## 16. Community Post Comment API + +Base path는 `/admin/ai-characters/{characterId}/community-posts/{postId}/comments`다. + +### 16.1 Endpoint Summary + +| Operation ID | Method | Endpoint | Request | Response Data | +|---|---|---|---|---| +| `COMMUNITY-COMMENT-01` | `GET` | `/admin/ai-characters/{characterId}/community-posts/{postId}/comments` | Path: `characterId`, `postId`; Query: `page`, `size`, `isActive` | `AdminPageResponse` | +| `COMMUNITY-COMMENT-02` | `GET` | `/admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}/replies` | Path: `characterId`, `postId`, `commentId`; Query: `page`, `size`, `isActive` | `AdminPageResponse` | +| `COMMUNITY-COMMENT-03` | `POST` | `/admin/ai-characters/{characterId}/community-posts/{postId}/comments` | Path: `characterId`, `postId`; JSON: `AdminCommunityCommentCreateRequest` | `AdminMutationResponse` | +| `COMMUNITY-COMMENT-04` | `PUT` | `/admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}` | Path: `characterId`, `postId`, `commentId`; JSON: `AdminCommentUpdateRequest` | `AdminMutationResponse` | +| `COMMUNITY-COMMENT-05` | `DELETE` | `/admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}` | Path: `characterId`, `postId`, `commentId`; Body 없음 | `AdminMutationResponse` | + +### 16.2 COMMUNITY-COMMENT-01/02 · List Request and Response + +- Query는 `page`, `size`, 선택 `isActive`를 받으며 공통 페이징 규칙을 적용한다. `isActive` 기본값은 `true`이고 `false`이면 논리 삭제 댓글을 조회한다. +- Response item은 `AdminCommunityCommentResponse`를 사용한다. +- `COMMUNITY-COMMENT-01`은 Path `characterId`, `postId`를 받는다. +- `COMMUNITY-COMMENT-02`는 Path `characterId`, `postId`, 루트 `commentId`를 받는다. + +루트 목록 Endpoint는 `parentCommentId=null`인 댓글만 `createdAtUtc DESC, commentId DESC`로 반환한다. 답글 목록 Endpoint는 지정한 루트의 직접 자식만 `createdAtUtc ASC, commentId ASC`로 반환한다. `replyCount`는 현재 필터와 관계없이 활성 직접 답글 수다. + +| Field | Type | Nullable | +|---|---|---:| +| `commentId` | `Long` | No | +| `parentCommentId` | `Long` | Yes | +| `writerId` | `Long` | No | +| `writerNickname` | `String` | No | +| `writerProfileImageUrl` | `String` | Yes | +| `content` | `String` | No | +| `isSecret` | `Boolean` | No | +| `isActive` | `Boolean` | No | +| `replyCount` | `Int` | No | +| `createdAtUtc` | `String` | No | +| `updatedAtUtc` | `String` | Yes | + +### 16.3 COMMUNITY-COMMENT-03 · Comment Create + +`AdminCommunityCommentCreateRequest` + +```json +{ + "content": "댓글 내용", + "parentCommentId": null, + "isSecret": false +} +``` + +| Field | Type | Required | Default | +|---|---|---:|---| +| `content` | `String` | Yes | - | +| `parentCommentId` | `Long` | No | `null` | +| `isSecret` | `Boolean` | No | `false` | + +작성자는 선택 AI 캐릭터의 `creatorMember`로 고정한다. 부모 댓글은 같은 `postId`에 속하고 활성 상태이며 `parentCommentId=null`인 최상위 댓글이어야 한다. 답글의 답글은 허용하지 않는다. + +Response Data는 `AdminMutationResponse`다. + +### 16.4 COMMUNITY-COMMENT-04 · Comment Update + +`AdminCommentUpdateRequest` + +```json +{ + "content": "수정한 댓글" +} +``` + +| Field | Type | Required | +|---|---|---:| +| `content` | `String` | Yes | + +AI 캐릭터의 `creatorMember`가 작성한 댓글만 본문을 수정할 수 있다. + +`parentCommentId`와 `isSecret`은 등록 후 변경할 수 없다. + +Response Data는 `AdminMutationResponse`다. + +### 16.5 COMMUNITY-COMMENT-05 · Comment Delete + +- Request: Path `characterId`, `postId`, `commentId`; Body 없음 +- Response Data: `{ "id": 4101, "isActive": false }` +- AI 캐릭터가 작성한 댓글은 작성자 권한으로 논리 삭제할 수 있다. +- 선택 AI 캐릭터 소유 게시글에 달린 댓글은 다른 사용자가 작성했어도 게시글 소유자 권한으로 논리 삭제할 수 있다. +- 다른 크리에이터의 게시글에 달린 댓글은 이 관리자 경로에서 삭제할 수 없다. + +## 17. FanTalk API + +Base path는 `/admin/ai-characters/{characterId}/fan-talks`다. + +### 17.1 Endpoint Summary + +| Operation ID | Method | Endpoint | Request | Response Data | +|---|---|---|---|---| +| `FAN-TALK-01` | `GET` | `/admin/ai-characters/{characterId}/fan-talks` | Path: `characterId`; Query: `page`, `size` | `AdminFanTalkPageResponse` | +| `FAN-TALK-02` | `POST` | `/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies` | Path: `characterId`, `fanTalkId`; JSON: `AdminFanTalkReplyCreateRequest` | `AdminFanTalkReplyResponse` | +| `FAN-TALK-03` | `PUT` | `/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}` | Path: `characterId`, `fanTalkId`, `replyId`; JSON: `AdminFanTalkReplyUpdateRequest` | `AdminFanTalkReplyResponse` | +| `FAN-TALK-04` | `DELETE` | `/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}` | Path: `characterId`, `fanTalkId`, `replyId`; Body 없음 | `AdminMutationResponse` | +| `FAN-TALK-05` | `DELETE` | `/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}` | Path: `characterId`, `fanTalkId`; Body 없음 | `AdminMutationResponse` | + +답글 전용 `GET` Endpoint는 만들지 않는다. FanTalk 목록 조회 시 각 루트 항목의 `creatorReplies`에 활성 AI 캐릭터 답글을 함께 반환한다. + +### 17.2 FAN-TALK-01 · FanTalk List + +#### Request + +| Field | In | Type | Required | Description | +|---|---|---|---:|---| +| `page` | Query | `Int` | No | 기본 0 | +| `size` | Query | `Int` | No | 기본 20, 최대 50 | + +활성 최상위 FanTalk만 `createdAtUtc DESC, fanTalkId DESC`로 반환한다. 중첩 AI 캐릭터 답글은 활성 직접 답글만 `createdAtUtc ASC, replyId ASC`로 정렬한다. + +#### Response Data + +기존 v2 FanTalk domain/query port는 활용할 수 있지만 공개 채널 API DTO를 직접 반환하지 않는다. 관리자 API 전용 `AdminFanTalkPageResponse`로 변환한다. + +| Field | Type | Nullable | +|---|---|---:| +| `items` | `List` | No | +| `page` | `Int` | No | +| `size` | `Int` | No | +| `totalCount` | `Long` | No | +| `hasNext` | `Boolean` | No | + +`AdminFanTalkResponse` + +| Field | Type | Nullable | +|---|---|---:| +| `fanTalkId` | `Long` | No | +| `writerId` | `Long` | No | +| `writerNickname` | `String` | No | +| `writerProfileImageUrl` | `String` | No | +| `content` | `String` | No | +| `createdAtUtc` | `String` | No | +| `creatorReplies` | `List` | No | + +중첩 `AdminFanTalkReplyResponse`는 다음 필드를 반환한다. + +| Field | Type | Nullable | +|---|---|---:| +| `replyId` | `Long` | No | +| `fanTalkId` | `Long` | No | +| `writerId` | `Long` | No | +| `writerNickname` | `String` | No | +| `writerProfileImageUrl` | `String` | No | +| `content` | `String` | No | +| `isActive` | `Boolean` | No | +| `createdAtUtc` | `String` | No | +| `updatedAtUtc` | `String` | Yes | + +프로필 이미지가 없는 작성자와 AI 캐릭터에는 기존 CDN 기본 프로필 이미지 URL을 적용하므로 `writerProfileImageUrl`은 null이 아니다. + +### 17.3 FAN-TALK-02 · FanTalk Reply Create + +#### Request + +`AdminFanTalkReplyCreateRequest` + +```json +{ + "content": "AI 캐릭터 답글", + "languageCode": "ko" +} +``` + +| Field | Type | Required | Default | +|---|---|---:|---| +| `content` | `String` | Yes | - | +| `languageCode` | `String` | No | `null` | + +루트 `fanTalkId`가 선택 AI 캐릭터를 대상으로 한 활성 FanTalk인지 검증한다. 작성자는 선택 AI 캐릭터의 `creatorMember`로 고정한다. + +#### Response Data + +`AdminFanTalkReplyResponse` + +| Field | Type | Nullable | +|---|---|---:| +| `replyId` | `Long` | No | +| `fanTalkId` | `Long` | No | +| `writerId` | `Long` | No | +| `writerNickname` | `String` | No | +| `writerProfileImageUrl` | `String` | No | +| `content` | `String` | No | +| `isActive` | `Boolean` | No | +| `createdAtUtc` | `String` | No | +| `updatedAtUtc` | `String` | Yes | + +### 17.4 FAN-TALK-03 · FanTalk Reply Update + +#### Request + +`AdminFanTalkReplyUpdateRequest` + +```json +{ + "content": "수정한 AI 캐릭터 답글" +} +``` + +| Field | Type | Required | +|---|---|---:| +| `content` | `String` | Yes | + +선택 AI 캐릭터의 `creatorMember`가 작성했고 해당 루트 FanTalk의 답글인 경우에만 수정한다. + +`fanTalkId`와 `languageCode`는 등록 후 변경할 수 없다. + +#### Response Data + +`AdminFanTalkReplyResponse` + +### 17.5 FAN-TALK-04 · FanTalk Reply Delete + +- Request: Path `characterId`, `fanTalkId`, `replyId`; Body 없음 +- Response: `{ "id": 5002, "isActive": false }` +- AI 캐릭터가 작성한 답글만 논리 삭제할 수 있다. +- 팬이 작성한 루트 FanTalk는 답글 삭제 Endpoint에서 함께 삭제하지 않는다. + +### 17.6 FAN-TALK-05 · FanTalk Root Moderation + +선택 AI 캐릭터를 대상으로 작성된 루트 FanTalk는 크리에이터 소유자 권한으로 논리 삭제할 수 있다. + +- Request: Path `characterId`, `fanTalkId`; Body 없음 +- Response: `{ "id": 5001, "isActive": false }` +- 팬이 작성한 본문을 수정할 수는 없다. +- 루트 FanTalk 삭제는 하위 AI 답글을 물리 삭제하지 않지만 공개 조회에서 루트와 답글을 함께 제외한다. + +## 18. Content Category and Creator Channel Settings API + +### 18.1 Content Category Endpoint Summary + +Base path는 `/admin/ai-characters/{characterId}/content-categories`다. + +| Operation ID | Method | Endpoint | Request | Response Data | +|---|---|---|---|---| +| `CATEGORY-01` | `GET` | `/admin/ai-characters/{characterId}/content-categories` | Path: `characterId`; Query: `page`, `size`, `isActive` | `AdminPageResponse` | +| `CATEGORY-02` | `POST` | `/admin/ai-characters/{characterId}/content-categories` | Path: `characterId`; JSON: `AdminContentCategoryCreateRequest` | `AdminContentCategoryMutationResponse` | +| `CATEGORY-03` | `PUT` | `/admin/ai-characters/{characterId}/content-categories/{categoryId}` | Path: `characterId`, `categoryId`; JSON: `AdminContentCategoryUpdateRequest` | `AdminContentCategoryMutationResponse` | +| `CATEGORY-04` | `DELETE` | `/admin/ai-characters/{characterId}/content-categories/{categoryId}` | Path: `characterId`, `categoryId`; Body 없음 | `AdminContentCategoryMutationResponse` | +| `CATEGORY-05` | `PUT` | `/admin/ai-characters/{characterId}/content-categories/orders` | Path: `characterId`; JSON: `AdminContentCategoryOrderRequest` | `AdminContentCategoryOrderResponse` | +| `CATEGORY-06` | `GET` | `/admin/ai-characters/{characterId}/content-categories/{categoryId}/contents` | Path: `characterId`, `categoryId`; Query: `page`, `size` | `AdminPageResponse` | +| `CATEGORY-07` | `GET` | `/admin/ai-characters/{characterId}/content-categories/{categoryId}/available-contents` | Path: `characterId`, `categoryId`; Query: `page`, `size`, `search` | `AdminPageResponse` | +| `CATEGORY-08` | `POST` | `/admin/ai-characters/{characterId}/content-categories/{categoryId}/contents` | Path: `characterId`, `categoryId`; JSON: `AdminCategoryContentsAddRequest` | `AdminCategoryContentsMutationResponse` | +| `CATEGORY-09` | `DELETE` | `/admin/ai-characters/{characterId}/content-categories/{categoryId}/contents/{contentId}` | Path: `characterId`, `categoryId`, `contentId`; Body 없음 | `AdminCategoryContentsMutationResponse` | + +### 18.2 CATEGORY-01 · Content Category List + +목록 Request는 공통 `page`, `size`와 선택 `isActive`를 받는다. `isActive=null`이면 활성·비활성 카테고리를 모두 반환한다. + +`AdminContentCategoryResponse` + +| Field | Type | Nullable | +|---|---|---:| +| `categoryId` | `Long` | No | +| `title` | `String` | No | +| `order` | `Int` | No | +| `contentCount` | `Int` | No | +| `isActive` | `Boolean` | No | + +기본 정렬은 `order ASC, categoryId ASC`다. + +### 18.3 CATEGORY-02 · Content Category Create + +#### Request + +`AdminContentCategoryCreateRequest` + +```json +{ + "title": "ASMR", + "contentIds": [2001, 2002] +} +``` + +| Field | Type | Required | Default | +|---|---|---:|---| +| `title` | `String` | Yes | - | +| `contentIds` | `List` | No | `[]` | + +#### Response Data + +`AdminContentCategoryMutationResponse` + +| Field | Type | Nullable | +|---|---|---:| +| `categoryId` | `Long` | No | +| `isActive` | `Boolean` | No | + +```json +{ + "categoryId": 6001, + "isActive": true +} +``` + +모든 `contentIds`는 중복이 없어야 하며 선택 AI 캐릭터가 소유한 활성 콘텐츠여야 한다. 하나라도 조건을 충족하지 않으면 카테고리를 생성하지 않는다. +`title`은 trim 후 2자 이상이어야 하며 같은 캐릭터의 활성 카테고리 제목과 중복될 수 없다. + +### 18.4 CATEGORY-03 · Content Category Update + +#### Request + +`AdminContentCategoryUpdateRequest` + +```json +{ + "title": "수면 ASMR" +} +``` + +| Field | Type | Required | Default | +|---|---|---:|---| +| `title` | `String` | Yes | - | + +등록과 동일하게 trim 후 2자 이상 및 같은 캐릭터의 활성 카테고리 제목 중복 금지 규칙을 적용한다. + +#### Response Data + +`AdminContentCategoryMutationResponse` + +### 18.5 CATEGORY-04 · Content Category Delete + +- Request: Path `characterId`, `categoryId`; Body 없음 +- Response: `{ "categoryId": 6001, "isActive": false }` +- 카테고리와 콘텐츠 연결만 비활성화하고 콘텐츠는 삭제하지 않는다. + +### 18.6 CATEGORY-05 · Content Category Order + +#### Request + +`AdminContentCategoryOrderRequest` + +```json +{ + "categoryIds": [6003, 6001, 6002] +} +``` + +| Field | Type | Required | +|---|---|---:| +| `categoryIds` | `List` | Yes | + +#### Response Data + +`AdminContentCategoryOrderResponse` + +```json +{ + "categoryIds": [6003, 6001, 6002] +} +``` + +`categoryIds`는 선택 캐릭터가 소유한 활성 카테고리 전체를 중복·누락 없이 정확히 한 번씩 포함해야 한다. 조건을 충족하지 않으면 400을 반환하고 순서를 변경하지 않는다. + +### 18.7 CATEGORY-06~09 · Category Contents + +`AdminCategoryContentResponse` + +| Field | Type | Nullable | +|---|---|---:| +| `contentId` | `Long` | No | +| `title` | `String` | No | +| `coverImageUrl` | `String` | Yes | +| `isActive` | `Boolean` | No | + +#### CATEGORY-06 · Included Content List + +- Request: Path `characterId`, `categoryId`; Query `page`, `size` +- Response Data: `AdminPageResponse` +- 활성 연결과 활성 콘텐츠만 대상으로 `CategoryContent.orders ASC, contentId ASC`로 정렬한다. + +#### CATEGORY-07 · Available Content List + +- Request: Path `characterId`, `categoryId`; Query `page`, `size`, 선택 `search` +- Response Data: `AdminPageResponse` +- 활성 콘텐츠 중 해당 카테고리에 포함되지 않은 콘텐츠를 `createdAtUtc DESC, contentId DESC`로 정렬한다. + +#### CATEGORY-08 · Add Contents + +Request `AdminCategoryContentsAddRequest`: + +```json +{ + "contentIds": [2001, 2002] +} +``` + +| Field | Type | Required | +|---|---|---:| +| `contentIds` | `List` | Yes | + +Response Data `AdminCategoryContentsMutationResponse`: + +```json +{ + "categoryId": 6001, + "affectedContentIds": [2001, 2002] +} +``` + +추가 대상은 중복이 없어야 하며 모두 선택 캐릭터 소유의 활성 콘텐츠여야 한다. 하나라도 조건을 충족하지 않으면 전체 요청을 rollback한다. + +기존 비활성 연결이 있으면 새 row를 만들지 않고 다시 활성화해 현재 마지막 `orders` 뒤에 배치한다. 이미 활성인 콘텐츠의 중복 추가는 409다. + +#### CATEGORY-09 · Remove Content + +- Request: Path `characterId`, `categoryId`, `contentId`; Body 없음 +- Response Data: `{ "categoryId": 6001, "affectedContentIds": [2001] }` +- 제거 대상 연결이 존재하면 `CategoryContent.isActive=false`로 논리 삭제한다. +- 같은 제거 요청을 반복하면 `affectedContentIds=[]`인 200을 반환한다. + +### 18.8 Channel Notice Endpoint Summary + +| Operation ID | Method | Endpoint | Request | Response Data | +|---|---|---|---|---| +| `NOTICE-01` | `GET` | `/admin/ai-characters/{characterId}/channel-notice` | Path: `characterId` | `AdminChannelNoticeResponse` | +| `NOTICE-02` | `PUT` | `/admin/ai-characters/{characterId}/channel-notice` | Path: `characterId`; JSON: `AdminChannelNoticeUpsertRequest` | `AdminChannelNoticeResponse` | + +별도 등록 Endpoint를 만들지 않고 `PUT`을 upsert로 사용한다. + +### 18.9 NOTICE-01 · Channel Notice Read + +#### Request + +- Path `characterId: Long` +- Query와 Body 없음 + +#### Response Data + +`AdminChannelNoticeResponse` + +| Field | Type | Nullable | +|---|---|---:| +| `characterId` | `Long` | No | +| `creatorId` | `Long` | No | +| `notice` | `String` | No | +| `updatedAtUtc` | `String` | Yes | + +저장된 공지가 없으면 `notice=""`, `updatedAtUtc=null`을 반환한다. + +### 18.10 NOTICE-02 · Channel Notice Upsert + +#### Request + +`AdminChannelNoticeUpsertRequest` + +```json +{ + "notice": "새 콘텐츠는 매주 금요일 공개됩니다." +} +``` + +| Field | Type | Required | Description | +|---|---|---:|---| +| `notice` | `String` | Yes | 빈 문자열은 공지 내용 지우기 | + +#### Response Data + +변경 후 `AdminChannelNoticeResponse`를 반환한다. + +- 선택 AI 캐릭터의 `creatorMemberId`로 공지를 조회·저장한다. +- 값이 실제로 변경된 경우에만 v2 notification port를 통해 기존 구독자 알림 이벤트와 동등한 알림을 발행한다. +- legacy `ExplorerService.saveNotice`를 호출하지 않는다. + +### 18.11 Channel Profile and Creator Tag Endpoint Summary + +| Operation ID | Method | Endpoint | Request | Response Data | +|---|---|---|---|---| +| `CREATOR-TAG-01` | `GET` | `/admin/ai-characters/metadata/creator-tags` | 없음 | `List` | +| `CHANNEL-PROFILE-01` | `GET` | `/admin/ai-characters/{characterId}/channel-profile` | Path: `characterId` | `AdminChannelProfileResponse` | +| `CHANNEL-PROFILE-02` | `PUT` | `/admin/ai-characters/{characterId}/channel-profile` | Path: `characterId`; JSON: `AdminChannelProfileUpdateRequest` | `AdminChannelProfileResponse` | + +### 18.12 CREATOR-TAG-01, CHANNEL-PROFILE-01/02 · Channel Profile and Creator Tags + +#### CREATOR-TAG-01 · Creator Tag Metadata + +`AdminCreatorTagResponse` + +| Field | Type | Nullable | +|---|---|---:| +| `tagId` | `Long` | No | +| `name` | `String` | No | +| `imageUrl` | `String` | Yes | +| `isAdult` | `Boolean` | No | + +creator tag metadata는 활성 태그 전체를 `orders ASC, tagId ASC`로 반환하는 비페이징 기준정보다. + +#### CHANNEL-PROFILE-01 · Channel Profile Read + +- Request: Path `characterId`; Query와 Body 없음 +- Response Data: `AdminChannelProfileResponse` + +`AdminChannelProfileResponse` + +| Field | Type | Nullable | +|---|---|---:| +| `characterId` | `Long` | No | +| `creatorId` | `Long` | No | +| `instagramUrl` | `String` | No | +| `fancimmUrl` | `String` | No | +| `xUrl` | `String` | No | +| `youtubeUrl` | `String` | No | +| `kakaoOpenChatUrl` | `String` | No | +| `creatorTags` | `List` | No | +| `isVisibleDonationRank` | `Boolean` | No | +| `donationRankingPeriod` | `String` | Yes | +| `updatedAtUtc` | `String` | Yes | + +#### CHANNEL-PROFILE-02 · Channel Profile Update + +`AdminChannelProfileUpdateRequest` + +| Field | Type | Required | +|---|---|---:| +| `instagramUrl` | `String` | Yes | +| `fancimmUrl` | `String` | Yes | +| `xUrl` | `String` | Yes | +| `youtubeUrl` | `String` | Yes | +| `kakaoOpenChatUrl` | `String` | Yes | +| `tagIds` | `List` | Yes | +| `isVisibleDonationRank` | `Boolean` | Yes | +| `donationRankingPeriod` | `String?` | Yes | + +```json +{ + "instagramUrl": "https://instagram.com/example", + "fancimmUrl": "", + "xUrl": "", + "youtubeUrl": "https://youtube.com/@example", + "kakaoOpenChatUrl": "", + "tagIds": [11, 14], + "isVisibleDonationRank": true, + "donationRankingPeriod": "CUMULATIVE" +} +``` + +- Response Data는 변경 후 `AdminChannelProfileResponse`다. +- 빈 문자열은 해당 URL 지우기다. +- URL 값은 빈 문자열 또는 `http`/`https` 절대 URL이어야 한다. +- `donationRankingPeriod`는 `WEEKLY`, `CUMULATIVE`, `null`만 허용한다. +- `tagIds`는 중복 없이 활성 creator tag만 포함해야 하며 AI creator Member의 태그를 전체 교체한다. 빈 배열은 모든 creator tag 제거를 의미하며 일부 크리에이터 탐색 목록에서 해당 채널이 제외될 수 있다. +- `ChatCharacter.tags`는 캐릭터 대화·검색용 별도 모델이므로 creator Member 태그와 자동 동기화하지 않는다. +- 이름, 프로필 이미지와 소개는 이 API에서 변경하지 않고 캐릭터 수정의 동기화 결과만 사용한다. + +## 19. Authorization and Ownership Rules + +요청군별 검증 경계는 다음과 같다. + +| Request group | Authentication | Target validation | +|---|---|---| +| `GET/POST /admin/ai-characters` | `ROLE_ADMIN` | 목록은 target 없음, 등록은 신규 character·creator 생성 규칙 적용 | +| `/admin/ai-characters/original-works/**` | `ROLE_ADMIN` | global 원작 상태와 캐릭터 배정 집합을 검증하며 사전 character 선택은 없음 | +| `/admin/ai-characters/{characterId}/**` 및 캐릭터 상세·수정·삭제 | `ROLE_ADMIN` | `characterId`와 연결 AI creator를 해석한 뒤 소유권·활성 상태 검증 | +| `/admin/ai-characters/metadata/**` | `ROLE_ADMIN` | character target 없이 활성 기준정보만 조회 | + +캐릭터 범위 요청은 다음 순서로 검증한다. + +1. Bearer JWT와 `ROLE_ADMIN`을 확인한다. +2. `characterId`에 해당하는 `ChatCharacter`와 연결 Member가 존재하고 `role=CREATOR`, `memberKind=AI_CHARACTER`인지 확인한다. +3. 대상 콘텐츠, 시리즈, 카테고리 또는 게시글이 연결 `creatorMember.id` 소유인지 확인한다. +4. 댓글·답글은 부모와 루트 리소스의 귀속까지 확인한다. +5. 생성·수정·구성 변경에는 캐릭터, 연결 Member 및 대상 부모 리소스가 모두 활성 상태인지 확인한다. +6. 검증이 끝난 뒤 해당 v2 도메인 use case를 호출한다. + +global 원작 변경은 사람 관리자만 인증한 뒤 `originalWorkId`, `isDeleted`와 제목 충돌을 검증한다. 배정·해제는 Request의 모든 `characterIds`를 한 번에 검증하고, 배정에서는 활성 AI 캐릭터인지, 해제에서는 Path 원작에 실제 연결되어 있는지 확인한 뒤 하나의 transaction으로 변경한다. 캐릭터를 먼저 선택하거나 관리자 principal을 AI 캐릭터 Member로 바꾸지 않는다. + +논리 삭제된 콘텐츠, 시리즈, 카테고리, 게시글, 댓글 또는 답글에는 수정·고정·순서/구성 변경·하위 리소스 생성을 허용하지 않고 409를 반환한다. 조회 API에서 명시한 상태 필터 조회와 `DELETE` 재시도만 허용한다. 캐릭터 자체 및 기존 리소스의 논리 삭제는 비활성 캐릭터에서도 허용하며 반복 호출은 같은 비활성 결과를 반환한다. + +관리자 principal을 AI 캐릭터 Member로 교체하지 않는다. + +```text +ADMIN JWT + -> v2 admin web adapter + -> v2 AI character target query + -> characterId / creatorMemberId 해석 + -> 해당 v2 domain use case + -> v2 persistence / external-system port +``` + +## 20. Error Contract + +다음 표의 역할 규칙은 `/admin/ai-characters/**`에 적용한다. + +| Situation | HTTP Status | `success` | `errorProperty` | +|---|---:|---:|---| +| JWT 없음 또는 유효하지 않음 | `401` | `false` | `null` | +| 인증되었지만 `ROLE_ADMIN` 아님 | `403` | `false` | `null` | +| 캐릭터 미존재 | `404` | `false` | `characterId` | +| `originalWorkId` 또는 원작 API Path ID가 0 이하 | `400` | `false` | `originalWorkId` | +| 원작 미존재 또는 삭제된 원작 조회 | `404` | `false` | `originalWorkId` | +| 원작 수정·삭제·배정·해제의 미존재 양수 Path ID | `404` | `false` | `originalWorkId` | +| 캐릭터 생성·수정의 미존재 양수 `originalWorkId` | `404` | `false` | `originalWorkId` | +| 캐릭터 생성·수정의 삭제된 `originalWorkId` | `409` | `false` | `originalWorkId` | +| 삭제된 원작 수정·배정·해제 | `409` | `false` | `originalWorkId` | +| 연결 캐릭터가 남은 원작 삭제 | `409` | `false` | `originalWorkId` | +| 원작 배정의 미존재·중복·비AI 캐릭터 또는 해제 귀속 불일치 | `400` | `false` | `characterIds` | +| 원작 배정 대상 캐릭터 비활성 | `409` | `false` | `characterIds` | +| 대상 리소스 미존재 또는 다른 캐릭터 소유 | `404` | `false` | 해당 resource ID field | +| 비활성 캐릭터에 대한 `DELETE` 외 변경 요청 | `409` | `false` | `characterId` | +| 논리 삭제된 리소스에 대한 삭제 외 변경 요청 | `409` | `false` | 해당 resource ID field | +| 잘못된 필드·부모 귀속·페이지 요청 | `400` | `false` | 해당 field | +| 동일 이름 등 현재 상태와 충돌 | `409` | `false` | 충돌 field | +| 외부 캐릭터 API 또는 파일 저장 실패 | `502` | `false` | `null` | +| 원작 이미지 저장 후 비재시도 DB 실패 또는 재시도 소진 | `500` | `false` | `null` | +| Signed URL 생성 또는 저장된 output key 무결성 검증 실패 | `500` | `false` | `null` | + +신규 Endpoint는 기존 일부 legacy handler의 `HTTP 200 + success=false` 관례를 답습하지 않고 위 HTTP status를 계약으로 사용한다. 오류 본문은 기존 `ApiResponse.error(...)` 형식을 유지한다. + +이미 삭제된 원작의 반복 DELETE는 과거 불일치 연결이 남아 있어도 성공 200이므로 위 “연결 캐릭터가 남은 원작 삭제” 409보다 먼저 판정한다. 원작 이미지 보상 삭제가 실패해도 이미 발생한 로컬 transaction 실패의 500을 다른 성공이나 502로 바꾸지 않고 orphan 운영 로그를 남긴다. + +기존 `/audio-content/upload-complete`의 인증·오류 응답 계약은 이번 범위에서 변경하지 않는다. 관리자 콘텐츠 조회에서 수행하는 canonical output key 검증은 기존 callback의 Request·Response 또는 오류 envelope를 변경하지 않는다. + +## 21. Technical Requirements + +### 21.1 Module Boundary + +- 관리자 inbound adapter와 DTO는 `kr.co.vividnext.sodalive.v2.admin.aicharacter` 아래에 둔다. +- 신규 worker callback Controller, 전용 Request/Response, V1/V2 dispatcher 또는 v2 completion input port를 만들지 않는다. 기존 `/audio-content/upload-complete`의 Controller와 Service 흐름을 유지한다. +- 비즈니스 기능은 관리자 패키지 한 곳에 모으지 않고 다음 v2 도메인이 소유한다. + +| 기능 | v2 소유 패키지 | +|---|---| +| AI 캐릭터 CRUD와 관리자 대상 해석 | `kr.co.vividnext.sodalive.v2.aicharacter` | +| 원작 CRUD·검색과 캐릭터 배정 | `kr.co.vividnext.sodalive.v2.originalwork` | +| 콘텐츠·콘텐츠 댓글·콘텐츠 카테고리·콘텐츠 기준정보 | `kr.co.vividnext.sodalive.v2.content` 하위 기능 패키지 | +| 시리즈·장르·시리즈 구성 | `kr.co.vividnext.sodalive.v2.creator.channel.series` | +| 커뮤니티 게시글·댓글 | `kr.co.vividnext.sodalive.v2.creator.channel.community` | +| FanTalk 조회·답글 | `kr.co.vividnext.sodalive.v2.creator.channel.fantalk` | +| 크리에이터 채널 공지 | `kr.co.vividnext.sodalive.v2.creator.channel.notice` | +| 크리에이터 채널 프로필·creator tag 설정 | `kr.co.vividnext.sodalive.v2.creator.channel.profile` | + +- 각 도메인은 필요한 `domain`, `application`, `port/out`, `adapter/out` 계층을 기존 v2 구조에 맞춰 둔다. +- admin web adapter는 Request 변환, `Member` principal에서 `adminMemberId` 추출, Response 변환만 담당한다. +- admin application 조정 계층은 character-scoped 요청의 `characterId -> creatorMemberId` 해석 또는 global 원작 요청의 원작 use case 호출과 Response 변환만 담당한다. +- 업무 규칙, 소유권 검증 및 상태 전이는 각 v2 도메인이 소유한다. +- legacy Controller, Service, Request/Response DTO 및 Repository를 호출하지 않는다. +- 호환용 legacy 원작 관리자 web adapter와 legacy 캐릭터 등록·수정 adapter는 각각 v2 원작·캐릭터 input command를 호출할 수 있다. 의존 방향은 `legacy inbound -> v2 application` 단방향이며, 기존 Method·Path·Request·성공 Response와 legacy patch 의미 변환만 legacy 계층이 담당한다. +- Controller에서 다른 Controller를 호출하지 않고 기존 API를 내부 HTTP로 호출하지 않는다. +- 단일 기능을 위한 범용 impersonation 프레임워크를 만들지 않는다. + +### 21.2 Target Resolver + +v2 AI character domain의 공통 대상 query는 entity가 아닌 다음 값 객체를 반환한다. + +```text +AiCharacterAdminTarget( + characterId, + creatorMemberId, + characterIsActive, + creatorMemberIsActive, + creatorRole, + memberKind +) +``` + +대상 query는 `creatorRole=CREATOR`, `memberKind=AI_CHARACTER`를 만족하지 않으면 대행 대상으로 반환하지 않는다. 생성·수정 작업에는 캐릭터와 연결 Member가 모두 활성 상태여야 한다. 모든 character-scoped 하위 도메인 use case는 이 해석 결과를 사용해 body의 `creatorId` 주입 가능성을 제거한다. 각 도메인은 전달된 `creatorMemberId`와 대상 리소스의 실제 소유자가 같은지도 자체 persistence port로 다시 검증한다. global 원작 CRUD에는 이 resolver를 호출하지 않는다. 원작 배정은 각 character ID의 활성 AI 캐릭터 유효성을 일괄 검증하고, 해제는 legacy 불일치 관계 정리를 위해 resolver 대신 기존 `ChatCharacter`의 존재와 Path 원작 귀속만 일괄 검증한다. + +### 21.3 v2 Domain Implementation + +- legacy 구현은 데이터 의미와 회귀 시나리오를 파악하는 참고 자료로만 사용한다. +- v2 도메인 규칙을 legacy service에 위임하지 않는다. +- 기존 v2 query use case와 port가 이 문서의 계약을 충족하면 같은 v2 도메인 안에서 재사용하거나 확장할 수 있다. +- command use case가 없는 콘텐츠·댓글·시리즈·커뮤니티·FanTalk 답글은 각 v2 도메인에 별도로 구현한다. +- 관리자 조회에는 구매 여부, 성인 선호도 또는 차단 관계에 따른 소비자용 마스킹을 적용하지 않고 관리자 전용 query projection을 사용한다. +- 다음 규칙은 legacy 동작을 복사하지 않고 v2 도메인 정책으로 명시적으로 구현한다. + - 캐릭터 활성 상태와 연결 AI creator 유효성 + - 원작의 `isDeleted=false`, 제목 중복, 전체 교체, 연결 캐릭터가 없는 삭제 조건 + - 원작 캐릭터 배정·해제의 전건 검증, 원자성, 이동 및 현재 원작 귀속 + - 콘텐츠·시리즈·게시글 소유권 + - 콘텐츠 카테고리와 포함 콘텐츠의 동일 소유권 + - 콘텐츠 댓글과 커뮤니티 댓글의 부모·루트 귀속 + - 작성자만 본문 수정 가능 + - 작성자 또는 소유자만 댓글 논리 삭제 가능 + - 시리즈 순서 변경 대상 전체의 동일 소유자 검증 + - FanTalk 루트 대상과 AI 답글 작성자 검증 + - 채널 공지의 creator 소유권과 변경 알림 + - 채널 프로필·creator tag 설정의 creator 소유권과 활성 tag 검증 +- 캐릭터 등록·수정의 외부 API와 파일 저장, 원작 이미지 저장은 v2 outbound port로 정의하고 v2 infrastructure adapter에서 구현한다. + +legacy Service를 호출하지 않더라도 다음 외부 관찰 가능 동작은 v2 outbound port와 after-commit event로 보존한다. + +| Trigger | Required side effect | +|---|---| +| 캐릭터 등록 | description 기반 언어 감지 작업 예약 | +| 캐릭터 수정 | 캐릭터 번역 갱신 작업 예약 | +| 원작 등록 | title·contentType·category·description 기반 언어 감지 작업 예약 | +| 원작 수정 | 정규화된 title·contentType·category·description·tags 중 하나 이상이 실제 변경된 경우 원작 번역 갱신 작업 예약 | +| 콘텐츠 등록·수정 | 언어 코드가 없으면 언어 감지, 있으면 번역 작업 예약 | +| 기존 callback 또는 예약 공개가 콘텐츠를 최초 공개 | 구독자 콘텐츠 공개 알림과 v2 following/home news 발행 | +| 콘텐츠 댓글 등록 | 대상 콘텐츠 알림, 언어 코드가 없으면 언어 감지 | +| 시리즈 등록·수정 | 언어 감지 또는 번역 작업 예약 | +| 콘텐츠 카테고리 등록·제목 수정 | 언어 감지 또는 번역 작업 예약 | +| 커뮤니티 게시글 등록 | 구독자 알림, 무료 게시글이면 v2 following/home news 발행 | +| FanTalk 답글 등록 | 언어 코드가 없으면 언어 감지 | +| 채널 공지 변경 | 구독자 공지 변경 알림 | +| 캐릭터 삭제 | commit 후 공개 콘텐츠·추천·랭킹·인기 캐릭터 cache 무효화 | + +트랜잭션 rollback 시 알림·home news·번역 요청을 발행하지 않는다. 같은 공개 처리 또는 같은 command의 멱등 재시도에서 중복 발행하지 않는다. + +원작 event adapter는 legacy 언어 감지 listener가 감지 transaction 안에서 후속 번역 event를 즉시 발행할 수 있다는 점을 그대로 노출하지 않는다. 원작 생성·수정 transaction이 commit된 뒤에만 감지 또는 번역 시작 event를 한 번 전달하고, 감지 결과를 사용하는 후속 번역도 해당 감지 결과 commit 이후 실행되도록 adapter test로 고정한다. + +### 21.4 Persistence and Shared Infrastructure + +- 기존 DB 테이블을 그대로 사용하며 같은 테이블을 위한 v2 전용 JPA entity를 중복 생성하지 않는다. +- v2 domain/application은 legacy JPA entity나 QueryDSL Q type을 참조하지 않는다. +- v2 persistence adapter만 기존 JPA entity 또는 QueryDSL Q type을 사용해 기존 테이블에 접근할 수 있다. +- adapter는 persistence 결과를 v2 port record 또는 v2 domain model로 변환한다. +- v2 application과 v2 persistence adapter 모두 legacy Repository를 주입하거나 호출하지 않는다. 필요한 query/command는 v2가 소유한 port와 persistence 구현으로 정의하고, adapter에서 `EntityManager`, QueryDSL 또는 v2 전용 repository 구현을 사용해 기존 entity/table에 접근한다. +- original-work persistence adapter는 기존 `OriginalWork`, `OriginalWorkLink`, `OriginalWorkTag`, `OriginalWorkTagMapping` entity와 `ChatCharacter.originalWork` 관계를 사용하되 v2 domain에 이를 노출하지 않는다. 같은 테이블을 위한 v2 `@Entity` 또는 relation mapping을 새로 만들지 않는다. +- 원작 link와 tag-mapping 조회는 각 mapping ID 오름차순을 query에 명시하고, 변경 시 정규화된 요청 순서대로 mapping을 다시 만든다. 순서 보존을 위해 기존 entity에 `@OrderColumn`을 추가하지 않는다. +- 원작 삭제·배정·해제와 캐릭터 원작 연결 변경은 v2 persistence port의 잠금 query를 사용한다. 양수 추가 연결 대상 또는 원작 Path가 있으면 해당 원작 row를 `PESSIMISTIC_WRITE`로 먼저 잠그고 batch 캐릭터 row를 ID 오름차순으로 잠근 뒤 `isDeleted`, 활성 AI 여부와 현재 귀속을 다시 검증한다. `CHAR-04` null과 legacy 수정 0의 해제는 target 원작 없이 캐릭터 row만 잠그고 현재 귀속을 재검증한다. +- 원작 생성과 제목이 실제 바뀌는 수정은 MySQL `SERIALIZABLE` 격리에서 중복 조회와 write를 같은 transaction으로 처리한다. deadlock·serialization 실패는 새 transaction에서 최대 한 번 재시도하며, 중복 재조회 결과가 없는 잠금 실패를 제목 충돌 409로 오인하지 않는다. +- 공통 JWT 인증, `ApiResponse`, 파일 저장 client, CDN URL 정책 및 외부 캐릭터 API client는 플랫폼·인프라 기능이므로 port 경계 뒤에서 재사용할 수 있다. +- 이번 기능을 위해 DB 테이블·컬럼·인덱스를 추가하거나 기존 JPA entity mapping을 변경하지 않는다. `alter-existing-tables.sql`, lifecycle backfill, upload pipeline 구분 컬럼과 v2 전용 콘텐츠 테이블도 만들지 않는다. +- content persistence adapter는 기존 `content` row의 `isActive`, `releaseDate`, `duration`, `content` 경로와 연결 creator 활성 상태를 반환한다. v2 query application은 12.2의 우선순위로 `status`를 계산하며 계산값을 DB에 다시 저장하지 않는다. +- CONTENT-01의 `status` 필터도 같은 기존 컬럼 조건과 하나의 UTC 기준 시각을 사용한다. 별도 status 컬럼이나 status 전용 인덱스는 실제 성능 근거 없이 추가하지 않는다. +- 콘텐츠 생성 Request의 `previewStartTime`, `previewEndTime`은 S3 metadata 전달용이며 DB에 저장하지 않는다. 따라서 목록·상세 persistence projection과 Response에도 포함하지 않는다. +- v2 content query application은 요청마다 기준 `Instant`를 한 번 얻고 `(duration의 HH 부분 + 2)시간`을 더한 절대 `expiresAt`을 계산한다. +- Signed URL outbound port는 canonical output key와 절대 `expiresAt`을 받아 URL policy에 정확히 같은 만료 시각을 사용하고 `SignedAudioUrl(url, expiresAt)`을 반환한다. 현재 `AudioContentCloudFront`의 상대 TTL 호출과 별도로 응답 만료 시각을 계산해 두 값이 어긋나는 구현은 허용하지 않는다. +- v2 content query application은 위 port를 통해 `CONTENT-01`, `CONTENT-02` 응답마다 Signed URL과 `contentUrlExpiresAtUtc`를 함께 생성한다. +- Signed URL 생성 실패 시 persistence 경로나 비서명 오디오 URL을 응답으로 노출하지 않는다. +- v2 콘텐츠 생성은 기존 row와 같은 방식으로 `isActive=false`, `duration=null`, `content=input/{contentId}/{contentId}-content-...`로 시작한다. object basename은 기존 생성 규칙을 유지해 worker가 만든 output basename이 callback의 content ID 검증을 통과하게 한다. `releaseAtUtc=null`이면 현재 UTC 시각을 `releaseDate`에 저장해 callback 완료 후 즉시 공개 조건을 표현한다. +- 콘텐츠 논리 삭제는 기존 의미대로 `isActive=false`, `releaseDate=null`로 기록한다. callback은 가공 결과 경로와 duration을 기록할 수 있지만 이 row를 다시 활성화하지 않는다. +- 캐릭터 삭제 cascade는 소유 콘텐츠 row의 raw `isActive`만 `false`로 전환하고 `releaseDate`, `duration`, `content`와 구매 이력을 보존한다. 연결 creator 비활성 상태를 포함해 계산한 유효 `isActive`는 `false`이고 기존 미삭제 콘텐츠의 `status`는 `SUSPENDED`다. 이 상태 변경은 기존 컬럼을 사용하며 schema나 JPA mapping 변경을 요구하지 않는다. +- 기존 비활성 캐릭터 row를 일괄 보정하는 데이터 migration도 현재 근거 없이 선제 수행하지 않는다. 실제 운영 불일치가 확인되면 대상·영향 건수를 먼저 조사하고 별도 승인과 migration 계획을 작성한다. + +### 21.5 Transactions and External Systems + +- DB 안에서 끝나는 변경은 하나의 transaction으로 처리한다. +- 캐릭터 등록·수정은 외부 캐릭터 API, 이미지 저장, DB 변경의 실패 지점을 구분해 오류를 반환한다. +- 원작 생성·이미지 교체 command도 서버에서 `requestId`를 생성한다. 이는 파일 key·보상·구조화 로그 상관관계용이며 클라이언트 HTTP 재시도 멱등성 key는 아니다. +- `OriginalWorkImageStoragePort`는 `store(originalWorkId, validatedImage, requestId, attemptNumber)`와 새로 저장한 object의 `delete(objectKey)`를 제공한다. `store`는 `originals/{originalWorkId}/...` 아래 attempt별 고유 key를 만들고 정확한 `objectKey`를 반환한다. 공통 `S3Uploader`에는 존재 확인 없이 정확한 bucket·object key를 `deleteObject`하는 최소 메서드만 추가하고, v2 S3 adapter가 이를 port 뒤에서 사용해 application에 AWS type을 노출하지 않는다. +- 원작 생성·이미지 교체는 DB와 새 이미지의 부분 성공을 반환하지 않는다. DB commit 전에 `originals/{originalWorkId}/...`에 저장한 새 이미지가 이후 실패하면 같은 request의 object key만 삭제 보상한다. 기존 이미지와 논리 삭제된 원작의 이미지는 보존하며 일반 이미지 정리 기능을 추가하지 않는다. +- 원작 이미지 orchestration은 `@Transactional` proxy method 내부의 `try/catch`에 commit 예외가 잡힌다고 가정하지 않는다. application의 비transactional 진입점이 attempt마다 하나의 `REQUIRES_NEW` `TransactionTemplate.execute` 전체를 감싸 DB flush·commit 예외까지 받은 뒤, callback 밖에서 확인한 해당 attempt의 새 object key만 보상한다. outer transaction에 join하거나 `REQUIRED`와 `SERIALIZABLE` template을 중첩하지 않는다. +- 원작 이미지 저장 자체가 실패하면 DB 변경을 rollback하고 502를 반환한다. 이미지 저장 뒤 retry 가능한 deadlock·serialization 실패가 발생하면 아직 HTTP 오류로 매핑하지 않고 해당 attempt object를 삭제한 다음, 보상이 성공한 경우에만 새 transaction과 새 key로 한 번 재시도한다. 재조회에서 실제 제목 중복이 확인되면 409를 반환한다. 비재시도 DB 실패 또는 재시도 소진은 attempt object 삭제 보상 후 500을 반환한다. 보상 삭제가 실패하면 재시도를 중단하고 원래 500을 유지하며 `requestId`, attempt number, object key와 실패 단계를 orphan 로그 및 운영 알림에 남긴다. +- 원작 배정·해제는 Request의 모든 캐릭터를 검증한 뒤 한 transaction에서 변경한다. 누락·비활성·귀속 불일치 ID를 조용히 건너뛰는 부분 성공을 허용하지 않는다. +- 캐릭터 변경 command마다 `requestId`를 생성하고 외부 캐릭터 API와 파일 저장 port에 idempotency key로 전달한다. +- 외부 캐릭터 또는 새 파일 생성 후 DB 작업이 실패하면 생성된 외부 리소스의 삭제·비활성화 보상을 즉시 시도한다. +- 외부 캐릭터 수정 성공 후 DB 작업이 실패하면 command 시작 전에 읽은 remote 표현으로 compensating update를 시도한다. 원상 복구를 지원하지 않거나 실패하면 local/remote 차이와 외부 resource ID를 divergence 로그와 운영 재처리 대상으로 남긴다. +- 외부 시스템이 보상 작업을 지원하지 않거나 보상이 실패하면 `requestId`, 외부 리소스 ID 또는 파일 경로, 실패 단계를 구조화 orphan 로그로 남기고 운영 알림을 발행한다. 같은 `requestId`의 재처리는 기존 외부 리소스를 확인해 중복 생성하지 않는다. +- 이 문서의 “운영 알림”은 기존 로그 수집·경보가 감지하는 구조화 `ERROR` log를 의미한다. 이번 범위에 별도 알림 outbound port, 메시지 채널 또는 범용 운영 알림 시스템을 추가하지 않는다. +- 외부 작업이나 보상 결과와 관계없이 DB 변경까지 완료되지 않은 요청은 성공으로 응답하지 않는다. +- 파일 업로드 실패 시 부분 DB 리소스를 성공으로 반환하지 않는다. +- v2 업로드 요청은 기존 worker가 이미 처리하는 S3 bucket, `input/{contentId}/{contentId}-content-...` key와 metadata 계약을 그대로 사용하며 callback URL, pipeline version 또는 새 분기 정보를 worker에 전달하지 않는다. +- 기존 `/audio-content/upload-complete`의 Method, Path, 인증과 Request/Response가 바뀌지 않는지 contract test로 검증한다. +- v2에서 생성한 기존 형식의 content row도 현재 `AudioContentService.uploadComplete`와 예약 공개 흐름이 처리하는지 통합 검증한다. +- callback은 `releaseDate=null` 또는 연결 creator 비활성인 콘텐츠를 공개하지 않고, 기존 예약 공개 query는 활성 creator만 선택하도록 최소 안전 조건을 보강한다. `AudioContentReleaseScheduledTask`의 cron·lock과 worker 코드는 수정하지 않는다. +- 캐릭터 삭제 시 raw `content.isActive=false`가 되므로 일반 사용자용 목록·검색·추천은 기존 공개 조건으로 이를 제외한다. 직접 상세 조회는 비활성 creator 또는 비활성 콘텐츠를 미구매 사용자에게 반환하지 않되, 기존 주문을 확인한 `KEEP`·`RENTAL` 구매자의 재생 경로는 유지한다. +- content/creator ranking의 latest/previous visible snapshot query는 snapshot 생성 당시 값만 신뢰하지 않고 현재 `content.isActive`와 creator Member의 `isActive`를 확인한다. snapshot row 자체는 변경하거나 backfill하지 않는다. +- snapshot 외 legacy creator ranking query도 현재 Member의 `isActive=true`를 요구한다. +- 캐릭터·콘텐츠·시리즈의 공개 언어별 banner query는 banner 자체의 활성 상태뿐 아니라 연결 대상의 현재 활성 상태를 확인한다. banner row와 관리자용 전체 목록은 변경하지 않는다. +- 비활성 creator 콘텐츠의 댓글·답글 공개 조회와 신규 등록·본문 수정·재활성화는 거부하고 권한 있는 기존 댓글 논리 삭제만 허용한다. 구매자는 재생에 필요한 상세와 Signed URL만 유지하며 댓글·관련 콘텐츠 같은 공개 상호작용은 제공하지 않는다. +- 현재 v2 FanTalk 탭은 활성 creator 조회 조건을 유지하고, legacy FanTalk 목록과 신규 원문 등록도 대상 creator의 활성 상태를 확인한다. 비활성 creator의 FanTalk 원문·답글 row는 변경하지 않는다. +- 캐릭터 삭제 event는 transaction 안에서 상태가 실제 변경된 경우에만 1회 publish한다. AFTER_COMMIT listener는 `default`, `cache_ttl_3_days`, `popularCharacters_24h_locale` cache 전체를 clear해 삭제 전 materialized DTO를 제거한다. rollback과 멱등 재시도에서는 clear하지 않으며 cache TTL이나 Redis 설정은 변경하지 않는다. +- worker와 AWS Trigger 구현은 이 저장소 밖에 있으므로 배포 전 staging에서 v2 원본 업로드부터 기존 callback 완료까지 E2E를 1회 수행한다. S3 input 저장 시점과 DB transaction commit 사이에 callback이 도착할 가능성과 worker의 조회 실패 retry 여부도 이 검증에서 확인하며, 확인되지 않은 retry 동작을 Backend 보장으로 가정하지 않는다. + +### 21.6 Audit Log + +`/admin/ai-characters/**`의 사람 관리자 변경 작업은 최소 다음 구조화 로그를 남긴다. + +| Field | Description | +|---|---| +| `adminMemberId` | 실제 인증된 사람 관리자 | +| `characterId` | 선택 AI 캐릭터. global 원작 CRUD에서는 `null`, 배정·해제에서는 캐릭터별 로그에 값 기록 | +| `creatorMemberId` | 연결 크리에이터 Member. global 원작 CRUD에서는 `null`, 배정에서는 캐릭터별 값, legacy 불일치 캐릭터 해제에서는 `null` 가능 | +| `action` | CREATE, UPDATE, DELETE, ASSIGN, UNASSIGN, PIN 등 | +| `resourceType` | CHARACTER, ORIGINAL_WORK, ORIGINAL_WORK_CHARACTER, CONTENT, CONTENT_COMMENT, CONTENT_CATEGORY, SERIES, COMMUNITY_POST, COMMUNITY_COMMENT, FAN_TALK, FAN_TALK_REPLY, CHANNEL_NOTICE, CHANNEL_PROFILE | +| `resourceId` | 변경 대상 ID | +| `result` | SUCCESS 또는 FAILURE | + +원작 배정·해제는 변경 캐릭터마다 `resourceType=ORIGINAL_WORK_CHARACTER`, `resourceId=originalWorkId`, 해당 `characterId`와 가능한 경우 `creatorMemberId`를 남긴다. 원작 생성·수정·삭제는 `resourceType=ORIGINAL_WORK`이며 두 캐릭터 field가 `null`이다. + +영속 audit table 도입은 이번 범위가 아니며 구조화 application log를 요구한다. 비밀번호, JWT, system prompt 전체, 댓글 본문 또는 업로드 파일 내용은 로그에 기록하지 않는다. + +### 21.7 Security + +- `/admin/ai-characters/**` Controller는 class level에서 `hasRole('ADMIN')`을 선언한다. +- 기존 `/audio-content/upload-complete`의 `hasAnyRole('BOT', 'ADMIN')`과 외부 응답 계약은 변경하지 않는다. +- `/admin/ai-characters/**` RequestMatcher에만 적용되는 authentication entry point, access-denied handler 및 exception response 경계를 두어 legacy API의 HTTP status를 변경하지 않고 20장의 status와 `ApiResponse` 계약을 보장한다. +- 클라이언트 메뉴·route guard 테스트와 Backend API 인가 테스트를 별도로 작성한다. +- Multipart 요청은 애플리케이션의 `max-file-size=1024MB`, `max-request-size=1024MB` 상한을 적용한다. 이미지 part는 공통 이미지 검증기를 v2 web adapter에서 사용해 실제 MIME type을 검증한다. GIF는 API에 별도 허용 조건이 있는 유료 커뮤니티 게시글 이미지만 허용하고, 원작·캐릭터·콘텐츠 커버·시리즈 이미지를 포함한 나머지 image part에서는 거부한다. +- 콘텐츠 원본 오디오는 빈 파일을 거부하고 worker에 전달한다. 지원 codec과 재생 가능 여부는 worker가 검증하며 실패한 콘텐츠를 `PUBLISHED`로 전환하지 않는다. +- 관리자 응답에서 system prompt는 캐릭터 상세에만 포함하며 목록에는 포함하지 않는다. +- `CONTENT-01`, `CONTENT-02`는 Signed URL이 포함될 수 있으므로 `Cache-Control: private, no-store`를 반환한다. + +## 22. Acceptance Criteria + +아래 체크박스는 사용자가 추가로 결정할 항목이 아니다. 후속 `plan-task.md`, 구현 및 테스트 단계에서 이 PRD의 충족 여부를 추적하기 위한 검증 목록이며, 아직 구현하지 않았으므로 모두 미체크 상태로 둔다. 제품 결정이 필요한 항목은 24장에 기록하고 미결정 사항은 25장에만 기록한다. + +### Authentication and Menu + +- [ ] 기존 `POST /admin/member/login`으로 로그인한 `ADMIN`이 신규 API를 호출할 수 있다. +- [ ] 별도 AI 캐릭터 관리자 로그인 Endpoint가 추가되지 않는다. +- [ ] 비로그인 요청은 401을 반환한다. +- [ ] `/admin/ai-characters/**`에 대한 `CONTENT_MANAGER`, `CREATOR`, `AGENT`, `USER` 요청은 403을 반환한다. +- [ ] 기존 `/audio-content/upload-complete`의 `BOT` 또는 `ADMIN` 인가와 외부 계약이 변경되지 않는다. +- [ ] Backend는 v2 AI 캐릭터 관리자용 menu Endpoint를 추가하지 않는다. +- [ ] 클라이언트 메뉴 또는 route guard와 관계없이 Backend API 인가가 독립적으로 동작한다. + +### Character + +- [ ] 활성·비활성 상태 및 이름으로 AI 캐릭터를 조회할 수 있다. +- [ ] 캐릭터 등록 시 연결 AI `creatorMember`가 생성된다. +- [ ] 캐릭터 표시 정보 수정 시 연결 Member가 동기화된다. +- [ ] 캐릭터 삭제 시 캐릭터와 연결 Member가 비활성화되고 공개 리소스가 노출되지 않으며 어떤 데이터도 물리 삭제되지 않는다. +- [ ] 캐릭터 삭제 시 소유 콘텐츠 row의 raw `isActive`만 `false`로 전환되고 `releaseDate`, `content`, `duration`과 구매 이력은 보존된다. 기존 미삭제 콘텐츠는 계산 상태 `SUSPENDED`, 기존 삭제 콘텐츠는 `DELETED`로 반환된다. +- [ ] 삭제 캐릭터의 콘텐츠는 일반 사용자용 목록·검색·추천·크리에이터 채널과 미구매 상세에서 노출되지 않지만, 기존 `KEEP`·`RENTAL` 구매자는 주문 이력 기반 재생을 유지한다. +- [ ] content/creator ranking의 latest/previous snapshot 조회도 현재 콘텐츠·creator 활성 상태를 적용하고, 기존 snapshot row를 삭제·수정·재생성하지 않는다. +- [ ] snapshot 외 legacy creator ranking도 비활성 creator를 반환하지 않는다. +- [ ] 공개 캐릭터·콘텐츠·시리즈 banner는 비활성 연결 대상을 반환하지 않지만 기존 banner row와 관리자용 목록은 보존된다. +- [ ] 삭제 캐릭터 콘텐츠의 공개 댓글·답글 조회와 신규 등록·본문 수정·재활성화는 구매 여부와 관계없이 차단되고, 권한 있는 기존 댓글 논리 삭제만 허용된다. +- [ ] 삭제 캐릭터의 FanTalk 원문·답글 row와 관리자 조회·논리 삭제 기능은 보존되지만, 일반 사용자용 FanTalk 조회와 신규 FanTalk 원문 등록은 차단된다. +- [ ] cache를 미리 채운 뒤 캐릭터 삭제가 commit되면 `default`, `cache_ttl_3_days`, `popularCharacters_24h_locale`의 stale 공개 응답이 제거되고, rollback·반복 삭제에서는 cache clear가 발생하지 않는다. +- [ ] legacy 캐릭터 비활성화 경로도 같은 v2 삭제 cascade를 사용하고 비활성 캐릭터 재활성화는 거부한다. +- [ ] 비활성 캐릭터의 신규 발행·수정 작업은 409를 반환한다. + +### Original Work + +- [ ] `ORIGINAL-WORK-01`~`ORIGINAL-WORK-08`의 method와 path가 11.7과 정확히 일치한다. +- [ ] 원작 목록이 공통 페이징과 `search` Query를 사용하고 삭제 원작을 제외하며 별도 비페이징 `/search` Endpoint를 만들지 않는다. +- [ ] 원작 상세·등록·수정·논리 삭제와 연결 캐릭터 목록을 사용할 수 있다. +- [ ] 생성·수정 이미지의 실제 MIME을 검증하고 GIF를 거부하며 이미지 또는 DB 실패를 부분 성공으로 반환하지 않는다. +- [ ] 원작 수정은 nullable field의 명시적 `null`과 빈 목록 전체 삭제를 포함한 전체 교체 계약을 지킨다. +- [ ] 생성과 수정 모두 삭제되지 않은 동일 제목 충돌을 409로 반환하고, 동시 요청에서도 MySQL `SERIALIZABLE` transaction과 한 번의 재시도로 중복 활성 제목을 만들지 않는다. +- [ ] 신규 `CHAR-03`·`CHAR-04`의 양수 `originalWorkId`는 `isDeleted=false` 원작만 허용하고, `null`은 각각 미연결과 연결 해제를 의미하며 `0` sentinel을 허용하지 않는다. legacy 호환 adapter는 기존 등록의 `0`을 미연결로, 수정의 `0`만 해제로 변환한다. +- [ ] 원작 배정은 모든 캐릭터가 활성 AI 캐릭터인지 먼저 검증하고 다른 원작 연결을 새 원작으로 원자적으로 이동한다. +- [ ] 원작 해제는 활성·비활성 및 연결 creator 상태와 관계없이 기존 캐릭터를 허용하되 모든 캐릭터가 Path 원작에 실제 연결되어 있는지 먼저 검증한다. +- [ ] 배정·해제에서 누락 ID를 조용히 무시하거나 일부만 성공하지 않고, 잘못된 ID가 하나라도 있으면 전체를 rollback한다. +- [ ] 원작 삭제·배정·해제와 양수 캐릭터 원작 연결 변경은 원작/캐릭터 잠금 순서를 사용하고, 대상 원작 없는 해제는 캐릭터만 잠가 동시 요청에서도 삭제 원작 참조를 만들지 않는다. +- [ ] 활성·비활성 연결 캐릭터가 하나라도 남은 원작 삭제는 409이며 관계를 암묵적으로 해제하지 않는다. +- [ ] 원작 삭제는 `isDeleted=true`만 변경하고 링크·태그·이미지·번역 이력을 물리 삭제하지 않으며, 이미 삭제된 원작은 연결 수보다 먼저 판정해 반복 삭제가 멱등하다. +- [ ] 원작 생성 언어 감지와 `title`, `contentType`, `category`, `description`, `tags`가 실제 바뀐 수정의 번역 갱신은 commit 후 한 번만 실행되고 다른 field 변경·배정·해제에서는 실행되지 않는다. +- [ ] legacy `/admin/chat/original/**`와 legacy 캐릭터 등록·수정의 Method·Path·Request·성공 Response는 유지되고, 원작 mutation 5개와 캐릭터 mutation 2개 전체가 각각 같은 v2 원작·캐릭터 command로 수렴한다. +- [ ] 배포 전 `isDeleted=true` 원작을 참조하는 캐릭터가 0건임을 확인하며, 1건 이상이면 자동 migration 대신 별도 승인된 데이터 보정을 완료한 뒤 mutation 전환을 활성화한다. + +### Creator Operations + +- [ ] 선택 AI 캐릭터의 콘텐츠 CRUD와 고정 상태 변경이 가능하다. +- [ ] 선택 AI 캐릭터 명의로 콘텐츠 댓글·답글 CRUD가 가능하다. +- [ ] 선택 AI 캐릭터 소유 콘텐츠에 달린 타인의 댓글을 삭제할 수 있다. +- [ ] 선택 AI 캐릭터의 콘텐츠 카테고리 CRUD, 콘텐츠 구성 및 카테고리 순서 변경이 가능하다. +- [ ] 선택 AI 캐릭터의 시리즈 CRUD, 콘텐츠 구성 및 순서 변경이 가능하다. +- [ ] 선택 AI 캐릭터의 커뮤니티 게시글 CRUD와 고정 상태 변경이 가능하다. +- [ ] 선택 AI 캐릭터 명의로 커뮤니티 댓글·답글 CRUD가 가능하다. +- [ ] 선택 AI 캐릭터 소유 게시글에 달린 타인의 댓글을 삭제할 수 있다. +- [ ] 선택 AI 캐릭터의 FanTalk를 조회하고 AI 캐릭터 답글을 등록·수정·삭제할 수 있다. +- [ ] FanTalk 목록의 각 항목에 `replyId`가 있는 AI 캐릭터 답글이 함께 반환되고 별도 답글 조회 API는 없다. +- [ ] 선택 AI 캐릭터를 대상으로 한 FanTalk 원문을 소유자 권한으로 논리 삭제할 수 있다. +- [ ] 선택 AI 캐릭터의 채널 공지를 조회하고 upsert할 수 있다. +- [ ] 선택 AI 캐릭터의 채널 SNS URL, creator tag와 후원 랭킹 공개 설정을 조회·수정할 수 있다. +- [ ] 다른 캐릭터 소유 리소스는 조회·변경할 수 없다. +- [ ] 댓글과 FanTalk의 부모·루트 귀속을 우회할 수 없다. + +### API Contract + +- [ ] 모든 신규 Endpoint가 이 문서의 Path, Request, Response 계약을 따른다. +- [ ] 신규 관리자 Operation 66개가 route inventory에 중복·누락·추가 없이 존재한다. +- [ ] 등록·수정·삭제 응답이 변경된 resource ID와 상태를 반환한다. +- [ ] 페이징 대상 목록 API가 동일한 페이징 규칙을 사용하고, 콘텐츠 테마·시리즈 장르·creator tag 기준정보만 명시된 비페이징 예외로 동작한다. +- [ ] 신규 오류 응답이 정의된 HTTP status와 `ApiResponse` body를 사용한다. +- [ ] 실제 관리자와 대행 AI 캐릭터를 구분하는 구조화 로그가 남는다. +- [ ] v2 비즈니스 로직과 persistence adapter가 legacy Controller, Service, Repository 또는 web DTO를 호출하지 않는다. +- [ ] 신규 upload-complete Endpoint를 만들지 않고 기존 AWS S3 Trigger worker의 코드·스케줄·설정을 변경하지 않는다. +- [ ] 기존 `/audio-content/upload-complete`의 Method, Path, 인증과 Request/Response가 그대로 유지된다. +- [ ] v2 콘텐츠 생성이 기존 `content` row와 S3 key·metadata 계약을 따르고 별도 V1/V2 callback 분기 없이 기존 callback과 예약 공개 흐름으로 처리된다. +- [ ] CONTENT-03 원본 object가 `input/{contentId}/{contentId}-content-...` key를 사용하고 worker 결과 basename이 기존 callback의 content ID 검증을 통과한다. +- [ ] staging에서 v2 S3 input 저장부터 기존 callback 완료까지 E2E가 통과하고 DB commit 전 callback 도착 시 worker retry 동작이 확인된다. +- [ ] 이번 기능을 위한 DB 컬럼·인덱스·테이블·DDL·backfill·데이터 migration이나 기존 JPA mapping 변경을 추가하지 않고 v2 전용 예약 공개 scheduler도 만들지 않는다. +- [ ] 콘텐츠 생성은 기존 초기 필드에서 계산한 `PROCESSING`으로 응답하고 목록·상세의 `status`와 `isActive` 필터도 기존 필드와 creator 활성 상태에서 계산한다. +- [ ] `previewStartTime`, `previewEndTime`은 생성 시 S3 metadata로만 전달되고 콘텐츠 목록·상세 Response에는 포함되지 않는다. +- [ ] 콘텐츠 목록과 상세가 계산 상태 `SCHEDULED`, `PUBLISHED`이고 canonical output key와 duration이 있는 가공 완료 오디오에만 Signed URL을 반환한다. +- [ ] Signed URL의 TTL이 legacy와 같은 `(duration의 HH 부분 + 2)시간`이고 URL policy 만료 시각과 `contentUrlExpiresAtUtc`가 동일한 기준 시각에서 계산되어 일치한다. +- [ ] `coverImageUrl`은 일반 CDN 절대 URL이며 Signed URL이나 저장 경로가 아니다. +- [ ] 관리자 `CONTENT-01`, `CONTENT-02`에서 계산 상태가 `PROCESSING`, `SUSPENDED`, `DELETED`이거나 creator가 비활성이면 원본 업로드 경로나 오디오 URL이 노출되지 않는다. 기존 구매자의 소비자용 재생 예외는 이 관리자 응답 계약에 적용하지 않는다. +- [ ] 콘텐츠 목록과 상세 재조회 시 Signed URL이 갱신되고 별도 브라우저용 URL 갱신 Endpoint는 없다. +- [ ] Signed URL 생성 또는 output key 무결성 검증 실패 시 500으로 실패하고 raw path, 비서명 오디오 URL 또는 빈 문자열로 fallback하지 않는다. +- [ ] `CONTENT-01`, `CONTENT-02` 응답에 `Cache-Control: private, no-store`가 포함된다. +- [ ] 처리 중 삭제되어 `releaseDate=null`이 된 콘텐츠는 늦은 callback 이후에도 `isActive=false`를 유지하고 공개 side effect를 발생시키지 않는다. +- [ ] 비활성 creator의 콘텐츠는 늦은 callback 이후에도 raw `content.isActive=false`를 유지하며, 과거 불일치 row는 `false`로 보정되고 공개 side effect를 발생시키지 않는다. +- [ ] 기존 예약 공개 query는 활성 creator의 공개 시각이 지난 가공 완료 콘텐츠만 선택하며 scheduler component의 cron·lock은 변경하지 않는다. + +### Frontend Handoff + +다음 체크박스는 Backend 구현 완료 조건이 아니라 27장의 프론트엔드 개발 프롬프트와 함께 전달할 클라이언트 검증 기준이다. + +- [ ] `/ai-characters` 메뉴와 하위 route/tab을 typed static configuration으로 관리하고 legacy `GET /menu`를 호출하지 않는다. +- [ ] 로그인 응답의 `{token, role}`을 `sessionStorage`에서 함께 복원하고 `role=ADMIN`으로 화면 진입을 제어하되 이를 Backend API 인가의 대체 수단으로 취급하지 않는다. +- [ ] child resource 등록·수정 전에 사용자가 캐릭터를 명시적으로 선택하고 URL Path의 `characterId`를 source of truth로 사용한다. +- [ ] child resource form에서 캐릭터를 다시 선택하게 하거나 Request body에 `characterId`, `creatorId`, writer ID를 추가하지 않는다. +- [ ] 27.4의 기준에 따라 주요 목록·상세·등록·수정은 Page, 부모 문맥 안의 짧은 입력·선택·확인은 Dialog 또는 inline UI로 구현한다. +- [ ] 선택 AI 캐릭터가 작성한 댓글에만 수정 action을 표시하고 선택 AI 캐릭터 소유 부모의 댓글에는 writer와 관계없이 삭제 action을 제공한다. +- [ ] 브라우저용 `AUTH-01`과 신규 관리자 Operation 58개를 API client와 화면에 필요한 범위로 연결한다. +- [ ] 기존 27.8의 58개 baseline 구현은 유지하고, `frontend-original-work-prompt.md`의 원작 Operation 8개를 추가해 브라우저용 신규 관리자 Operation 66개를 연결한다. +- [ ] `contentUrl`을 영속 저장하지 않고 `contentUrlExpiresAtUtc` 기준으로 player 진입·만료 임박 시 `CONTENT-02`를 재조회하며 TTL을 재계산하거나 raw 업로드 경로를 조합하지 않는다. +- [ ] character-scoped query key와 캐릭터 목록·기준정보용 global query key를 분리한다. +- [ ] 공통 UI는 실제 반복 사용되는 단위로 component화하고 도메인별 validation과 form을 범용 CRUD 설정 하나로 합치지 않는다. +- [ ] 27.2의 선택 stack과 제외 목록을 지키고 lockfile, typecheck, ESLint, unit/UI test와 production build 검증을 통과한다. +- [ ] 현재 비어 있는 작업 디렉터리를 frontend project root로 사용하고 그 아래에 프로젝트 디렉터리를 다시 중첩 생성하지 않는다. +- [ ] Backend DTO/data class 이름을 전제로 하지 않고 27.8의 Operation별 실제 Request/Response JSON만으로 type, API client와 mock을 구현한다. +- [ ] `.env.development`와 `.env.production`에서 동일한 `VITE_API_BASE_URL` key에 서로 다른 dev·production API Base URL을 설정하고 source code에 두 URL을 하드코딩하지 않는다. +- [ ] `packageManager`를 `pnpm@11.15.0`으로 고정하고 Jenkins가 `pnpm install --frozen-lockfile`, `pnpm run ci:prod` 순서로 typecheck, lint, unit test와 production build를 검증해 `dist/`를 생성한다. +- [ ] mutation 성공과 일시적 오류는 Sonner, field/form 오류는 inline, 최초 조회 실패는 `ErrorState`, 파괴적 작업의 사전 확인은 `AlertDialog`로 분리하고 중복 알림이나 별도 notification center를 만들지 않는다. +- [ ] API의 절대 날짜·시간을 UTC `Z`로 보관·비교하고 `Asia/Seoul`로만 표시하며, KST 입력은 전송 직전에 UTC `Z`로 변환한다. duration과 preview offset은 timezone 변환하지 않는다. +- [ ] production host가 app route를 `index.html`로 rewrite하고 정적 asset과 절대 API Base URL 요청에는 SPA fallback을 적용하지 않는지 deep-link 새로고침으로 검증한다. +- [ ] 환경 책임자의 edge 접근 제어, HTML meta `noindex`, 응답 `X-Robots-Tag`를 적용하고 `robots.txt Disallow: /`로 noindex 확인을 차단하지 않는다. +- [ ] 승인된 production host/edge와 설정 책임자가 없으면 production 배포 완료로 판단하지 않는다. + +## 23. Metrics + +- 권한 없는 신규 API 접근 성공 건수: 0 +- 다른 AI 캐릭터 소유 리소스 변경 성공 건수: 0 +- 캐릭터 삭제로 인한 연관 리소스 물리 삭제 건수: 0 +- 원작 삭제로 인한 원작·링크·태그·이미지·캐릭터 관계 물리 삭제 건수: 0 +- 원작 배정·해제 Request의 부분 성공 건수: 0 +- 삭제된 원작을 새로 참조하는 캐릭터 관계 건수: 0 +- 삭제되지 않은 동일 제목 원작의 동시 생성 건수: 0 +- 신규 변경 API 구조화 audit log 누락 건수: 0 +- 신규 API에서 parent/root 귀속 검증 우회 성공 건수: 0 + +## 24. Decisions + +- 관리자 로그인은 기존 `POST /admin/member/login`을 재사용한다. +- AI 캐릭터 관리자 전용 로그인과 AI 캐릭터 사칭 토큰은 만들지 않는다. +- 1차 접근 권한은 `ROLE_ADMIN`으로 제한한다. +- v2 AI 캐릭터 관리자 메뉴는 클라이언트가 소유하며 legacy `GET /menu`를 재사용하지 않는다. +- 신규 menu Endpoint 또는 capability Endpoint는 이번 범위에 만들지 않는다. +- FanTalk 답글은 FanTalk 목록 응답에 포함하고 별도 답글 조회 Endpoint는 만들지 않는다. +- 신규 API는 v2 패키지에 두고 `/admin/ai-characters`를 base path로 사용한다. +- 원작 CRUD·검색·캐릭터 배정은 global `/admin/ai-characters/original-works` 아래 8개 Operation으로 v2에 이관하고 legacy `/admin/chat/original/**`는 호환을 위해 유지한다. +- legacy 원작 mutation 5개와 legacy 캐릭터 등록·수정 2개 전체는 기존 외부 계약과 patch 의미를 유지한 채 같은 v2 원작·캐릭터 command로 위임해 공존 중 정책 우회와 외부 작업 뒤 원작 연결 실패의 부분 성공을 막는다. legacy 원작 조회와 일반 사용자용 원작 조회는 기존 흐름을 유지한다. +- legacy 원작 목록과 검색은 `ORIGINAL-WORK-01`의 페이징 `search` Query로 합치고 별도 v2 `/search` Endpoint는 만들지 않는다. +- 원작 도메인은 `v2.originalwork`가 소유하고 관리자 HTTP 계층만 `v2.admin.aicharacter`에 둔다. +- 원작에 연결된 캐릭터가 하나라도 있으면 삭제를 409로 거부하고 명시적 해제를 요구한다. +- 이미 삭제된 원작의 반복 DELETE는 연결 수보다 먼저 판정해 멱등 성공한다. 배포 전 삭제 원작 연결 불일치가 1건 이상이면 별도 승인된 보정을 완료하기 전 mutation 전환을 활성화하지 않는다. +- 원작 배정은 다른 원작의 활성 AI 캐릭터를 새 원작으로 이동하며, 해제는 Path 원작 귀속을 전건 검증한다. 어느 작업도 부분 성공하지 않는다. +- 원작 관계 mutation은 양수 연결 대상 또는 원작 Path가 있으면 원작 row, 캐릭터 row 순서의 pessimistic lock을 사용하고, `CHAR-04`의 `null`과 legacy 수정의 `0` 해제는 캐릭터 row만 잠근다. 원작 생성과 제목이 실제 바뀌는 수정은 MySQL `SERIALIZABLE` transaction과 1회 재시도로 동시성 불변식을 보장한다. +- 신규 `CHAR-03`의 `originalWorkId=null`은 미연결, `CHAR-04`의 명시적 `null`은 해제이며 신규 계약은 legacy의 `0` sentinel을 사용하지 않는다. legacy 호환 adapter는 기존 등록의 `0`을 미연결로, 수정의 `0`만 해제로 변환한다. +- v2 Kotlin package와 HTTP URL version은 별개이므로 기존 v2 관리자 관례에 없는 `/admin/v2` 또는 `/v2/admin` prefix를 추가하지 않는다. +- 관리자 principal은 실제 `ADMIN`으로 유지하고 선택 캐릭터의 `creatorMember`만 도메인 작업 주체로 전달한다. +- legacy Controller, Service, Repository 및 Request/Response DTO를 신규 v2 비즈니스 로직에서 재사용하지 않는다. +- AWS S3 Trigger worker의 기존 `PUT /audio-content/upload-complete` 계약과 처리 흐름을 유지한다. v2 콘텐츠도 기존 row·S3 계약으로 처리하며 V1/V2 dispatcher 또는 v2 completion use case를 추가하지 않는다. +- 신규 upload-complete Endpoint는 실제 AWS 연동 전환 일정과 호출 주체가 확정될 때 별도 PRD에서 정의하며 이번 범위에는 선제 구현하지 않는다. +- 기존 오디오 가공 worker의 코드·스케줄·AWS Trigger 설정은 변경하지 않는다. +- v2 전용 예약 공개 scheduler를 만들지 않고 기존 scheduler component의 cron·lock을 유지한다. 삭제 콘텐츠와 비활성 creator를 공개하지 않는 callback·조회 조건만 최소 보강한다. +- 캐릭터 삭제 시 소유 콘텐츠의 기존 raw `isActive`만 `false`로 전환하고 나머지 콘텐츠 필드와 구매 이력을 보존한다. 일반 공개 탐색과 미구매 상세는 차단하고 기존 구매 재생은 유지한다. +- 콘텐츠·creator ranking snapshot은 현재 콘텐츠·creator 활성 상태를 visible query에 적용해 stale 노출만 차단하고 snapshot row는 보존한다. +- legacy creator ranking과 공개 캐릭터·콘텐츠·시리즈 banner도 연결 대상의 현재 활성 상태를 확인하며 원본 ranking/banner row는 보존한다. +- 삭제 성공 commit 후 영향받는 기존 cache namespace 3개만 clear하며 별도 범용 cache invalidation framework나 Redis key scan은 만들지 않는다. +- 필요한 command/query 기능은 콘텐츠, 댓글, 시리즈, 커뮤니티, FanTalk 등 v2 각 도메인 패키지에 구현한다. +- 기존 v2 domain/application/port는 계약이 맞는 경우 v2 내부에서 재사용하거나 확장한다. +- 기존 DB 스키마와 JPA 매핑은 변경하거나 복제하지 않고 v2 persistence adapter 뒤에서 연결한다. 이번 기능을 위한 DDL·backfill·데이터 migration은 만들지 않는다. +- 기존 `OriginalWork` 관련 entity와 `ChatCharacter.originalWork` 관계도 v2 persistence adapter에서 그대로 사용하며 원작용 schema·JPA mapping·data migration을 추가하지 않는다. +- 콘텐츠 상태는 기존 `isActive`, `releaseDate`, `duration`과 연결 creator 활성 상태로 계산하고, 저장된 `content` 경로는 Signed URL 발급 전 canonical output key인지 검증한다. +- 콘텐츠 생성의 `previewStartTime`, `previewEndTime`은 기존 worker용 S3 metadata로만 전달하고 DB나 목록·상세 Response에 저장하지 않는다. +- 공통 인증, `ApiResponse` 및 외부 인프라 client는 v2 port 또는 web adapter 경계에서 재사용한다. +- 콘텐츠 목록·상세의 가공 오디오는 legacy와 같은 TTL의 CloudFront Signed URL로 반환하고 커버 이미지는 비서명 CDN URL로 반환한다. +- Controller 간 호출과 내부 HTTP 호출은 금지한다. +- 독립 업무 리소스 삭제는 논리 삭제로 통일하고, 시리즈-콘텐츠 및 creator-Member-tag 연결 해제만 join row 물리 제거 예외로 둔다. +- legacy 캐릭터 비활성화 경로는 v2 삭제 cascade를 사용한다. 기존 비활성 AI 캐릭터 데이터의 선제 migration은 하지 않으며 실제 불일치가 확인되면 별도 승인 범위로 다룬다. +- 댓글·FanTalk의 타인 작성 본문 수정은 금지하고, 선택 캐릭터 소유 리소스에 달린 댓글의 삭제만 허용한다. +- 요구 목록에서 빠진 콘텐츠 고정, 콘텐츠 카테고리, 시리즈 구성·순서, 커뮤니티 고정, 기준정보 조회, FanTalk 원문 moderation, 채널 공지, 채널 프로필과 creator tag 설정을 이번 범위에 포함한다. +- 기존 27.8 Frontend 프롬프트는 적용된 baseline이므로 수정하지 않고, 원작 화면·선택 UI와 8개 JSON 계약은 별도 `frontend-original-work-prompt.md`로 추가한다. +- 라이브, 정산 및 시그니처 후원은 별도 제품 범위로 둔다. + +## 25. Open Questions + +- 제품·API·UX 미결정 사항은 없다. 요구사항이 바뀌면 구현 전에 이 PRD와 후속 `plan-task.md`를 먼저 갱신한다. +- dev·production API Base URL 실제 값과 production static host/edge 접근 제어 설정은 배포 전에 환경 책임자가 제공해야 한다. 값이 없으면 예시 URL로 배포하지 않고 production 배포를 차단 상태로 보고한다. + +## 26. Related Documents and Code Evidence + +### Documents + +- `AGENTS.md` +- `docs/agent-guides/작업절차.md` +- `docs/agent-guides/문서유지보수.md` +- `docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md` +- `docs/20260611_AI캐릭터_크리에이터기능_최소연결/prd.md` +- `docs/20260622_크리에이터_채널_FanTalk_탭_API/prd.md` +- `docs/20260709_팬톡_작성수정_응답보강/prd.md` +- `docs/20260706_커뮤니티_게시물_상세_API/prd.md` + +### Authentication and Menu + +- `src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberLoginController.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberLoginService.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuController.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuService.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuRepository.kt` + +### AI Character + +- `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterCreatorMemberService.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDto.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/service/AdminChatCharacterService.kt` + +### Original Work + +- `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkController.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/dto/OriginalWorkDtos.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWork.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkLink.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkTag.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkTagMapping.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/aws/s3/S3Uploader.kt` +- `src/main/resources/application.yml` +- `src/test/resources/application.yml` + +### Creator Operations + +- `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryService.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/category/CreatorAdminCategoryService.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentService.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityCommentRepository.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/ChannelNotice.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheers.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileUpdateRequest.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberTagController.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberTagService.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/member/tag/CreatorTag.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberCreatorTag.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageValidation.kt` +- `src/main/resources/application.yml` + +### v2 Admin URL, Existing AWS Callback, and Content Signed URL + +- `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobController.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobController.kt` +- `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobControllerTest.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentController.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentService.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentRepository.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/GetCreatorAdminContentListResponse.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/content/UploadCompleteRequest.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/scheduler/AudioContentReleaseScheduledTask.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/aws/s3/S3Uploader.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/AudioContentCloudFront.kt` +- `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt` + +## 27. Web Frontend Client Development Handoff + +### 27.1 Purpose and Scope + +이 장은 AI 캐릭터 관리자 웹 클라이언트를 구현할 때 사용할 기술·UX 결정과 복사 가능한 개발 프롬프트다. +27장 전체는 프론트엔드 전용 handoff appendix이며 Backend 구현 범위나 Backend 완료 조건이 아니다. +기준일은 2026-07-20이며, 구현을 시작할 때는 아래 major/minor 범위 안의 최신 보안 patch를 확인한 뒤 lockfile로 고정한다. + +- 브라우저가 호출하는 Operation은 기존 로그인 `AUTH-01`과 신규 관리자 Operation 58개다. +- 프론트엔드 에이전트는 Backend DTO/data class를 볼 수 없다고 가정한다. 27.8의 Operation별 실제 Request/Response JSON만으로 type, API client와 mock을 구현할 수 있어야 한다. +- Request/Response의 canonical contract는 9~18장이며, Operation ID로 화면·API 함수·테스트를 연결한다. 27.8 JSON과 본문 계약이 다르면 구현하지 않고 계약 불일치로 보고한다. +- 이미 생성되어 있는 빈 작업 디렉터리 자체를 frontend project root로 사용하며 하위에 프로젝트 디렉터리를 다시 만들지 않는다. +- production 배포 인프라는 25장의 외부 prerequisite다. 프론트엔드 구현자는 승인되지 않은 hosting 또는 edge 제품을 임의 도입하지 않는다. + +### 27.2 Selected Frontend Stack + +| Area | Decision | Reason | +|---|---|---| +| Build/CI tooling runtime | Node.js 24 LTS, pnpm 11.15.0, `packageManager`와 lockfile 고정 | local과 Jenkins가 같은 package manager version과 dependency graph를 사용함 | +| UI runtime | React 19.2 stable line | 현재 공식 안정 React major/minor | +| Build | Vite 8.1 stable line, React TypeScript template | 별도 Spring API를 호출하는 내부 SPA이며 SSR, RSC, BFF가 필요하지 않음 | +| Language | TypeScript 6.0 stable line, `strict=true` | TypeScript 7 생태계 전환 비용 없이 최신 안정 도구와 호환되는 기준 | +| Routing | React Router 8.2 Declarative Mode | TanStack Query가 data layer를 소유하므로 loader/action을 중복하지 않음 | +| Styling | Tailwind CSS 4.3 + `@tailwindcss/vite` | Vite 공식 통합 방식 | +| Components | shadcn/ui latest stable CLI + Base UI primitive | 신규 shadcn 프로젝트의 기본 primitive이며 필요한 component source만 가져옴 | +| UI feedback | shadcn/ui Sonner + inline field/form error + `ErrorState` | 성공·일시 오류, 입력 오류, 조회 오류를 서로 다른 수명과 위치로 표시함 | +| Server state | TanStack Query v5 | 조회, mutation, cache invalidation과 loading/error 상태를 관리함 | +| Tables | TanStack Table v8 | 서버 페이징 목록에만 `manualPagination`으로 사용함 | +| Forms | TanStack Form v1 + Zod 4 | 중첩 캐릭터 field array와 multipart form을 type-safe하게 관리함 | +| HTTP | Browser `fetch` 기반 얇은 wrapper | Axios와 별도 API framework 없이 Bearer, JSON, multipart, `ApiResponse`만 공통 처리함 | +| Date/time | Native `Date`, epoch millisecond, `Intl.DateTimeFormat` | API UTC instant를 유지하고 별도 date library 없이 KST 표시만 수행함 | +| Lint | Vite React TypeScript template의 ESLint + `typescript-eslint` | 별도 formatter/linter 경쟁 구성을 추가하지 않고 template 기준을 유지함 | +| Unit/UI test | Vitest 4.1 + `jsdom` + Testing Library + `user-event` + `jest-dom` | browser DOM 환경에서 사용자 동작 중심으로 검증함 | +| E2E | Playwright latest stable patch | 로그인, route guard, 캐릭터 scope, CRUD 핵심 흐름만 검증함 | + +Node.js와 `vite preview`는 production application server가 아니다. `vite build`의 `dist`를 25장에서 환경 책임자가 정한 static host/edge가 제공한다. + +다음은 이번 범위에서 사용하지 않는다. + +- Next.js: SSR, Server Component, Server Action 또는 BFF 요구가 없고 공개 검색 노출도 금지한다. +- Redux, Zustand: 서버 상태는 TanStack Query, 선택 캐릭터와 필터는 URL이 source of truth다. +- React Hook Form: 폼 라이브러리는 TanStack Form 하나로 통일한다. +- Axios: `fetch` wrapper로 필요한 계약을 충족한다. +- OpenAPI code generator: 실제 OpenAPI 문서가 발행되기 전에는 수기 PRD와 생성 결과가 어긋날 수 있다. +- 범용 CRUD engine: 도메인별 validation과 화면 차이를 거대한 설정 객체 하나로 숨기지 않는다. +- prerelease, beta, RC package: 운영 관리자 페이지에 사용하지 않는다. + +### 27.3 Menu, Character Selection, and Routes + +메뉴는 Backend에서 받지 않고 클라이언트의 typed static configuration으로 관리한다. + +```ts +type AdminMenuItem = { + key: string + label: string + to: string + scope: "GLOBAL" | "CHARACTER" +} +``` + +전역 메뉴는 `AI 캐릭터 관리 -> /ai-characters` 하나다. 캐릭터를 선택한 뒤에만 다음 character scope 메뉴를 표시한다. + +- 캐릭터 정보 +- 콘텐츠 +- 콘텐츠 카테고리 +- 시리즈 +- 커뮤니티 +- FanTalk +- 채널 설정 + +콘텐츠 댓글과 커뮤니티 댓글은 부모 리소스의 목록·상세 화면에서 진입하며 sidebar 최상위 메뉴로 만들지 않는다. + +등록·수정 화면의 캐릭터 선택 정책은 다음과 같다. + +- AI 캐릭터 자체 등록은 선택할 기존 캐릭터가 없으므로 `/ai-characters/new`에서 시작한다. +- 콘텐츠, 카테고리, 시리즈, 커뮤니티, FanTalk 답글 및 채널 설정은 먼저 캐릭터를 선택한 뒤 접근한다. +- 선택한 `characterId`는 전역 memory가 아니라 URL Path가 source of truth다. +- child resource Request body에 `characterId` 또는 `creatorId`를 추가하지 않는다. PRD의 Path parameter만 사용한다. +- deep link로 진입하면 URL의 `characterId`로 `CHAR-02`를 조회해 `CharacterContextBar`를 복원한다. +- 캐릭터를 임의로 자동 선택하지 않는다. 전환은 사용자의 명시적 선택으로만 수행한다. +- dirty form에서 캐릭터를 전환하거나 route를 이탈하면 확인한다. +- 비활성 캐릭터는 조회와 허용된 삭제만 제공하고 그 밖의 mutation control을 disabled 처리한다. Backend 409도 그대로 처리한다. + +권장 route는 다음과 같다. + +| Route | UI | +|---|---| +| `/login` | 관리자 로그인 | +| `/ai-characters` | 캐릭터 목록과 명시적 선택 | +| `/ai-characters/new` | 캐릭터 등록 Page | +| `/ai-characters/:characterId` | 캐릭터 상세 | +| `/ai-characters/:characterId/edit` | 캐릭터 수정 Page | +| `/ai-characters/:characterId/contents` | 콘텐츠 목록 | +| `/ai-characters/:characterId/contents/new` | 콘텐츠 등록 Page | +| `/ai-characters/:characterId/contents/:contentId` | 콘텐츠 상세와 Signed URL player | +| `/ai-characters/:characterId/contents/:contentId/edit` | 콘텐츠 수정 Page | +| `/ai-characters/:characterId/contents/:contentId/comments` | 콘텐츠 댓글 moderation | +| `/ai-characters/:characterId/content-categories` | 콘텐츠 카테고리 목록 | +| `/ai-characters/:characterId/content-categories/:categoryId/contents` | 카테고리 콘텐츠 구성 | +| `/ai-characters/:characterId/series` | 시리즈 목록 | +| `/ai-characters/:characterId/series/new` | 시리즈 등록 Page | +| `/ai-characters/:characterId/series/:seriesId` | 시리즈 상세와 콘텐츠 구성 | +| `/ai-characters/:characterId/series/:seriesId/edit` | 시리즈 수정 Page | +| `/ai-characters/:characterId/community-posts` | 커뮤니티 게시글 목록 | +| `/ai-characters/:characterId/community-posts/new` | 커뮤니티 게시글 등록 Page | +| `/ai-characters/:characterId/community-posts/:postId` | 게시글 상세와 댓글 진입 | +| `/ai-characters/:characterId/community-posts/:postId/edit` | 게시글 수정 Page | +| `/ai-characters/:characterId/community-posts/:postId/comments` | 커뮤니티 댓글 moderation | +| `/ai-characters/:characterId/fan-talks` | FanTalk 목록과 embedded 답글 | +| `/ai-characters/:characterId/channel-settings` | 공지·프로필·creator tag 설정 | + +BrowserRouter deep link와 새로고침을 위해 production static host/edge는 `/login`, `/ai-characters`, `/ai-characters/**`의 파일이 아닌 GET 요청을 `index.html`로 rewrite한다. +정적 asset 요청에는 SPA fallback을 적용하지 않는다. API 요청은 현재 Vite mode의 `VITE_API_BASE_URL` 절대 URL로 보내므로 frontend route rewrite 대상이 아니다. + +### 27.4 Page, Dialog, and Inline UI Rule + +URL 복원, 새로고침, 서버 페이징, 복잡한 validation 또는 감사 대상 문맥이 필요한 primary resource는 Page로 만든다. +부모 화면 안에서 끝나는 짧은 입력·선택·확인만 Dialog 또는 inline UI로 만든다. + +| Use case | UI | Reason | +|---|---|---| +| 캐릭터 목록·상세·등록·수정 | Page | 중첩 관계·성격·배경·기억과 이미지 form이 큼 | +| 콘텐츠 목록·상세·등록·수정 | Page | multipart, 계산 status, player, 다양한 validation이 있음 | +| 콘텐츠·커뮤니티 댓글 목록 | Page | 루트·답글 서버 페이징과 moderation 문맥이 필요함 | +| 콘텐츠·커뮤니티 댓글 작성·수정 | inline 또는 작은 Dialog | 짧은 본문 입력이며 부모 목록을 벗어날 필요가 없음 | +| 시리즈 목록·상세·등록·수정·구성 | Page | 콘텐츠 검색·구성과 순서 관리가 있음 | +| 커뮤니티 게시글 목록·상세·등록·수정 | Page | multipart와 댓글 진입 문맥이 있음 | +| FanTalk 목록 | Page | 답글이 목록 응답에 포함되고 root 문맥이 필요함 | +| FanTalk 답글 등록·수정 | inline 또는 작은 Dialog | 별도 조회 Endpoint 없이 선택 root 안에서 완료됨 | +| 콘텐츠 카테고리 목록 | Page | 서버 페이징과 순서 관리가 있음 | +| 카테고리 생성·이름 수정 | Dialog | 짧은 단일 작업 | +| 시리즈·카테고리 available content 선택 | Dialog | 부모 구성 작업을 위한 검색·다중 선택 | +| creator tag 선택 | Dialog 또는 Combobox | 채널 설정 form의 종속 선택 | +| 채널 공지·프로필 | 하나의 Page 안 section 또는 tab | 같은 캐릭터의 채널 설정 문맥을 공유함 | +| 삭제·비활성화·고정 해제 | AlertDialog | 파괴적 또는 노출 상태를 바꾸는 작업 | +| 캐릭터 전환 | Command/Dialog | 현재 character scope를 명시적으로 바꿈 | + +Dialog가 여러 tab, 중첩 form, browser history 또는 독립적인 서버 페이징 URL을 요구하기 시작하면 Page로 승격한다. + +댓글 row action은 권한 계약을 UI에도 반영한다. + +- 선택 AI 캐릭터가 작성한 댓글과 답글만 수정 action을 표시한다. +- 선택 AI 캐릭터 소유 콘텐츠·게시글에 달린 댓글은 writer와 관계없이 삭제 action을 표시할 수 있다. +- 다른 캐릭터 소유 부모의 댓글은 route에 진입시키지 않고, Backend의 동일 소유권 검증도 유지한다. + +### 27.5 Reusable Component Boundary + +아래 경로는 현재 비어 있는 작업 디렉터리를 그대로 사용하는 project root 기준이다. + +shadcn source component는 `src/components/ui`, 두 개 이상의 실제 화면에서 반복되는 조합은 `src/components/shared`, +도메인별 화면·schema·column은 `src/features/{domain}`에 둔다. + +최초 공통 component 후보는 다음으로 제한한다. + +- `AppShell` +- `AppSidebar` +- `CharacterContextBar` +- `PageHeader` +- `SearchFilterBar` +- `ServerDataTable` +- `ServerPagination` +- `StatusBadge` +- `EmptyState` +- `ErrorState` +- `AppToaster` +- `FormErrorSummary` +- `ConfirmDeleteDialog` +- `FormActions` +- `ImageUploadField` +- `UtcDateTime` + +두 번째 실제 사용처가 생기기 전에는 도메인 component를 공통 component로 승격하지 않는다. + +권장 feature 경계는 다음과 같다. + +```text +src/ + app/ + routes/ + components/ui/ + components/shared/ + features/auth/ + features/ai-characters/ + features/contents/ + features/content-comments/ + features/content-categories/ + features/series/ + features/community-posts/ + features/community-comments/ + features/fan-talks/ + features/channel-settings/ + lib/api/ + lib/query/ + lib/routes/ + test/ +``` + +### 27.6 Search Indexing Prohibition and Security Layers + +`robots.txt`나 `noindex`만으로 “절대 검색 노출 금지”를 보장할 수 없다. 접근 제어를 1차 경계로 두고 다음 책임을 분리한다. + +| Owner | Required Contract | +|---|---| +| 환경·플랫폼 책임자 | production HTML 앞에 조직 승인 IAP, Zero Trust, VPN, IP allowlist 또는 edge authentication을 적용하고 비인가 요청을 차단한다. 제품별 redirect 또는 401/403 동작은 배포 검증에 기록한다. | +| 환경·플랫폼 책임자 | HTML과 비인가 응답에 `X-Robots-Tag: noindex, nofollow, noarchive, nosnippet, noimageindex`, HTML에 `Cache-Control: no-store`를 적용한다. | +| 환경·플랫폼 책임자 | 27.3의 SPA rewrite를 구성하고 dev·production API Base URL 실제 값을 각 환경에 제공한다. 정적 asset에는 SPA fallback을 적용하지 않는다. | +| 프론트엔드 | 공통 `index.html`에 ``를 둔다. | +| 프론트엔드 | `AUTH-01`로 로그인하고 `role=ADMIN`만 route에 진입시킨다. route guard를 Backend `ROLE_ADMIN` 검사의 대체 수단으로 사용하지 않는다. | +| 프론트엔드 | sitemap, prerender, SSR, 공개 marketing page를 만들지 않고 `VITE_*`에는 API base URL 같은 공개 설정만 둔다. JWT, API key, private key 또는 다른 secret을 build-time 변수에 넣지 않는다. | + +`robots.txt`에 `Disallow: /`를 두면 crawler가 meta 또는 HTTP `noindex`를 읽지 못해 URL만 검색 결과에 남을 수 있으므로 이 방식은 사용하지 않는다. +`robots.txt`는 보안 경계가 아니며 생략하거나 noindex 확인을 막지 않는 형태로만 제공한다. + +25장의 승인된 production host/edge와 설정 책임자가 제공되지 않으면 프론트엔드 에이전트는 production 인프라를 임의 선택하지 않는다. +local production build와 설정 요구사항 문서까지만 만들고 배포는 차단 상태로 보고한다. + +### 27.7 Frontend Runtime, Build, Feedback, Date, and API Rules + +#### 27.7.1 Environment and API Base URL + +Vite mode별로 같은 key에 다른 API Base URL을 주입한다. 아래 host는 형식 설명용 예시이며 실제 배포 값이 아니다. + +`.env.development` + +```dotenv +VITE_API_BASE_URL=https://dev-api.example.com +``` + +`.env.production` + +```dotenv +VITE_API_BASE_URL=https://api.example.com +``` + +- source code에 dev·production URL을 동시에 하드코딩하거나 runtime host를 보고 추측하지 않는다. +- `ImportMetaEnv`를 선언해 `VITE_API_BASE_URL`을 필수 string으로 취급한다. +- 앱 bootstrap에서 값의 존재, `http:` 또는 `https:` 절대 URL 여부를 검증하고 trailing slash는 한 곳에서만 제거한다. 누락·예시 값·잘못된 URL이면 시작 또는 build를 실패시킨다. +- 모든 API URL은 검증된 Base URL과 이 PRD의 `/admin/...` Path를 결합해 만든다. +- `pnpm dev`는 development mode, `pnpm run build:prod`는 production mode를 사용한다. +- `VITE_*` 값은 browser bundle에 노출되므로 API Base URL 같은 공개 설정만 넣고 secret, JWT 또는 API key를 넣지 않는다. +- API가 cross-origin이면 허용 origin, method, header와 credential 정책은 환경·Backend 책임자가 명시적으로 구성한다. 프론트엔드는 Vite proxy나 same-origin reverse proxy가 있다고 가정하지 않는다. + +#### 27.7.2 Package Scripts and Jenkins Build + +`package.json`에 `"packageManager": "pnpm@11.15.0"`을 선언하고 `pnpm-lock.yaml`을 commit한다. script의 canonical contract는 다음과 같다. + +```json +{ + "scripts": { + "dev": "vite --mode development", + "typecheck": "tsc -b --pretty false", + "lint": "eslint . --max-warnings=0", + "test:run": "vitest run", + "build:dev": "vite build --mode development", + "build:prod": "vite build --mode production", + "ci:prod": "pnpm run typecheck && pnpm run lint && pnpm run test:run && pnpm run build:prod" + }, + "packageManager": "pnpm@11.15.0" +} +``` + +Jenkins의 install·검증·build 명령은 다음 순서를 기준으로 한다. + +```sh +npm install --global corepack@latest +corepack enable +corepack prepare pnpm@11.15.0 --activate +pnpm --version +pnpm install --frozen-lockfile +pnpm run ci:prod +``` + +- `pnpm --version` 결과가 `11.15.0`이 아니면 pipeline을 실패시킨다. +- production `VITE_API_BASE_URL`은 승인된 Jenkins environment 또는 workspace의 `.env.production`으로 제공한다. 값이 없거나 `example.com`이면 build를 실패시킨다. +- `pnpm run ci:prod`가 성공한 뒤 생성된 `dist/`만 정적 배포 artifact로 보관한다. +- `vite preview`는 production server로 사용하지 않는다. + +#### 27.7.3 In-Page Feedback and Error Display + +이 관리자 페이지의 “내부 알림”은 별도 알림함이나 실시간 알림 시스템이 아니라 현재 사용자 작업 결과를 알려 주는 UI feedback이다. + +- root에 Sonner `Toaster`를 하나만 두고 `position="top-right"`, `richColors`, `closeButton`, 기본 표시 시간 4초를 적용한다. +- mutation 성공과 짧게 확인하면 되는 background 오류는 Sonner에 표시한다. 성공 toast는 HTTP 성공 응답 뒤에만 표시하고 optimistic success toast는 사용하지 않는다. +- `errorProperty`가 특정 field를 가리키면 해당 field 아래 inline error에 연결한다. field에 귀속되지 않는 form 오류는 `FormErrorSummary`에 표시하며 같은 오류를 toast로 중복 표시하지 않는다. +- 최초 목록·상세 조회 실패는 해당 content 영역의 `ErrorState`와 재시도 action으로 표시한다. +- background refetch 또는 field에 귀속되지 않는 mutation 실패만 중복을 제거한 error toast로 표시한다. +- 401은 session을 정리하고 `/login`으로 이동한 뒤 “세션이 만료되었습니다”를 한 번 표시한다. 403은 권한 없음 Page를 표시한다. +- 삭제·비활성화·고정 해제는 호출 전에 `AlertDialog`로 확인한다. 처리 결과는 toast 또는 inline error로 표시하며 AlertDialog를 결과 알림으로 재사용하지 않는다. +- 이번 범위에는 notification center, 읽음 상태, WebSocket/SSE, push 알림 또는 알림 영속 저장을 추가하지 않는다. + +#### 27.7.4 UTC Storage and KST Display + +- Request와 Response의 절대 날짜·시간은 ISO-8601 UTC `Z` 문자열을 사용한다. 필드 이름은 `createdAtUtc`, `updatedAtUtc`, `releaseAtUtc`, `contentUrlExpiresAtUtc`처럼 `*AtUtc`를 사용한다. +- API 원문, query cache와 비교 로직은 UTC 문자열 또는 epoch millisecond를 유지한다. browser·운영체제 timezone을 저장 기준으로 사용하지 않는다. +- 화면 표시는 `Intl.DateTimeFormat("ko-KR", { timeZone: "Asia/Seoul", ... })`로 KST 변환하고 날짜·시간을 표시하는 곳에 `KST`를 명시한다. +- 목록은 분 단위, 상세·tooltip은 초 단위로 표시하고 `hourCycle: "h23"`을 사용한다. 상대 시간만 단독으로 표시하지 않는다. +- `datetime-local` 입력값은 KST wall-clock으로 해석하고 전송 직전에 명시적 `+09:00` instant로 만든 뒤 `toISOString()`의 UTC `Z` 값으로 변환한다. 입력 문자열 뒤에 `Z`만 붙이지 않는다. +- 공통 utility는 최소한 `parseUtcInstant`, `formatUtcInKst`, `kstInputToUtcIso`, `utcIsoToKstInput`으로 제한하고 invalid 또는 UTC `Z`가 아닌 API instant는 계약 오류로 처리한다. +- nullable 날짜는 조회 화면에서 `-`, form에서 빈 값으로 표시한다. +- `duration`, `previewStartTime`, `previewEndTime` 같은 `HH:mm:ss` offset은 절대 시각이 아니므로 timezone 변환하지 않는다. +- 자정·연말·월말 경계, nullable 값, invalid 값과 KST 입력→UTC→KST round trip을 unit test로 검증한다. + +#### 27.7.5 API Client and Cache + +- 27.8의 실제 JSON에서 공통 응답 envelope와 page shape를 TypeScript generic으로 추출할 수 있지만 Backend DTO/data class 이름에 의존하지 않는다. +- `AUTH-01` 성공 시 `{ token, role }`만 `sessionStorage`에 보관하고 새로고침 시 함께 복원한다. `localStorage`, URL, log 또는 build-time 환경변수에는 저장하지 않는다. +- 모든 관리자 API에 `Authorization: Bearer `을 추가한다. 복원된 `role`이 `ADMIN`이 아니거나 값이 손상되면 session을 지우고 로그인으로 이동한다. +- Query parameter는 `URLSearchParams`로 만들고 null, undefined와 빈 검색어는 보내지 않는다. +- JSON mutation에는 `Content-Type: application/json`을 명시한다. +- multipart는 API별 정확한 file part 이름을 지키고 `request` part에는 27.8에 표시된 Request JSON을 `JSON.stringify`한 문자열을 넣는다. 브라우저가 boundary를 만들게 하므로 multipart 전체 `Content-Type`을 직접 지정하지 않는다. +- child resource body에 `characterId`, `creatorId`, writer ID를 추가하지 않는다. +- character-scoped query key는 `["ai-character", characterId, domain, ...]`로 시작하고 캐릭터 전환 시 이전 캐릭터 mutation을 재사용하지 않는다. +- `CHAR-01`과 content theme, series genre, creator tag metadata는 `characterId`가 없는 별도 global key factory를 사용한다. +- 목록은 응답의 `page`, `size`, `totalCount`, `hasNext`를 사용하고 TanStack Table은 `manualPagination=true`로 둔다. +- mutation 성공 후 해당 character와 parent resource 범위의 query만 invalidate한다. +- `CONTENT-01`, `CONTENT-02`의 `contentUrl`과 전체 응답을 persistent storage에 보관하지 않는다. +- player 진입 시 `CONTENT-02`를 조회한다. duration으로 TTL을 재계산하지 않고 `contentUrlExpiresAtUtc`만 기준으로 만료 또는 만료 임박 여부를 판단해 상세를 한 번 재조회한다. +- `contentUrl=null`이면 player를 숨기고 API가 반환한 계산 status를 표시한다. raw path나 preview path를 조합하지 않는다. + +### 27.8 Copy-Paste Frontend Development Prompt + +아래 블록은 이 PRD 전체와 함께 프론트엔드 구현 에이전트에 제공한다. 프론트엔드 구현자는 별도 구현 타입 정보 없이 +문서의 HTTP 계약만 사용한다. 각 Operation의 Request JSON과 Response JSON은 클라이언트 type·API client·mock을 +만들 수 있는 self-contained 예시이며, validation과 권한의 최종 기준은 9~20장이다. + +```text +당신은 운영용 AI 캐릭터 관리자 웹 클라이언트를 구현한다. + +[작업 위치] +- 현재 비어 있는 작업 디렉터리 자체를 project root로 사용하고 하위에 별도 프로젝트 디렉터리를 만들지 않는다. +- 이 프롬프트만으로 독립 실행 가능한 frontend project를 만들고 문서에 없는 외부 구현 타입을 찾거나 전제하지 않는다. +- production host/edge, 접근 제어 제품과 설정 책임자는 환경 책임자가 제공한다. 값이 없으면 local production + build와 배포 요구사항 문서까지만 만들고 production 배포를 완료했다고 말하지 않는다. + +[목표] +- 기존 POST /admin/member/login으로 로그인한 ADMIN만 접근하는 SPA를 만든다. +- 관리자가 캐릭터를 명시적으로 선택한 뒤 해당 characterId scope에서 콘텐츠, 댓글, 카테고리, + 시리즈, 커뮤니티, FanTalk, 채널 설정을 관리하게 한다. +- 아래 HTTP Endpoint와 JSON 계약을 임의 변경하지 않는다. +- legacy GET /menu를 호출하지 않고 메뉴를 클라이언트 typed static config로 제공한다. + +[기술 스택] +- Node.js 24 LTS, pnpm 11.15.0, packageManager와 pnpm-lock.yaml 고정 +- React 19.2 stable, Vite 8.1 stable, TypeScript 6.0 strict +- React Router 8.2 Declarative Mode +- Tailwind CSS 4.3, @tailwindcss/vite +- shadcn/ui latest stable CLI, Base UI primitive +- shadcn/ui Sonner, inline field/form error, 조회 ErrorState +- TanStack Query v5, TanStack Table v8, TanStack Form v1, Zod 4 +- fetch wrapper +- Vite React TypeScript template의 ESLint와 typescript-eslint +- Vitest 4.1, jsdom, Testing Library, user-event, jest-dom, Playwright +- beta, RC, prerelease package를 사용하지 않는다. +- Next.js, Redux, Zustand, React Hook Form, Axios, OpenAPI generator를 추가하지 않는다. +- Node나 vite preview를 production server로 사용하지 않는다. vite build의 dist는 승인된 static host/edge가 제공한다. + +[환경별 API Base URL] +- .env.development: VITE_API_BASE_URL=https://dev-api.example.com +- .env.production: VITE_API_BASE_URL=https://api.example.com +- 위 URL은 예시다. 실제 dev·production 값이 제공되기 전에는 배포하지 않는다. +- 같은 VITE_API_BASE_URL key를 Vite mode별로 다르게 설정하고 source code에 두 URL을 하드코딩하지 않는다. +- 값을 필수 typed env로 선언하고 누락, example.com, http/https가 아닌 값이면 시작 또는 build를 실패시킨다. +- API URL은 검증된 Base URL과 아래 절대 Path를 결합한다. Vite proxy나 same-origin reverse proxy를 가정하지 않는다. +- VITE_*에는 browser에 노출해도 되는 값만 넣고 secret, JWT와 API key를 넣지 않는다. + +[package.json scripts와 Jenkins] +- package.json에 "packageManager": "pnpm@11.15.0"을 기록한다. +- scripts는 다음 명령을 정확히 제공한다. + dev = vite --mode development + typecheck = tsc -b --pretty false + lint = eslint . --max-warnings=0 + test:run = vitest run + build:dev = vite build --mode development + build:prod = vite build --mode production + ci:prod = pnpm run typecheck && pnpm run lint && pnpm run test:run && pnpm run build:prod +- Jenkins는 Node.js 24 LTS agent에서 다음 순서로 실행한다. + npm install --global corepack@latest + corepack enable + corepack prepare pnpm@11.15.0 --activate + pnpm --version + pnpm install --frozen-lockfile + pnpm run ci:prod +- pnpm --version이 11.15.0이 아니면 실패한다. 성공 후 dist/만 배포 artifact로 보관한다. +- production VITE_API_BASE_URL이 없거나 example.com이면 Jenkins build를 실패시킨다. + +[공통 API 계약] +- 각 Operation의 Response JSON 전체가 실제 envelope 예시다. 문서에 없는 Response type이나 class를 추측하지 않는다. +- 성공 envelope key는 success, message, data, errorProperty다. +- 목록 data key는 items, page, size, totalCount, hasNext다. +- 공통 오류 Response JSON: + {"success":false,"message":"요청을 처리할 수 없습니다.","data":null,"errorProperty":"characterId"} +- Bearer JWT를 사용한다. +- 로그인 성공의 {token, role}만 sessionStorage에 함께 저장·복원하고 localStorage, URL, log에는 저장하지 않는다. +- 401 또는 ADMIN이 아닌 복원 role은 sessionStorage 삭제 후 /login, 403은 권한 없음, + 400/409는 errorProperty를 form 오류에 연결한다. +- Path의 characterId가 작업 대상이다. body에 creatorId, characterId, writerId를 추가하지 않는다. +- multipart의 request part는 각 Operation에 표시된 Request JSON을 JSON.stringify한 문자열이다. + 브라우저가 boundary를 생성하도록 multipart 전체 Content-Type을 직접 지정하지 않는다. +- character-scoped query key는 ["ai-character", characterId, domain, ...]를 사용한다. +- CHAR-01, content theme, series genre, creator tag는 characterId가 없는 global query key를 사용한다. + +[날짜·시간] +- 모든 *AtUtc 절대 시각 Request/Response는 ISO-8601 UTC Z 문자열이다. +- API 원문, query cache와 비교는 UTC 문자열 또는 epoch millisecond를 유지하고 표시에만 Asia/Seoul을 적용한다. +- Intl.DateTimeFormat("ko-KR", { timeZone: "Asia/Seoul", ... })을 사용하고 화면에 KST를 표시한다. +- 목록은 분 단위, 상세와 tooltip은 초 단위, hourCycle은 h23으로 표시하며 상대 시간만 단독 표시하지 않는다. +- datetime-local 입력은 KST로 해석해 명시적 +09:00 instant로 만든 뒤 toISOString() UTC Z 값으로 전송한다. + 입력 문자열에 Z만 붙이지 않는다. +- parseUtcInstant, formatUtcInKst, kstInputToUtcIso, utcIsoToKstInput 공통 utility를 만들고 경계값을 test한다. +- nullable 날짜는 조회에서 -, form에서 빈 값으로 표시한다. +- duration, previewStartTime, previewEndTime은 HH:mm:ss offset이므로 timezone 변환하지 않는다. + +[화면 내부 알림] +- root에 Sonner Toaster를 하나만 두고 top-right, richColors, closeButton, 기본 4초로 표시한다. +- mutation 성공은 HTTP 성공 응답 뒤 Sonner success toast로 표시한다. +- errorProperty가 가리키는 field 오류는 field 아래, 나머지 form 오류는 FormErrorSummary에 표시하고 toast와 중복하지 않는다. +- 최초 조회 실패는 content 영역의 ErrorState와 재시도 action으로 표시한다. +- background refetch 또는 field에 귀속되지 않는 mutation 오류만 중복 제거한 Sonner error toast로 표시한다. +- 401은 session을 지우고 /login으로 이동한 뒤 세션 만료 toast를 한 번 표시한다. 403은 권한 없음 Page다. +- AlertDialog는 삭제·비활성화·고정 해제 호출 전 확인에만 사용한다. +- notification center, 읽음 상태, WebSocket/SSE, push 또는 알림 영속 저장은 만들지 않는다. + +[Operation Catalog: Authentication and Character] +AUTH-01 POST /admin/member/login + Request JSON: + {"email":"admin@example.com","password":"password"} + Response JSON: + {"success":true,"message":null,"data":{"token":"","role":"ADMIN"},"errorProperty":null} + +CHAR-01 GET /admin/ai-characters + Path: 없음 + Query: page=0&size=20&search=루나&isActive=true + Request JSON: 없음 + Response JSON: + { + "success": true, + "message": null, + "data": { + "items": [{ + "characterId": 101, + "creatorId": 10001, + "name": "루나", + "imageUrl": "https://cdn.example.com/characters/101.webp", + "description": "달빛 라디오 DJ", + "gender": "FEMALE", + "age": 24, + "mbti": "INFP", + "region": "KR", + "tags": ["라디오", "힐링"], + "isActive": true, + "createdAtUtc": "2026-07-20T09:00:00Z", + "updatedAtUtc": "2026-07-20T10:00:00Z" + }], + "page": 0, + "size": 20, + "totalCount": 1, + "hasNext": false + }, + "errorProperty": null + } + +CHAR-02 GET /admin/ai-characters/{characterId} + Path: characterId=101 + Query: 없음 + Request JSON: 없음 + Response JSON: + { + "success": true, + "message": null, + "data": { + "characterId": 101, + "creatorId": 10001, + "characterUuid": "018f4dc0-9c49-7d15-9f73-3f573e52a871", + "name": "루나", + "imageUrl": "https://cdn.example.com/characters/101.webp", + "description": "달빛 라디오 DJ", + "systemPrompt": "차분하고 다정한 말투로 대화한다.", + "characterType": "Character", + "age": 24, + "gender": "FEMALE", + "mbti": "INFP", + "speechPattern": "문장 끝을 부드럽게 맺는다.", + "speechStyle": "차분함", + "appearance": "은빛 단발", + "region": "KR", + "isActive": true, + "tags": ["라디오", "힐링"], + "hobbies": ["음악 감상"], + "values": ["공감"], + "goals": ["팬의 편안한 밤 돕기"], + "relationships": [{ + "personName": "별이", + "relationshipName": "친구", + "description": "오랜 친구", + "importance": 5, + "relationshipType": "FRIEND", + "currentStatus": "CLOSE" + }], + "personalities": [{"trait":"다정함","description":"상대의 감정을 먼저 살핀다."}], + "backgrounds": [{"topic":"직업","description":"심야 라디오 DJ다."}], + "memories": [{"title":"첫 방송","content":"첫 생방송을 성공했다.","emotion":"기쁨"}], + "originalWork": {"id":71,"imageUrl":"https://cdn.example.com/works/71.webp","title":"달빛 방송국"}, + "createdAtUtc": "2026-07-20T09:00:00Z", + "updatedAtUtc": "2026-07-20T10:00:00Z" + }, + "errorProperty": null + } + +CHAR-03 POST /admin/ai-characters + Parts: image=, request= + request Part JSON: + { + "name": "루나", + "systemPrompt": "차분하고 다정한 말투로 대화한다.", + "description": "달빛 라디오 DJ", + "age": 24, + "gender": "FEMALE", + "mbti": "INFP", + "speechPattern": "문장 끝을 부드럽게 맺는다.", + "speechStyle": "차분함", + "appearance": "은빛 단발", + "region": "KR", + "originalWorkId": 71, + "characterType": "Character", + "tags": ["라디오", "힐링"], + "hobbies": ["음악 감상"], + "values": ["공감"], + "goals": ["팬의 편안한 밤 돕기"], + "relationships": [{ + "personName": "별이", + "relationshipName": "친구", + "description": "오랜 친구", + "importance": 5, + "relationshipType": "FRIEND", + "currentStatus": "CLOSE" + }], + "personalities": [{"trait":"다정함","description":"상대의 감정을 먼저 살핀다."}], + "backgrounds": [{"topic":"직업","description":"심야 라디오 DJ다."}], + "memories": [{"title":"첫 방송","content":"첫 생방송을 성공했다.","emotion":"기쁨"}] + } + Response JSON: + {"success":true,"message":null,"data":{"characterId":101,"creatorId":10001,"isActive":true},"errorProperty":null} + +CHAR-04 PUT /admin/ai-characters/{characterId} + Path: characterId=101 + Parts: image=, request= + request Part JSON: + { + "name": "루나", + "systemPrompt": "차분하고 다정한 말투로 대화한다.", + "description": "달빛 라디오 DJ", + "age": 25, + "gender": "FEMALE", + "mbti": "INFP", + "speechPattern": "문장 끝을 부드럽게 맺는다.", + "speechStyle": "차분함", + "appearance": "은빛 단발", + "originalWorkId": 71, + "characterType": "Character", + "tags": ["라디오", "힐링"], + "hobbies": ["음악 감상"], + "values": ["공감"], + "goals": ["팬의 편안한 밤 돕기"], + "relationships": [], + "personalities": [], + "backgrounds": [], + "memories": [] + } + Response JSON: + {"success":true,"message":null,"data":{"characterId":101,"creatorId":10001,"isActive":true},"errorProperty":null} + +CHAR-05 DELETE /admin/ai-characters/{characterId} + Path: characterId=101 + Query: 없음 + Request JSON: 없음 + Response JSON: + {"success":true,"message":null,"data":{"characterId":101,"creatorId":10001,"characterIsActive":false,"creatorIsActive":false},"errorProperty":null} + +[Operation Catalog: Content] +CONTENT-01 GET /admin/ai-characters/{characterId}/contents + Path: characterId=101 + Query: page=0&size=20&search=비&isActive=true&status=PUBLISHED + Request JSON: 없음 + Response JSON: + { + "success": true, + "message": null, + "data": { + "items": [{ + "contentId": 2001, + "title": "비 오는 밤", + "detail": "수면을 위한 빗소리", + "coverImageUrl": "https://cdn.example.com/audio_content_cover/2001/cover.webp", + "contentUrl": "https://audio.example.com/output/2001/audio.m4a?Expires=...", + "contentUrlExpiresAtUtc": "2026-07-20T14:00:00Z", + "themeId": 1, + "theme": "ASMR", + "price": 0, + "purchaseOption": "BOTH", + "limited": null, + "totalContentCount": null, + "remainingContentCount": null, + "isAdult": false, + "isActive": true, + "isPointAvailable": false, + "isCommentAvailable": true, + "isGeneratePreview": false, + "isOnlyRental": false, + "isFullDetailVisible": true, + "languageCode": "ko", + "isPinned": false, + "status": "PUBLISHED", + "duration": "00:10:30", + "releaseAtUtc": "2026-07-20T12:00:00Z", + "tags": ["수면", "빗소리"], + "createdAtUtc": "2026-07-20T11:00:00Z", + "updatedAtUtc": "2026-07-20T12:00:00Z" + }], + "page": 0, + "size": 20, + "totalCount": 1, + "hasNext": false + }, + "errorProperty": null + } + +CONTENT-02 GET /admin/ai-characters/{characterId}/contents/{contentId} + Path: characterId=101, contentId=2001 + Query: 없음 + Request JSON: 없음 + Response JSON: + { + "success": true, + "message": null, + "data": { + "contentId": 2001, + "title": "비 오는 밤", + "detail": "수면을 위한 빗소리", + "coverImageUrl": "https://cdn.example.com/audio_content_cover/2001/cover.webp", + "contentUrl": "https://audio.example.com/output/2001/audio.m4a?Expires=...", + "contentUrlExpiresAtUtc": "2026-07-20T14:00:00Z", + "themeId": 1, + "theme": "ASMR", + "price": 0, + "purchaseOption": "BOTH", + "limited": null, + "totalContentCount": null, + "remainingContentCount": null, + "isAdult": false, + "isActive": true, + "isPointAvailable": false, + "isCommentAvailable": true, + "isGeneratePreview": false, + "isOnlyRental": false, + "isFullDetailVisible": true, + "languageCode": "ko", + "isPinned": false, + "status": "PUBLISHED", + "duration": "00:10:30", + "releaseAtUtc": "2026-07-20T12:00:00Z", + "tags": ["수면", "빗소리"], + "createdAtUtc": "2026-07-20T11:00:00Z", + "updatedAtUtc": "2026-07-20T12:00:00Z" + }, + "errorProperty": null + } + +CONTENT-03 POST /admin/ai-characters/{characterId}/contents + Path: characterId=101 + Parts: contentFile=, coverImage=, request= + request Part JSON: + { + "title": "비 오는 밤", + "detail": "수면을 위한 빗소리", + "tags": ["수면", "빗소리"], + "price": 0, + "purchaseOption": "BOTH", + "limited": null, + "releaseAtUtc": "2026-07-20T12:00:00Z", + "themeId": 1, + "isAdult": false, + "isGeneratePreview": false, + "isOnlyRental": false, + "isPointAvailable": false, + "isCommentAvailable": true, + "isFullDetailVisible": true, + "previewStartTime": null, + "previewEndTime": null, + "languageCode": "ko" + } + Response JSON: + {"success":true,"message":null,"data":{"contentId":2001,"isActive":false,"status":"PROCESSING"},"errorProperty":null} + +CONTENT-04 PUT /admin/ai-characters/{characterId}/contents/{contentId} + Path: characterId=101, contentId=2001 + Parts: coverImage=, request= + request Part JSON: + {"title":"비 오는 밤","detail":"수면을 위한 빗소리","tags":["수면","빗소리"],"price":0,"isAdult":false,"isPointAvailable":false,"isCommentAvailable":true} + Response JSON: + {"success":true,"message":null,"data":{"id":2001,"isActive":true},"errorProperty":null} + +CONTENT-05 DELETE /admin/ai-characters/{characterId}/contents/{contentId} + Path: characterId=101, contentId=2001 + Query: 없음 + Request JSON: 없음 + Response JSON: + {"success":true,"message":null,"data":{"id":2001,"isActive":false},"errorProperty":null} + +CONTENT-06 PUT /admin/ai-characters/{characterId}/contents/{contentId}/pin + Path: characterId=101, contentId=2001 + Request JSON: + {"isPinned":true} + Response JSON: + {"success":true,"message":null,"data":{"contentId":2001,"isPinned":true,"replacedContentId":null},"errorProperty":null} + +CONTENT-07 GET /admin/ai-characters/metadata/content-themes + Path, Query, Request JSON: 없음 + Response JSON: + {"success":true,"message":null,"data":[{"themeId":1,"name":"ASMR","imageUrl":"https://cdn.example.com/themes/1.webp"}],"errorProperty":null} + +[Operation Catalog: Content Comment] +CONTENT-COMMENT-01 GET /admin/ai-characters/{characterId}/contents/{contentId}/comments + Path: characterId=101, contentId=2001 + Query: page=0&size=20&isActive=true + Request JSON: 없음 + Response JSON: + { + "success": true, + "message": null, + "data": { + "items": [{ + "commentId": 2101, + "parentCommentId": null, + "writerId": 9001, + "writerNickname": "팬A", + "writerProfileImageUrl": null, + "content": "잘 들었습니다.", + "languageCode": "ko", + "donationCan": 0, + "isSecret": false, + "isActive": true, + "replyCount": 1, + "createdAtUtc": "2026-07-20T12:10:00Z", + "updatedAtUtc": null + }], + "page": 0, + "size": 20, + "totalCount": 1, + "hasNext": false + }, + "errorProperty": null + } + +CONTENT-COMMENT-02 GET /admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId}/replies + Path: characterId=101, contentId=2001, commentId=2101 + Query: page=0&size=20&isActive=true + Request JSON: 없음 + Response JSON: + { + "success": true, + "message": null, + "data": { + "items": [{ + "commentId": 2102, + "parentCommentId": 2101, + "writerId": 10001, + "writerNickname": "루나", + "writerProfileImageUrl": "https://cdn.example.com/characters/101.webp", + "content": "들어주셔서 고마워요.", + "languageCode": "ko", + "donationCan": 0, + "isSecret": false, + "isActive": true, + "replyCount": 0, + "createdAtUtc": "2026-07-20T12:20:00Z", + "updatedAtUtc": null + }], + "page": 0, + "size": 20, + "totalCount": 1, + "hasNext": false + }, + "errorProperty": null + } + +CONTENT-COMMENT-03 POST /admin/ai-characters/{characterId}/contents/{contentId}/comments + Path: characterId=101, contentId=2001 + Request JSON: + {"content":"들어주셔서 고마워요.","parentCommentId":2101,"isSecret":false,"languageCode":"ko"} + Response JSON: + {"success":true,"message":null,"data":{"id":2102,"isActive":true},"errorProperty":null} + +CONTENT-COMMENT-04 PUT /admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId} + Path: characterId=101, contentId=2001, commentId=2102 + Request JSON: + {"content":"정말 고마워요."} + Response JSON: + {"success":true,"message":null,"data":{"id":2102,"isActive":true},"errorProperty":null} + +CONTENT-COMMENT-05 DELETE /admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId} + Path: characterId=101, contentId=2001, commentId=2101 + Query: 없음 + Request JSON: 없음 + Response JSON: + {"success":true,"message":null,"data":{"id":2101,"isActive":false},"errorProperty":null} + +[Operation Catalog: Series] +SERIES-01 GET /admin/ai-characters/{characterId}/series + Path: characterId=101 + Query: page=0&size=20&search=밤&isActive=true&state=PROCEEDING + Request JSON: 없음 + Response JSON: + { + "success": true, + "message": null, + "data": { + "items": [{ + "seriesId": 3001, + "title": "루나의 밤", + "introduction": "매주 금요일 밤의 이야기", + "coverImageUrl": "https://cdn.example.com/series/3001.webp", + "publishedDaysOfWeek": ["FRI"], + "genreId": 3, + "isAdult": false, + "state": "PROCEEDING", + "isActive": true, + "writer": "루나", + "studio": null + }], + "page": 0, + "size": 20, + "totalCount": 1, + "hasNext": false + }, + "errorProperty": null + } + +SERIES-02 GET /admin/ai-characters/{characterId}/series/{seriesId} + Path: characterId=101, seriesId=3001 + Query: 없음 + Request JSON: 없음 + Response JSON: + { + "success": true, + "message": null, + "data": { + "seriesId": 3001, + "title": "루나의 밤", + "introduction": "매주 금요일 밤의 이야기", + "coverImageUrl": "https://cdn.example.com/series/3001.webp", + "publishedDaysOfWeek": ["FRI"], + "genreId": 3, + "isAdult": false, + "state": "PROCEEDING", + "isActive": true, + "writer": "루나", + "studio": null, + "genre": "힐링", + "keyword": "수면,라디오", + "createdAtUtc": "2026-07-20T09:00:00Z", + "updatedAtUtc": "2026-07-20T10:00:00Z" + }, + "errorProperty": null + } + +SERIES-03 POST /admin/ai-characters/{characterId}/series + Path: characterId=101 + Parts: image=, request= + request Part JSON: + {"title":"루나의 밤","introduction":"매주 금요일 밤의 이야기","publishedDaysOfWeek":["FRI"],"keyword":"수면,라디오","genreId":3,"isAdult":false,"writer":"루나","studio":null} + Response JSON: + {"success":true,"message":null,"data":{"id":3001,"isActive":true},"errorProperty":null} + +SERIES-04 PUT /admin/ai-characters/{characterId}/series/{seriesId} + Path: characterId=101, seriesId=3001 + Parts: image=, request= + request Part JSON: + {"title":"루나의 밤","introduction":"매주 금요일 밤의 이야기","keyword":"수면,라디오","publishedDaysOfWeek":["FRI"],"genreId":3,"isAdult":false,"state":"PROCEEDING","writer":"루나","studio":null} + Response JSON: + {"success":true,"message":null,"data":{"id":3001,"isActive":true},"errorProperty":null} + +SERIES-05 DELETE /admin/ai-characters/{characterId}/series/{seriesId} + Path: characterId=101, seriesId=3001 + Query: 없음 + Request JSON: 없음 + Response JSON: + {"success":true,"message":null,"data":{"id":3001,"isActive":false},"errorProperty":null} + +SERIES-06 GET /admin/ai-characters/{characterId}/series/{seriesId}/contents + Path: characterId=101, seriesId=3001 + Query: page=0&size=20 + Request JSON: 없음 + Response JSON: + {"success":true,"message":null,"data":{"items":[{"contentId":2001,"title":"비 오는 밤","coverImageUrl":"https://cdn.example.com/audio_content_cover/2001/cover.webp","isActive":true,"order":0}],"page":0,"size":20,"totalCount":1,"hasNext":false},"errorProperty":null} + +SERIES-07 GET /admin/ai-characters/{characterId}/series/{seriesId}/available-contents + Path: characterId=101, seriesId=3001 + Query: page=0&size=20&search=파도 + Request JSON: 없음 + Response JSON: + {"success":true,"message":null,"data":{"items":[{"contentId":2002,"title":"잔잔한 파도","coverImageUrl":null,"isActive":true,"order":null}],"page":0,"size":20,"totalCount":1,"hasNext":false},"errorProperty":null} + +SERIES-08 POST /admin/ai-characters/{characterId}/series/{seriesId}/contents + Path: characterId=101, seriesId=3001 + Request JSON: + {"contentIds":[2002]} + Response JSON: + {"success":true,"message":null,"data":{"seriesId":3001,"affectedContentIds":[2002]},"errorProperty":null} + +SERIES-09 DELETE /admin/ai-characters/{characterId}/series/{seriesId}/contents/{contentId} + Path: characterId=101, seriesId=3001, contentId=2001 + Query: 없음 + Request JSON: 없음 + Response JSON: + {"success":true,"message":null,"data":{"seriesId":3001,"affectedContentIds":[2001]},"errorProperty":null} + +SERIES-10 PUT /admin/ai-characters/{characterId}/series/orders + Path: characterId=101 + Request JSON: + {"seriesIds":[3003,3001,3002]} + Response JSON: + {"success":true,"message":null,"data":{"seriesIds":[3003,3001,3002]},"errorProperty":null} + +SERIES-11 GET /admin/ai-characters/metadata/series-genres + Path, Query, Request JSON: 없음 + Response JSON: + {"success":true,"message":null,"data":[{"genreId":3,"name":"힐링"}],"errorProperty":null} + +[Operation Catalog: Community Post and Comment] +COMMUNITY-POST-01 GET /admin/ai-characters/{characterId}/community-posts + Path: characterId=101 + Query: page=0&size=20&isActive=true + Request JSON: 없음 + Response JSON: + { + "success": true, + "message": null, + "data": { + "items": [{ + "postId": 4001, + "creatorId": 10001, + "creatorNickname": "루나", + "creatorProfileImageUrl": "https://cdn.example.com/characters/101.webp", + "imageUrl": "https://cdn.example.com/community/4001.webp", + "audioUrl": null, + "content": "오늘 밤 10시에 만나요.", + "price": 0, + "isCommentAvailable": true, + "isAdult": false, + "isFixed": true, + "isActive": true, + "likeCount": 12, + "commentCount": 3, + "createdAtUtc": "2026-07-20T08:00:00Z", + "updatedAtUtc": null + }], + "page": 0, + "size": 20, + "totalCount": 1, + "hasNext": false + }, + "errorProperty": null + } + +COMMUNITY-POST-02 GET /admin/ai-characters/{characterId}/community-posts/{postId} + Path: characterId=101, postId=4001 + Query: 없음 + Request JSON: 없음 + Response JSON: + {"success":true,"message":null,"data":{"postId":4001,"creatorId":10001,"creatorNickname":"루나","creatorProfileImageUrl":"https://cdn.example.com/characters/101.webp","imageUrl":"https://cdn.example.com/community/4001.webp","audioUrl":null,"content":"오늘 밤 10시에 만나요.","price":0,"isCommentAvailable":true,"isAdult":false,"isFixed":true,"isActive":true,"likeCount":12,"commentCount":3,"createdAtUtc":"2026-07-20T08:00:00Z","updatedAtUtc":null},"errorProperty":null} + +COMMUNITY-POST-03 POST /admin/ai-characters/{characterId}/community-posts + Path: characterId=101 + Parts: audioFile=, postImage=, request= + request Part JSON: + {"content":"오늘 밤 10시에 만나요.","isCommentAvailable":true,"isAdult":false,"price":0} + Response JSON: + {"success":true,"message":null,"data":{"id":4001,"isActive":true},"errorProperty":null} + +COMMUNITY-POST-04 PUT /admin/ai-characters/{characterId}/community-posts/{postId} + Path: characterId=101, postId=4001 + Parts: postImage=, request= + request Part JSON: + {"content":"오늘 밤 10시에 꼭 만나요.","isCommentAvailable":true,"isAdult":false} + Response JSON: + {"success":true,"message":null,"data":{"id":4001,"isActive":true},"errorProperty":null} + +COMMUNITY-POST-05 DELETE /admin/ai-characters/{characterId}/community-posts/{postId} + Path: characterId=101, postId=4001 + Query: 없음 + Request JSON: 없음 + Response JSON: + {"success":true,"message":null,"data":{"id":4001,"isActive":false},"errorProperty":null} + +COMMUNITY-POST-06 PUT /admin/ai-characters/{characterId}/community-posts/{postId}/fixed + Path: characterId=101, postId=4001 + Request JSON: + {"isFixed":true} + Response JSON: + {"success":true,"message":null,"data":{"postId":4001,"isFixed":true},"errorProperty":null} + +COMMUNITY-COMMENT-01 GET /admin/ai-characters/{characterId}/community-posts/{postId}/comments + Path: characterId=101, postId=4001 + Query: page=0&size=20&isActive=true + Request JSON: 없음 + Response JSON: + { + "success": true, + "message": null, + "data": { + "items": [{ + "commentId": 4101, + "parentCommentId": null, + "writerId": 9001, + "writerNickname": "팬A", + "writerProfileImageUrl": null, + "content": "기대할게요.", + "isSecret": false, + "isActive": true, + "replyCount": 1, + "createdAtUtc": "2026-07-20T08:10:00Z", + "updatedAtUtc": null + }], + "page": 0, + "size": 20, + "totalCount": 1, + "hasNext": false + }, + "errorProperty": null + } + +COMMUNITY-COMMENT-02 GET /admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}/replies + Path: characterId=101, postId=4001, commentId=4101 + Query: page=0&size=20&isActive=true + Request JSON: 없음 + Response JSON: + { + "success": true, + "message": null, + "data": { + "items": [{ + "commentId": 4102, + "parentCommentId": 4101, + "writerId": 10001, + "writerNickname": "루나", + "writerProfileImageUrl": "https://cdn.example.com/characters/101.webp", + "content": "조금 뒤에 만나요.", + "isSecret": false, + "isActive": true, + "replyCount": 0, + "createdAtUtc": "2026-07-20T08:20:00Z", + "updatedAtUtc": null + }], + "page": 0, + "size": 20, + "totalCount": 1, + "hasNext": false + }, + "errorProperty": null + } + +COMMUNITY-COMMENT-03 POST /admin/ai-characters/{characterId}/community-posts/{postId}/comments + Path: characterId=101, postId=4001 + Request JSON: + {"content":"조금 뒤에 만나요.","parentCommentId":4101,"isSecret":false} + Response JSON: + {"success":true,"message":null,"data":{"id":4102,"isActive":true},"errorProperty":null} + +COMMUNITY-COMMENT-04 PUT /admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId} + Path: characterId=101, postId=4001, commentId=4102 + Request JSON: + {"content":"곧 만나요."} + Response JSON: + {"success":true,"message":null,"data":{"id":4102,"isActive":true},"errorProperty":null} + +COMMUNITY-COMMENT-05 DELETE /admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId} + Path: characterId=101, postId=4001, commentId=4101 + Query: 없음 + Request JSON: 없음 + Response JSON: + {"success":true,"message":null,"data":{"id":4101,"isActive":false},"errorProperty":null} + +[Operation Catalog: FanTalk] +FAN-TALK-01 GET /admin/ai-characters/{characterId}/fan-talks + Path: characterId=101 + Query: page=0&size=20 + Request JSON: 없음 + Response JSON: + { + "success": true, + "message": null, + "data": { + "items": [{ + "fanTalkId": 5001, + "writerId": 9001, + "writerNickname": "팬A", + "writerProfileImageUrl": "https://cdn.example.com/default-profile.webp", + "content": "오늘도 힘내세요.", + "createdAtUtc": "2026-07-20T07:00:00Z", + "creatorReplies": [{ + "replyId": 5002, + "fanTalkId": 5001, + "writerId": 10001, + "writerNickname": "루나", + "writerProfileImageUrl": "https://cdn.example.com/characters/101.webp", + "content": "응원 고마워요.", + "isActive": true, + "createdAtUtc": "2026-07-20T07:10:00Z", + "updatedAtUtc": null + }] + }], + "page": 0, + "size": 20, + "totalCount": 1, + "hasNext": false + }, + "errorProperty": null + } + +FAN-TALK-02 POST /admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies + Path: characterId=101, fanTalkId=5001 + Request JSON: + {"content":"응원 고마워요.","languageCode":"ko"} + Response JSON: + {"success":true,"message":null,"data":{"replyId":5002,"fanTalkId":5001,"writerId":10001,"writerNickname":"루나","writerProfileImageUrl":"https://cdn.example.com/characters/101.webp","content":"응원 고마워요.","isActive":true,"createdAtUtc":"2026-07-20T07:10:00Z","updatedAtUtc":null},"errorProperty":null} + +FAN-TALK-03 PUT /admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId} + Path: characterId=101, fanTalkId=5001, replyId=5002 + Request JSON: + {"content":"늘 응원해 줘서 고마워요."} + Response JSON: + {"success":true,"message":null,"data":{"replyId":5002,"fanTalkId":5001,"writerId":10001,"writerNickname":"루나","writerProfileImageUrl":"https://cdn.example.com/characters/101.webp","content":"늘 응원해 줘서 고마워요.","isActive":true,"createdAtUtc":"2026-07-20T07:10:00Z","updatedAtUtc":"2026-07-20T07:20:00Z"},"errorProperty":null} + +FAN-TALK-04 DELETE /admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId} + Path: characterId=101, fanTalkId=5001, replyId=5002 + Query: 없음 + Request JSON: 없음 + Response JSON: + {"success":true,"message":null,"data":{"id":5002,"isActive":false},"errorProperty":null} + +FAN-TALK-05 DELETE /admin/ai-characters/{characterId}/fan-talks/{fanTalkId} + Path: characterId=101, fanTalkId=5001 + Query: 없음 + Request JSON: 없음 + Response JSON: + {"success":true,"message":null,"data":{"id":5001,"isActive":false},"errorProperty":null} + +- 답글 조회 API를 추가하지 않는다. FAN-TALK-01 Response JSON의 creatorReplies를 사용한다. + +[Operation Catalog: Content Category and Channel Settings] +CATEGORY-01 GET /admin/ai-characters/{characterId}/content-categories + Path: characterId=101 + Query: page=0&size=20&isActive=true + Request JSON: 없음 + Response JSON: + {"success":true,"message":null,"data":{"items":[{"categoryId":6001,"title":"ASMR","order":0,"contentCount":2,"isActive":true}],"page":0,"size":20,"totalCount":1,"hasNext":false},"errorProperty":null} + +CATEGORY-02 POST /admin/ai-characters/{characterId}/content-categories + Path: characterId=101 + Request JSON: + {"title":"ASMR","contentIds":[2001,2002]} + Response JSON: + {"success":true,"message":null,"data":{"categoryId":6001,"isActive":true},"errorProperty":null} + +CATEGORY-03 PUT /admin/ai-characters/{characterId}/content-categories/{categoryId} + Path: characterId=101, categoryId=6001 + Request JSON: + {"title":"수면 ASMR"} + Response JSON: + {"success":true,"message":null,"data":{"categoryId":6001,"isActive":true},"errorProperty":null} + +CATEGORY-04 DELETE /admin/ai-characters/{characterId}/content-categories/{categoryId} + Path: characterId=101, categoryId=6001 + Query: 없음 + Request JSON: 없음 + Response JSON: + {"success":true,"message":null,"data":{"categoryId":6001,"isActive":false},"errorProperty":null} + +CATEGORY-05 PUT /admin/ai-characters/{characterId}/content-categories/orders + Path: characterId=101 + Request JSON: + {"categoryIds":[6003,6001,6002]} + Response JSON: + {"success":true,"message":null,"data":{"categoryIds":[6003,6001,6002]},"errorProperty":null} + +CATEGORY-06 GET /admin/ai-characters/{characterId}/content-categories/{categoryId}/contents + Path: characterId=101, categoryId=6001 + Query: page=0&size=20 + Request JSON: 없음 + Response JSON: + {"success":true,"message":null,"data":{"items":[{"contentId":2001,"title":"비 오는 밤","coverImageUrl":"https://cdn.example.com/audio_content_cover/2001/cover.webp","isActive":true}],"page":0,"size":20,"totalCount":1,"hasNext":false},"errorProperty":null} + +CATEGORY-07 GET /admin/ai-characters/{characterId}/content-categories/{categoryId}/available-contents + Path: characterId=101, categoryId=6001 + Query: page=0&size=20&search=파도 + Request JSON: 없음 + Response JSON: + {"success":true,"message":null,"data":{"items":[{"contentId":2002,"title":"잔잔한 파도","coverImageUrl":null,"isActive":true}],"page":0,"size":20,"totalCount":1,"hasNext":false},"errorProperty":null} + +CATEGORY-08 POST /admin/ai-characters/{characterId}/content-categories/{categoryId}/contents + Path: characterId=101, categoryId=6001 + Request JSON: + {"contentIds":[2002]} + Response JSON: + {"success":true,"message":null,"data":{"categoryId":6001,"affectedContentIds":[2002]},"errorProperty":null} + +CATEGORY-09 DELETE /admin/ai-characters/{characterId}/content-categories/{categoryId}/contents/{contentId} + Path: characterId=101, categoryId=6001, contentId=2001 + Query: 없음 + Request JSON: 없음 + Response JSON: + {"success":true,"message":null,"data":{"categoryId":6001,"affectedContentIds":[2001]},"errorProperty":null} + +NOTICE-01 GET /admin/ai-characters/{characterId}/channel-notice + Path: characterId=101 + Query: 없음 + Request JSON: 없음 + Response JSON: + {"success":true,"message":null,"data":{"characterId":101,"creatorId":10001,"notice":"새 콘텐츠는 매주 금요일 공개됩니다.","updatedAtUtc":"2026-07-20T10:00:00Z"},"errorProperty":null} + +NOTICE-02 PUT /admin/ai-characters/{characterId}/channel-notice + Path: characterId=101 + Request JSON: + {"notice":"새 콘텐츠는 매주 금요일 공개됩니다."} + Response JSON: + {"success":true,"message":null,"data":{"characterId":101,"creatorId":10001,"notice":"새 콘텐츠는 매주 금요일 공개됩니다.","updatedAtUtc":"2026-07-20T10:00:00Z"},"errorProperty":null} + +CREATOR-TAG-01 GET /admin/ai-characters/metadata/creator-tags + Path, Query, Request JSON: 없음 + Response JSON: + {"success":true,"message":null,"data":[{"tagId":11,"name":"ASMR","imageUrl":"https://cdn.example.com/creator-tags/11.webp","isAdult":false}],"errorProperty":null} + +CHANNEL-PROFILE-01 GET /admin/ai-characters/{characterId}/channel-profile + Path: characterId=101 + Query: 없음 + Request JSON: 없음 + Response JSON: + { + "success": true, + "message": null, + "data": { + "characterId": 101, + "creatorId": 10001, + "instagramUrl": "https://instagram.com/example", + "fancimmUrl": "", + "xUrl": "", + "youtubeUrl": "https://youtube.com/@example", + "kakaoOpenChatUrl": "", + "creatorTags": [{ + "tagId": 11, + "name": "ASMR", + "imageUrl": "https://cdn.example.com/creator-tags/11.webp", + "isAdult": false + }], + "isVisibleDonationRank": true, + "donationRankingPeriod": "CUMULATIVE", + "updatedAtUtc": "2026-07-20T10:00:00Z" + }, + "errorProperty": null + } + +CHANNEL-PROFILE-02 PUT /admin/ai-characters/{characterId}/channel-profile + Path: characterId=101 + Request JSON: + {"instagramUrl":"https://instagram.com/example","fancimmUrl":"","xUrl":"","youtubeUrl":"https://youtube.com/@example","kakaoOpenChatUrl":"","tagIds":[11,14],"isVisibleDonationRank":true,"donationRankingPeriod":"CUMULATIVE"} + Response JSON: + { + "success": true, + "message": null, + "data": { + "characterId": 101, + "creatorId": 10001, + "instagramUrl": "https://instagram.com/example", + "fancimmUrl": "", + "xUrl": "", + "youtubeUrl": "https://youtube.com/@example", + "kakaoOpenChatUrl": "", + "creatorTags": [ + {"tagId":11,"name":"ASMR","imageUrl":"https://cdn.example.com/creator-tags/11.webp","isAdult":false}, + {"tagId":14,"name":"힐링","imageUrl":null,"isAdult":false} + ], + "isVisibleDonationRank": true, + "donationRankingPeriod": "CUMULATIVE", + "updatedAtUtc": "2026-07-20T10:05:00Z" + }, + "errorProperty": null + } + +[Signed URL] +- CONTENT-01과 CONTENT-02 Response JSON의 contentUrl은 가공 완료된 전체 오디오 CloudFront Signed URL이다. +- coverImageUrl은 Signed URL이 아닌 일반 CDN 절대 URL이다. +- 기존 DB 필드와 creator 활성 상태에서 계산한 status가 SCHEDULED/PUBLISHED이고 canonical output/{contentId}/... key와 duration이 있을 때만 URL이 있다. +- 계산 status가 PROCESSING/SUSPENDED/DELETED이면 contentUrl과 contentUrlExpiresAtUtc는 null이다. +- URL TTL은 (duration HH + 2)시간이며 URL policy 만료와 contentUrlExpiresAtUtc가 일치한다. +- 클라이언트는 duration으로 TTL을 계산하지 않고 contentUrlExpiresAtUtc만 사용한다. +- CONTENT-01/02는 응답마다 새 URL을 반환하며 API response Cache-Control은 private, no-store다. +- URL과 응답을 persistent storage에 저장하지 않는다. +- 만료 또는 만료 임박 시 CONTENT-02를 한 번 재조회한다. +- raw input path, DB path, preview path를 조합하거나 서명 실패 시 fallback하지 않는다. + +[Menu and Route] +- 메뉴는 클라이언트 typed static config다. GET /menu를 호출하지 않는다. +- /ai-characters에서 캐릭터를 먼저 명시적으로 선택한다. +- 선택 characterId는 /ai-characters/:characterId/** URL Path가 source of truth다. +- 상단 CharacterContextBar에 avatar, name, characterId, active status, 전환 action을 표시한다. +- child resource 등록·수정 화면 안에서 다시 캐릭터를 선택하게 하지 않는다. +- 캐릭터 자체 등록만 /ai-characters/new에서 selection 없이 수행한다. +- production host는 /login과 /ai-characters/**의 파일이 아닌 GET을 index.html로 rewrite한다. +- 정적 asset과 VITE_API_BASE_URL로 보내는 API 요청에는 SPA fallback을 적용하지 않는다. + +[Page vs Dialog] +- 캐릭터, 콘텐츠, 시리즈, 커뮤니티의 목록·상세·등록·수정은 Page다. +- 댓글 moderation, FanTalk 목록, 카테고리 구성은 Page다. +- 짧은 댓글·답글 입력, 카테고리 이름, available content 선택, creator tag 선택은 Dialog 또는 inline이다. +- 삭제·비활성화·고정 해제는 AlertDialog다. +- 선택 AI 캐릭터가 작성한 댓글에만 edit action을 표시한다. +- 선택 AI 캐릭터 소유 콘텐츠·게시글의 댓글은 writer와 관계없이 delete action을 표시할 수 있다. +- Dialog에 독립 URL, 여러 tab, 중첩 form 또는 복잡한 서버 페이징이 필요해지면 Page로 바꾼다. + +[Component Reuse] +- shadcn 원시는 components/ui에 둔다. +- 두 개 이상의 실제 화면에서 반복되는 조합만 components/shared로 올린다. +- AppShell, AppSidebar, CharacterContextBar, PageHeader, SearchFilterBar, ServerDataTable, + ServerPagination, StatusBadge, EmptyState, ErrorState, AppToaster, FormErrorSummary, + ConfirmDeleteDialog, FormActions, ImageUploadField, UtcDateTime을 최초 공통 후보로 한다. +- 도메인별 schema, column, form은 features/{domain}에 유지한다. +- 범용 CRUD engine을 만들지 않는다. + +[검색 노출 금지와 보안] +- 환경 책임자가 production HTML 앞에 조직 승인 edge 접근 제어를 적용한다. +- 승인된 host/edge와 설정 책임자가 없으면 임의 선택하거나 production 배포 완료로 판단하지 않는다. +- index.html에 noindex,nofollow,noarchive,nosnippet,noimageindex meta를 둔다. +- 환경 책임자는 HTML과 비인가 응답에 같은 X-Robots-Tag를, HTML에 Cache-Control: no-store를 적용한다. +- robots.txt Disallow: /는 crawler가 noindex를 읽지 못하게 할 수 있으므로 사용하지 않는다. +- robots.txt는 생략하거나 noindex 확인을 막지 않는 형태로만 제공하며 보안 경계로 간주하지 않는다. +- sitemap, SSR, prerender, 공개 marketing page를 만들지 않는다. +- VITE_* 환경변수에 secret이나 JWT를 넣지 않는다. +- 클라이언트 role guard는 화면 진입 UX를 위한 것이며 HTTP 401/403 처리를 생략하는 근거가 아니다. + +[테스트] +- AUTH-01 성공/실패, token과 role의 sessionStorage 복원, ADMIN 외 role 차단, 401 session 정리, 403 화면을 검증한다. +- 캐릭터를 자동 선택하지 않는지, 선택 후 URL과 CharacterContextBar가 일치하는지 검증한다. +- deep link 새로고침에서 SPA rewrite 후 CHAR-02로 context가 복원되고 API/asset path가 index.html로 rewrite되지 않는지 검증한다. +- development와 production mode가 서로 다른 VITE_API_BASE_URL을 사용하고 누락·예시 값이면 실패하는지 검증한다. +- child mutation body에 creatorId/characterId/writerId가 들어가지 않는지 검증한다. +- JSON과 multipart part 이름 및 Query 직렬화를 Operation별로 검증한다. +- 서버 페이징, 빈 상태, 오류 상태, mutation 후 좁은 query invalidation을 검증한다. +- global query key와 character-scoped query key가 섞이지 않는지 검증한다. +- 타인 댓글에는 edit가 없고 선택 캐릭터 소유 부모의 댓글에는 delete가 표시되는지 검증한다. +- FanTalk 답글을 별도 GET 없이 creatorReplies로 렌더링하는지 검증한다. +- Signed URL null 상태, contentUrlExpiresAtUtc 기준 만료 임박 상세 재조회, TTL 미재계산, raw path 미사용을 검증한다. +- destructive action은 AlertDialog 확인 전 호출되지 않는지 검증한다. +- mutation success toast, inline field/form 오류, 조회 ErrorState, 401 session 만료 toast와 중복 알림 방지를 검증한다. +- UTC Z 검증, KST 표시, KST 입력의 UTC 변환, 자정·월말·연말 경계와 HH:mm:ss 미변환을 검증한다. +- meta robots, restrictive robots.txt 미사용, X-Robots-Tag와 환경별 edge 비인가 차단 동작을 배포 검증에 포함한다. +- Jenkins와 같은 pnpm install --frozen-lockfile 및 pnpm run ci:prod가 통과하고 dist/를 생성해야 완료다. +- 로그인, character scope와 주요 CRUD의 핵심 Playwright E2E가 통과해야 완료다. + +[완료 산출물] +- 실행 가능한 Vite SPA +- typed route/menu config +- Operation ID 기준 typed API module +- 공통 layout/table/pagination/form/error component +- 각 도메인 Page/Dialog +- unit/UI test와 핵심 E2E +- README에 실행 방법, dev·production 환경변수, Jenkins 명령, UTC/KST 규칙, + 화면 feedback 규칙, production 접근 제어 및 noindex 검증 방법 +- 구현하지 않은 API나 화면이 있으면 숨기지 말고 Operation ID와 이유를 명시한다. +``` + +### 27.9 Official Frontend References + +- [React versions](https://react.dev/versions) +- [Vite 8.1 release](https://vite.dev/blog/announcing-vite8-1) +- [Vite env files and modes](https://vite.dev/guide/env-and-mode) +- [Vite static deployment](https://vite.dev/guide/static-deploy.html) +- [Node.js release status](https://nodejs.org/en/about/previous-releases) +- [pnpm package versions](https://www.npmjs.com/package/pnpm?activeTab=versions) +- [pnpm continuous integration with Jenkins](https://pnpm.io/continuous-integration#jenkins) +- [Jenkins Pipeline](https://www.jenkins.io/doc/book/pipeline/) +- [TypeScript 6.0 release notes](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-6-0.html) +- [React Router current changelog](https://reactrouter.com/start/start/changelog) +- [React Router mode selection](https://reactrouter.com/start/modes) +- [React Router SPA deployment](https://reactrouter.com/how-to/spa) +- [Tailwind CSS with Vite](https://tailwindcss.com/docs/installation/using-vite) +- [shadcn/ui Vite installation](https://ui.shadcn.com/docs/installation/vite) +- [shadcn/ui Base UI decision](https://ui.shadcn.com/docs/changelog) +- [shadcn/ui TanStack Form guide](https://ui.shadcn.com/docs/forms/tanstack-form) +- [shadcn/ui Sonner](https://ui.shadcn.com/docs/components/radix/sonner) +- [TanStack Query v5](https://tanstack.com/query/v5/docs/framework/react/overview) +- [TanStack Table v8](https://tanstack.com/table/v8/docs/introduction) +- [Zod 4](https://zod.dev/) +- [Vitest](https://vitest.dev/guide/) +- [Playwright](https://playwright.dev/docs/intro) +- [MDN Intl.DateTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat) +- [MDN Date.prototype.toISOString](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString) +- [Google noindex and X-Robots-Tag](https://developers.google.com/search/docs/crawling-indexing/block-indexing) +- [Google robots.txt limitations](https://developers.google.com/search/docs/crawling-indexing/robots/intro) + +## 28. Original Work Frontend Add-on + +27.8은 이미 적용된 58개 Operation의 baseline 프롬프트이므로 내용을 수정하지 않는다. 원작 관리 8개 Operation과 캐릭터 등록·수정의 원작 검색·선택 UI는 다음 별도 delta 프롬프트를 기존 frontend 프로젝트에 추가 적용한다. + +- Prompt: `docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md` +- 적용 후 브라우저용 계약: 기존 로그인 `AUTH-01` + 신규 관리자 Operation 66개 +- 원작 추가 Operation: `ORIGINAL-WORK-01`~`ORIGINAL-WORK-08` +- 기존 stack, 환경별 `VITE_API_BASE_URL`, Jenkins 명령, 인증·세션, noindex, UTC/KST와 feedback 규칙은 변경하지 않는다. +- 27.8 fenced prompt의 변경 전 SHA-256은 `5956ddc152c026937728381d625859bdea65b9a2f16a39b200f0d6b3660a73e1`이며 문서 보강 후에도 같아야 한다. From 5b700892c39abab1f0ad103f3e319342f932c170 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 21 Jul 2026 11:31:36 +0900 Subject: [PATCH 2/5] =?UTF-8?q?docs(ai-character):=20legacy=20API=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=EC=9D=84=20=EB=B3=B4=EA=B0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260720_AI캐릭터_관리자기능/prd.md | 111 +++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/docs/20260720_AI캐릭터_관리자기능/prd.md b/docs/20260720_AI캐릭터_관리자기능/prd.md index bf9ccfde..1d5493aa 100644 --- a/docs/20260720_AI캐릭터_관리자기능/prd.md +++ b/docs/20260720_AI캐릭터_관리자기능/prd.md @@ -4511,3 +4511,114 @@ CHANNEL-PROFILE-02 PUT /admin/ai-characters/{characterId}/channel-profile - 원작 추가 Operation: `ORIGINAL-WORK-01`~`ORIGINAL-WORK-08` - 기존 stack, 환경별 `VITE_API_BASE_URL`, Jenkins 명령, 인증·세션, noindex, UTC/KST와 feedback 규칙은 변경하지 않는다. - 27.8 fenced prompt의 변경 전 SHA-256은 `5956ddc152c026937728381d625859bdea65b9a2f16a39b200f0d6b3660a73e1`이며 문서 보강 후에도 같아야 한다. + +## 29. PRD-Plan Synchronization and Legacy API Mapping + +### 29.1 Synchronization Check + +- 이 PRD와 `plan-task.md`는 신규 관리자 Operation 66개를 같은 범위로 추적한다. +- `plan-task.md`의 Task 1.1~11.4는 PRD의 인증, 메뉴, 원작, 캐릭터, 콘텐츠, 댓글, 카테고리, 시리즈, 커뮤니티, FanTalk, 채널 설정, 삭제 cascade, legacy 호환, callback 유지 및 경계 검증을 모두 포함한다. +- 기존 `AUTH-01`, legacy `/admin/chat/**`, 기존 `/audio-content/upload-complete`는 신규 Operation 수에 포함하지 않는다는 점도 두 문서가 일치한다. +- Frontend baseline 58개와 원작 8개 추가로 브라우저용 신규 관리자 Operation 66개가 된다는 설명도 두 문서가 일치한다. + +### 29.2 Mapping Rule + +- 아래 표는 신규 v2 관리자 API가 이관·대체·참고하는 legacy HTTP API를 추적하기 위한 문서다. +- `없음`은 기존에 같은 목적의 HTTP API가 없거나, 기존 서비스 내부 기능만 있었음을 의미한다. +- `부분 대응`은 legacy API가 데이터 조회나 일부 동작의 근거만 제공하며 신규 v2 계약을 그대로 대체하지 못한다는 의미다. +- 이 표는 구현 의존성 허용 목록이 아니다. v2 신규 비즈니스 로직은 PRD 7.6과 21장의 경계대로 legacy Controller, Service, Repository, Request/Response DTO를 호출하지 않는다. + +### 29.3 Authentication, Character, Original Work + +| Operation ID | 신규 API | 대응 legacy API | 매핑 판단 | +|---|---|---|---| +| `AUTH-01` | `POST /admin/member/login` | 동일 | 기존 로그인 재사용 | +| `CHAR-01` | `GET /admin/ai-characters` | `GET /admin/chat/character/list`, `GET /admin/chat/character/search` | 목록·검색 통합 | +| `CHAR-02` | `GET /admin/ai-characters/{characterId}` | `GET /admin/chat/character/{characterId}` | 상세 이관 | +| `CHAR-03` | `POST /admin/ai-characters` | `POST /admin/chat/character/register` | 등록 이관, legacy 성공 응답은 유지 | +| `CHAR-04` | `PUT /admin/ai-characters/{characterId}` | `PUT /admin/chat/character/update` | 수정 이관, legacy nullable patch 의미는 adapter에서 보존 | +| `CHAR-05` | `DELETE /admin/ai-characters/{characterId}` | `PUT /admin/chat/character/update` with `isActive=false` | 명시 DELETE 없음, 비활성화 경로를 v2 delete cascade로 수렴 | +| `ORIGINAL-WORK-01` | `GET /admin/ai-characters/original-works` | `GET /admin/chat/original/list`, `GET /admin/chat/original/search` | 목록·검색 통합 | +| `ORIGINAL-WORK-02` | `GET /admin/ai-characters/original-works/{originalWorkId}` | `GET /admin/chat/original/{id}` | 상세 이관 | +| `ORIGINAL-WORK-03` | `POST /admin/ai-characters/original-works` | `POST /admin/chat/original/register` | 등록 이관, legacy 성공 응답은 유지 | +| `ORIGINAL-WORK-04` | `PUT /admin/ai-characters/original-works/{originalWorkId}` | `PUT /admin/chat/original/update` | 수정 이관, legacy nullable patch 의미는 adapter에서 보존 | +| `ORIGINAL-WORK-05` | `DELETE /admin/ai-characters/original-works/{originalWorkId}` | `DELETE /admin/chat/original/{id}` | 삭제 이관, 연결 존재 시 신규 정책 적용 | +| `ORIGINAL-WORK-06` | `GET /admin/ai-characters/original-works/{originalWorkId}/characters` | `GET /admin/chat/original/{id}/characters` | 연결 캐릭터 목록 이관 | +| `ORIGINAL-WORK-07` | `POST /admin/ai-characters/original-works/{originalWorkId}/characters` | `POST /admin/chat/original/{id}/assign-characters` | 배정 이관, 부분 성공 금지 | +| `ORIGINAL-WORK-08` | `DELETE /admin/ai-characters/original-works/{originalWorkId}/characters` | `POST /admin/chat/original/{id}/unassign-characters` | 해제 이관, 신규 API는 DELETE body 사용 | + +### 29.4 Content and Content Comment + +| Operation ID | 신규 API | 대응 legacy API | 매핑 판단 | +|---|---|---|---| +| `CONTENT-01` | `GET /admin/ai-characters/{characterId}/contents` | `GET /creator-admin/audio-content/list`, `GET /creator-admin/audio-content/search` | 목록·검색과 관리자 projection 재구성 | +| `CONTENT-02` | `GET /admin/ai-characters/{characterId}/contents/{contentId}` | `GET /audio-content/{id}` | 부분 대응, 관리자 상세·Signed URL 계약은 v2에서 새로 정의 | +| `CONTENT-03` | `POST /admin/ai-characters/{characterId}/contents` | `POST /audio-content` | 생성 이관, 기존 callback 계약 유지 | +| `CONTENT-04` | `PUT /admin/ai-characters/{characterId}/contents/{contentId}` | `PUT /creator-admin/audio-content`, `PUT /audio-content` | 부분 대응, 수정 가능 field를 v2에서 제한 | +| `CONTENT-05` | `DELETE /admin/ai-characters/{characterId}/contents/{contentId}` | `DELETE /audio-content/{id}` | 논리 삭제 이관 | +| `CONTENT-06` | `PUT /admin/ai-characters/{characterId}/contents/{contentId}/pin` | `POST /audio-content/pin-to-the-top/{id}`, `PUT /audio-content/unpin-at-the-top/{id}` | 고정·해제 통합 | +| `CONTENT-07` | `GET /admin/ai-characters/metadata/content-themes` | `GET /audio-content/theme`, `GET /audio-content/theme/active` | 기준정보 조회 이관 | +| `CONTENT-COMMENT-01` | `GET /admin/ai-characters/{characterId}/contents/{contentId}/comments` | `GET /audio-content/{id}/comment` | 루트 댓글 목록 이관 | +| `CONTENT-COMMENT-02` | `GET /admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId}/replies` | `GET /audio-content/comment/{id}` | 답글 목록 이관 | +| `CONTENT-COMMENT-03` | `POST /admin/ai-characters/{characterId}/contents/{contentId}/comments` | `POST /audio-content/comment` | 작성 이관, writer는 AI creator로 고정 | +| `CONTENT-COMMENT-04` | `PUT /admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId}` | `PUT /audio-content/comment` | 수정 이관, AI 작성자만 허용 | +| `CONTENT-COMMENT-05` | `DELETE /admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId}` | `PUT /audio-content/comment` | 명시 DELETE 없음, 논리 삭제 동작만 v2로 분리 | + +### 29.5 Category and Series + +| Operation ID | 신규 API | 대응 legacy API | 매핑 판단 | +|---|---|---|---| +| `CATEGORY-01` | `GET /admin/ai-characters/{characterId}/content-categories` | `GET /category` | 목록 이관 | +| `CATEGORY-02` | `POST /admin/ai-characters/{characterId}/content-categories` | `POST /category` | 생성 이관 | +| `CATEGORY-03` | `PUT /admin/ai-characters/{characterId}/content-categories/{categoryId}` | `PUT /category` | 수정 이관 | +| `CATEGORY-04` | `DELETE /admin/ai-characters/{characterId}/content-categories/{categoryId}` | `DELETE /category/{id}` | 논리 삭제 이관 | +| `CATEGORY-05` | `PUT /admin/ai-characters/{characterId}/content-categories/orders` | `PUT /category/orders` | 순서 변경 이관, 소유자 전체 집합 검증 추가 | +| `CATEGORY-06` | `GET /admin/ai-characters/{characterId}/content-categories/{categoryId}/contents` | `GET /creator-admin/content-category` | 포함 콘텐츠 목록 이관 | +| `CATEGORY-07` | `GET /admin/ai-characters/{characterId}/content-categories/{categoryId}/available-contents` | `GET /creator-admin/content-category/search` | 추가 가능 콘텐츠 검색 이관 | +| `CATEGORY-08` | `POST /admin/ai-characters/{characterId}/content-categories/{categoryId}/contents` | 없음 | 카테고리 생성·수정 내부 구성 기능을 명시 API로 분리 | +| `CATEGORY-09` | `DELETE /admin/ai-characters/{characterId}/content-categories/{categoryId}/contents/{contentId}` | 없음 | 카테고리-콘텐츠 연결 해제를 명시 API로 신설 | +| `SERIES-01` | `GET /admin/ai-characters/{characterId}/series` | `GET /creator-admin/audio-content/series` | 목록 이관 | +| `SERIES-02` | `GET /admin/ai-characters/{characterId}/series/{seriesId}` | `GET /creator-admin/audio-content/series/{id}` | 상세 이관 | +| `SERIES-03` | `POST /admin/ai-characters/{characterId}/series` | `POST /creator-admin/audio-content/series` | 등록 이관 | +| `SERIES-04` | `PUT /admin/ai-characters/{characterId}/series/{seriesId}` | `PUT /creator-admin/audio-content/series` | 수정 이관 | +| `SERIES-05` | `DELETE /admin/ai-characters/{characterId}/series/{seriesId}` | 없음 | 명시 삭제 API 신설 | +| `SERIES-06` | `GET /admin/ai-characters/{characterId}/series/{seriesId}/contents` | `GET /creator-admin/audio-content/series/{id}/content` | 포함 콘텐츠 목록 이관 | +| `SERIES-07` | `GET /admin/ai-characters/{characterId}/series/{seriesId}/available-contents` | `GET /creator-admin/audio-content/series/content/search` | 추가 가능 콘텐츠 검색 이관 | +| `SERIES-08` | `POST /admin/ai-characters/{characterId}/series/{seriesId}/contents` | `POST /creator-admin/audio-content/series/add/content` | 콘텐츠 추가 이관 | +| `SERIES-09` | `DELETE /admin/ai-characters/{characterId}/series/{seriesId}/contents/{contentId}` | `PUT /creator-admin/audio-content/series/remove/content` | 신규 API는 DELETE로 관계 해제 표현 | +| `SERIES-10` | `PUT /admin/ai-characters/{characterId}/series/orders` | `PUT /creator-admin/audio-content/series/orders` | 순서 변경 이관, 소유자 전체 집합 검증 추가 | +| `SERIES-11` | `GET /admin/ai-characters/metadata/series-genres` | `GET /creator-admin/audio-content/series/genre` | 기준정보 조회 이관 | + +### 29.6 Community, FanTalk, Channel Settings + +| Operation ID | 신규 API | 대응 legacy API | 매핑 판단 | +|---|---|---|---| +| `COMMUNITY-POST-01` | `GET /admin/ai-characters/{characterId}/community-posts` | `GET /creator-community` | 목록 이관, 관리자 projection 사용 | +| `COMMUNITY-POST-02` | `GET /admin/ai-characters/{characterId}/community-posts/{postId}` | `GET /creator-community/{id}` | 상세 이관 | +| `COMMUNITY-POST-03` | `POST /admin/ai-characters/{characterId}/community-posts` | `POST /creator-community` | 등록 이관 | +| `COMMUNITY-POST-04` | `PUT /admin/ai-characters/{characterId}/community-posts/{postId}` | `PUT /creator-community` | 수정 이관 | +| `COMMUNITY-POST-05` | `DELETE /admin/ai-characters/{characterId}/community-posts/{postId}` | 없음 | 명시 삭제 API 신설 | +| `COMMUNITY-POST-06` | `PUT /admin/ai-characters/{characterId}/community-posts/{postId}/fixed` | `PUT /creator-community/fixed` | 고정 상태 변경 이관 | +| `COMMUNITY-COMMENT-01` | `GET /admin/ai-characters/{characterId}/community-posts/{postId}/comments` | `GET /creator-community/{id}/comment` | 루트 댓글 목록 이관 | +| `COMMUNITY-COMMENT-02` | `GET /admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}/replies` | `GET /creator-community/comment/{id}` | 답글 목록 이관 | +| `COMMUNITY-COMMENT-03` | `POST /admin/ai-characters/{characterId}/community-posts/{postId}/comments` | `POST /creator-community/comment` | 작성 이관, writer는 AI creator로 고정 | +| `COMMUNITY-COMMENT-04` | `PUT /admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}` | `PUT /creator-community/comment` | 수정 이관, AI 작성자만 허용 | +| `COMMUNITY-COMMENT-05` | `DELETE /admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}` | `PUT /creator-community/comment` | 명시 DELETE 없음, 논리 삭제 동작만 v2로 분리 | +| `FAN-TALK-01` | `GET /admin/ai-characters/{characterId}/fan-talks` | `GET /api/v2/creator-channels/{creatorId}/fan-talks`, `GET /explorer/profile/{id}/cheers` | 기존 v2 query와 legacy FanTalk 조회를 관리자 projection으로 확장 | +| `FAN-TALK-02` | `POST /admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies` | `POST /explorer/profile/cheers` | 부분 대응, AI 답글 생성 정책을 v2에서 분리 | +| `FAN-TALK-03` | `PUT /admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}` | `PUT /explorer/profile/cheers` | 부분 대응, AI 답글 수정만 허용 | +| `FAN-TALK-04` | `DELETE /admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}` | `PUT /explorer/profile/cheers` | 명시 DELETE 없음, AI 답글 논리 삭제를 v2로 분리 | +| `FAN-TALK-05` | `DELETE /admin/ai-characters/{characterId}/fan-talks/{fanTalkId}` | `PUT /explorer/profile/cheers` | 명시 DELETE 없음, 루트 moderation을 v2로 분리 | +| `NOTICE-01` | `GET /admin/ai-characters/{characterId}/channel-notice` | `GET /explorer/profile/{id}/detail` | 부분 대응, 공지 전용 조회 API 신설 | +| `NOTICE-02` | `PUT /admin/ai-characters/{characterId}/channel-notice` | `POST /explorer/profile/notice` | 공지 저장 이관, 신규 API는 PUT upsert | +| `CREATOR-TAG-01` | `GET /admin/ai-characters/metadata/creator-tags` | `GET /member/tag` | 기준정보 조회 이관 | +| `CHANNEL-PROFILE-01` | `GET /admin/ai-characters/{characterId}/channel-profile` | `GET /member/info`, `GET /explorer/profile/{id}/detail` | 부분 대응, 관리자 설정 projection 신설 | +| `CHANNEL-PROFILE-02` | `PUT /admin/ai-characters/{characterId}/channel-profile` | `PUT /member` | 채널 SNS·태그·후원 랭킹 설정 이관 | + +### 29.7 Existing API Kept Outside New Operation Count + +| Existing API | 신규 Operation 포함 여부 | 처리 | +|---|---:|---| +| `GET /menu` | No | v2 AI 캐릭터 관리자는 호출하지 않음, 메뉴는 클라이언트 정적 설정 | +| `PUT /audio-content/upload-complete` | No | 기존 AWS worker callback 계약 유지, v2 콘텐츠도 같은 row·S3 metadata 계약으로 처리 | +| `GET /api/chat/original/**` | No | 일반 사용자용 원작 조회 계약 유지 | From 3f4d7b237f717468e701c94afbe15e8d9a3d6488 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 22 Jul 2026 01:43:08 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(ai-character):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EA=B8=B0=EB=8A=A5=20=EA=B8=B0=EB=B0=98=EC=9D=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260720_AI캐릭터_관리자기능/plan-task.md | 74 +- docs/20260720_AI캐릭터_관리자기능/prd.md | 22 +- .../sodalive/common/SodaException.kt | 5 +- .../sodalive/common/SodaExceptionHandler.kt | 34 +- .../sodalive/configs/SecurityConfig.kt | 35 +- .../AiCharacterAdminAccessDeniedHandler.kt | 27 + ...iCharacterAdminAuthenticationEntryPoint.kt | 27 + .../adapter/in/web/AdminImagePartValidator.kt | 395 ++++++ .../adapter/in/web/AdminJsonRequestParser.kt | 49 + .../web/AiCharacterAdminExceptionHandler.kt | 106 ++ .../application/AdminPagePolicy.kt | 26 + .../AiCharacterAdminAuditLogger.kt | 128 ++ .../admin/aicharacter/dto/AdminCommonDtos.kt | 37 + .../DefaultAiCharacterPersistenceAdapter.kt | 51 + .../AiCharacterAdminTargetResolver.kt | 44 + .../domain/AiCharacterAdminTarget.kt | 13 + .../port/out/AiCharacterPersistencePort.kt | 7 + .../common/application/AfterCommitExecutor.kt | 34 + .../AdminChatCharacterControllerTest.kt | 696 +++++++++++ ...AdminOriginalWorkControllerContractTest.kt | 469 ++++++++ .../member/AdminMemberLoginServiceTest.kt | 29 +- .../OriginalWorkControllerContractTest.kt | 276 +++++ ...udioContentUploadCompletionContractTest.kt | 162 +++ .../LegacyAdminSearchQueryContractTest.kt | 210 ++++ ...gacySodaExceptionHttpStatusContractTest.kt | 119 ++ .../in/web/AdminImagePartValidatorTest.kt | 1054 +++++++++++++++++ .../in/web/AdminJsonRequestParserTest.kt | 93 ++ .../AiCharacterAdminExceptionHandlerTest.kt | 126 ++ ...AiCharacterAdminLoginJwtIntegrationTest.kt | 165 +++ ...AiCharacterAdminSecurityIntegrationTest.kt | 215 ++++ .../application/AdminPagePolicyTest.kt | 85 ++ .../AiCharacterAdminAuditLoggerTest.kt | 199 ++++ ...efaultAiCharacterPersistenceAdapterTest.kt | 121 ++ .../AiCharacterAdminTargetResolverTest.kt | 124 ++ .../CreatorChannelCommunityControllerTest.kt | 31 +- .../CreatorChannelFanTalkControllerTest.kt | 31 +- .../web/CreatorChannelSeriesControllerTest.kt | 31 +- ...AfterCommitEventBoundaryIntegrationTest.kt | 333 ++++++ .../application/AfterCommitExecutorTest.kt | 88 ++ 39 files changed, 5657 insertions(+), 114 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAccessDeniedHandler.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAuthenticationEntryPoint.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminImagePartValidator.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminJsonRequestParser.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandler.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicy.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLogger.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminCommonDtos.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapter.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterAdminTargetResolver.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterAdminTarget.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterPersistencePort.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutor.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkControllerContractTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkControllerContractTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentUploadCompletionContractTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/legacy/LegacyAdminSearchQueryContractTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/legacy/LegacySodaExceptionHttpStatusContractTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminImagePartValidatorTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminJsonRequestParserTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandlerTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminLoginJwtIntegrationTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminSecurityIntegrationTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicyTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLoggerTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapterTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterAdminTargetResolverTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitEventBoundaryIntegrationTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutorTest.kt diff --git a/docs/20260720_AI캐릭터_관리자기능/plan-task.md b/docs/20260720_AI캐릭터_관리자기능/plan-task.md index 9a478478..a65fe6c3 100644 --- a/docs/20260720_AI캐릭터_관리자기능/plan-task.md +++ b/docs/20260720_AI캐릭터_관리자기능/plan-task.md @@ -18,6 +18,7 @@ - 메뉴는 클라이언트의 typed static configuration이 소유한다. Backend의 기존 `GET /menu` 및 메뉴 코드는 수정하지 않는다. - Kotlin package는 v2지만 HTTP base path는 `/admin/ai-characters`다. `/v2`, `/admin/v2`, `/v2/admin` prefix를 추가하지 않는다. - 신규 웹 Operation은 PRD에 명시된 66개다. 기존 `PUT /audio-content/upload-complete`는 호환 계약이므로 신규 Operation 수에 포함하지 않는다. +- 기존 `PUT /audio-content/upload-complete`는 인증 정보 없음·유효하지 않은 JWT에 `401`, 인증됐지만 `ADMIN`/`BOT`이 아닌 역할에 `403`을 반환한다. 기존 Request와 `ADMIN`/`BOT` 성공 Response는 유지한다. - `ORIGINAL-WORK-01`~`ORIGINAL-WORK-08`은 `/admin/ai-characters/original-works`의 global 원작 CRUD·검색·캐릭터 배정 계약이다. legacy `/admin/chat/original/**`의 Method·Path·Request·성공 Response와 일반 사용자용 `/api/chat/original/**`는 호환을 위해 유지한다. - `CONTENT-08`이라는 신규 Endpoint, V1/V2 dispatcher와 v2 completion use case를 만들지 않는다. v2 콘텐츠도 기존 row·S3 계약을 따라 현재 callback이 동일하게 처리한다. - AWS S3 Trigger worker 코드, worker 스케줄, metadata 계약 및 AWS Trigger 설정은 생성·수정하지 않는다. @@ -212,24 +213,25 @@ Phase 0 기존 callback·소비자 계약 고정 ### Phase 0: 기존 callback·소비자 계약 고정 -- [ ] **Task 0.1: 변경 전 callback·소비자 API 계약을 회귀 테스트로 고정** +- [x] **Task 0.1: 변경 전 callback·소비자 API 계약을 회귀 테스트로 고정** - Files: - Create: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentUploadCompletionContractTest.kt` - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterControllerTest.kt` - Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkControllerContractTest.kt` - Create: `src/test/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkControllerContractTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/legacy/LegacyAdminSearchQueryContractTest.kt` - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt` - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt` - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt` - RED: TDD 예외 사유: 변경 대상이 아닌 기존 callback·소비자 계약을 characterization test로 고정하는 작업이므로 의도적인 production 결함을 먼저 만들지 않는다. - - 대체 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.chat.original.controller.OriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest`를 실행해 기존 `PUT /audio-content/upload-complete`, legacy 캐릭터·원작 관리자, 일반 사용자용 원작 API, BOT/ADMIN 인가, Request/Response 및 소비자 API 계약 중 현재 구현과 어긋난 지점이 있으면 먼저 조사하고, 모두 일치하면 최초 통과 결과를 baseline으로 기록한다. legacy 원작 mutation은 이후 같은 v2 정책으로 수렴하므로 이 Task에서는 Method·Path·Request·성공 Response를 고정하고 잘못된 mutation을 성공시키는 내부 동작을 호환 계약으로 고정하지 않는다. + - 대체 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.chat.original.controller.OriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.legacy.LegacyAdminSearchQueryContractTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest`를 실행해 callback의 Method·Path·Request·`ADMIN`/`BOT` 성공 Response·인가 status, legacy 캐릭터·원작 관리자의 Method·Path·Request·성공 Response, 일반 사용자용 원작 API와 소비자 API 계약 중 현재 구현과 어긋난 지점이 있으면 먼저 조사하고, 모두 일치하면 최초 통과 결과를 baseline으로 기록한다. callback은 인증 정보 없음·유효하지 않은 JWT `401`, 인증됐지만 `ADMIN`/`BOT`이 아닌 역할 `403`을 고정한다. legacy 검색은 실제 repository/service로 검색 field, 활성·삭제 제외, 정렬과 pagination을 고정한다. legacy 원작 mutation은 이후 같은 v2 정책으로 수렴하므로 이 Task에서는 Method·Path·Request·성공 Response를 고정하고 잘못된 mutation을 성공시키는 내부 동작을 호환 계약으로 고정하지 않는다. - GREEN: 이 Task에서는 신규 route를 구현하지 않는다. callback과 기존 소비자 API가 현재 상태에서 통과하는지 baseline을 기록한다. - REFACTOR: 테스트 fixture는 실제 Spring mapping과 응답 surface를 검증하며 운영 코드를 위한 범용 endpoint registry를 만들지 않는다. - 기대 결과: 기존 callback, legacy 캐릭터·원작 관리자와 소비자 API 계약이 초록색 baseline으로 고정된다. ### Phase 1: 공통 관리자 계약, 보안, 대상 해석 -- [ ] **Task 1.1: 공통 page/응답/multipart JSON 계약 구현** +- [x] **Task 1.1: 공통 page/응답/multipart JSON 계약 구현** - Files: - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminCommonDtos.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicy.kt` @@ -238,30 +240,34 @@ Phase 0 기존 callback·소비자 계약 고정 - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicyTest.kt` - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminJsonRequestParserTest.kt` - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminImagePartValidatorTest.kt` - - RED: page `null/-1/0`, size `null/0/1/20/50/51`, page response `hasNext`, multipart JSON과 일반 JSON body의 필수 key 누락/명시적 `null` 구분, 이미지 bytes의 실제 MIME과 `allowGif` 조건을 테스트한다. + - RED: page `null/-1/0`, size `null/0/1/20/50/51`, page response `hasNext`, multipart JSON과 일반 JSON body의 필수 key 누락/명시적 `null` 구분, 빈 multipart JSON과 단일 root 뒤의 추가 root·garbage 거부, 이미지 bytes의 실제 MIME `image/jpeg`, `image/png`, `image/gif`, 10MB·한 변 20,000px·총 40,000,000 pixels 초과 거부를 테스트한다. PNG는 ancillary payload 합계 1MB·chunk 4,096개 상한과 `ignoreMetadata=true`를 검증한다. GIF는 최대 500 frame, extension 1,024개, extension당 sub-block 64개, extension payload 합계 1MB, 전체 frame 누적 40,000,000 pixels 상한을 검증하고, logical canvas·모든 frame header의 동일 상한, 선언 pixel 수와 정확히 일치하는 LZW 출력, 조기 EOI·연속 clear·EOI 뒤 data와 첫 frame이 정상이지만 후속 frame decode가 손상된 입력 거부를 고정한다. 실제 format 확인 시 모든 GIF frame을 각각 1x1 출력 영역으로 decode하는지와 `allowGif` 조건도 테스트한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AdminPagePolicyTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest` - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: `AdminPageResponse`, 공통 mutation/comment request와 PRD의 정규화 규칙만 구현한다. `AdminJsonRequestParser`는 multipart JSON string과 `JsonNode` 모두에서 required nullable key를 검증하고, `AdminImagePartValidator`는 v2 web adapter에서 실제 MIME을 검사한다. domain port에는 admin DTO나 Spring `Pageable`을 넘기지 않고 정규화된 offset/limit을 전달한다. + - GREEN: `AdminPageResponse`, 공통 mutation/comment request와 PRD의 정규화 규칙만 구현한다. `AdminJsonRequestParser`는 multipart JSON string과 `JsonNode` 모두에서 required nullable key를 검증하고, multipart JSON string은 parser 전용 strict reader로 단일 root만 허용한다. `AdminImagePartValidator`는 v2 web adapter에서 실제 MIME을 검사한다. PNG ImageIO 입력은 `ignoreMetadata=true`로 metadata를 읽지 않고, container preflight에서 ancillary payload 합계 1MB와 chunk 4,096개를 제한한다. GIF preflight는 최대 500 frame, extension 1,024개, extension당 sub-block 64개, extension payload 합계 1MB, 전체 frame 누적 40,000,000 pixels를 제한하고 각 frame LZW 출력이 선언 pixel 수와 정확히 일치하는지 bounded code parser로 확인한다. 실제 format 확인 decode는 모든 frame의 1x1 출력 영역으로 제한하고, 입력 유래 decoder 예외를 잘못된 이미지로 처리한다. domain port에는 admin DTO나 Spring `Pageable`을 넘기지 않고 정규화된 offset/limit을 전달한다. - REFACTOR: 기존 `CreatorChannel*QueryPolicy`는 size 1~19 처리 계약이 다르므로 수정하거나 재사용하지 않는다. - - 기대 결과: 모든 관리자 목록과 multipart update가 하나의 명시적 계약을 사용한다. + - 기대 결과: 모든 관리자 목록과 multipart update가 하나의 명시적 계약을 사용하고, image part는 bytes·container 구조·metadata·frame·LZW 작업량의 bounded 안전 경계를 공유한다. -- [ ] **Task 1.2: `/admin/ai-characters/**` 전용 오류·인증 응답 경계 구현** +- [x] **Task 1.2: `/admin/ai-characters/**` 전용 오류·인증 응답 경계 구현** - Files: - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/common/SodaException.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandler.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAuthenticationEntryPoint.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAccessDeniedHandler.kt` - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminSecurityIntegrationTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminLoginJwtIntegrationTest.kt` - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandlerTest.kt` - - RED: 기존 `POST /admin/member/login` 응답 token으로 신규 API 호출 성공, 무JWT 401, 잘못된 JWT 401, `USER/CREATOR/AGENT/CONTENT_MANAGER` 403, ADMIN 통과 및 400/404/409/500/502별 `ApiResponse` body와 `errorProperty`를 검증한다. 같은 예외가 legacy route에서는 기존 HTTP 200 관례를 유지하는 회귀 케이스도 추가한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminSecurityIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminExceptionHandlerTest` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/legacy/LegacySodaExceptionHttpStatusContractTest.kt` + - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberLoginServiceTest.kt` + - RED: 기존 `POST /admin/member/login` 응답 token으로 신규 API 호출 성공, 무JWT 401, 잘못된 JWT 401, `USER/CREATOR/AGENT/CONTENT_MANAGER` 403, ADMIN 통과 및 400/404/409/500/502별 `ApiResponse` body와 `errorProperty`를 검증한다. 실제 Spring Boot context에서 두 production advice와 `SecurityConfig`를 함께 로드하고, DispatcherServlet에 연결된 test `MultipartResolver`가 handler 선택 전에 실패할 때 admin 경로는 400, legacy 경로는 기존 HTTP 200과 unknown 메시지를 유지하는지도 검증한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminSecurityIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminLoginJwtIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminExceptionHandlerTest --tests kr.co.vividnext.sodalive.legacy.LegacySodaExceptionHttpStatusContractTest --tests kr.co.vividnext.sodalive.admin.member.AdminMemberLoginServiceTest` - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - GREEN: `SodaException` 생성자 끝에 legacy 기본 동작을 보존하는 선택 HTTP status를 추가하고, 우선순위가 높은 admin Controller 범위 advice와 path-specific security handler만 그 status를 응답에 사용한다. - REFACTOR: 기존 `SodaExceptionHandler`, `JwtAuthenticationEntryPoint`, `JwtAccessDeniedHandler`의 응답을 변경하지 않는다. - 기대 결과: 신규 관리자 API만 PRD 20장의 status/envelope를 사용하고 legacy API는 영향받지 않는다. -- [ ] **Task 1.3: AI 캐릭터 관리자 대상 해석과 owner 입력 차단 구현** +- [x] **Task 1.3: AI 캐릭터 관리자 대상 해석과 owner 입력 차단 구현** - Files: - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterAdminTarget.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterAdminTargetResolver.kt` @@ -276,17 +282,17 @@ Phase 0 기존 callback·소비자 계약 고정 - REFACTOR: JPA `ChatCharacter`, `Member`, Q type은 persistence adapter 밖으로 노출하지 않는다. - 기대 결과: 모든 character-scoped facade가 동일한 대상 해석 결과를 사용한다. -- [ ] **Task 1.4: direct after-commit 실행과 구조화 관리자 audit 기반 구현** +- [x] **Task 1.4: direct after-commit 실행과 구조화 관리자 audit 기반 구현** - Files: - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutor.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLogger.kt` - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutorTest.kt` - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitEventBoundaryIntegrationTest.kt` - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLoggerTest.kt` - - RED: direct callback의 commit 후 1회 실행, rollback/동일 command 재시도에서 미실행·비중복, transaction 안에서 publish한 기존 FCM/언어 event listener가 commit 후 실행되고 유실되지 않는지, mutation 성공/실패 audit field와 민감 본문 미기록을 검증한다. global 원작 CRUD는 nullable character field를 허용하고 원작 배정·해제는 캐릭터별 context를 요구하는지도 고정한다. + - RED: direct callback의 commit 후 1회 실행과 rollback 미실행을 검증하고, 실제 `TransactionTemplate`에서 첫 attempt가 rollback된 동일 command를 재시도해 commit하면 callback이 두 attempt 합계 1회 실행되는 기존 동작을 characterization으로 고정한다. `AfterCommitExecutor`에는 command identity가 없으므로 attempt 간 dedup은 요구하지 않는다. 기존 `FcmEvent`는 commit 후, `LanguageDetectEvent`는 commit 후·rollback 시, `LanguageTranslationEvent(waitTransactionCommit=true)`는 commit 후 listener 동작을 각각 검증하고 세 listener의 `AFTER_COMMIT` 선언도 고정한다. mutation 성공/실패 audit field와 민감 본문 미기록을 검증한다. global 원작 context는 `ORIGINAL_WORK`의 `CREATE/UPDATE/DELETE`만, 원작 배정·해제는 캐릭터별 `ORIGINAL_WORK_CHARACTER` context만 허용한다. character-scoped context의 `creatorMemberId`는 `UNASSIGN`에서만 nullable로 두되, factory가 정상/과거 불일치 해제 타입을 구분하지 않는다. factory 검증을 우회하는 public `copy`도 노출하지 않는다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest` - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: `AfterCommitExecutor`는 home news 같은 direct callback만 Spring transaction synchronization에 등록한다. 기존 `FcmEvent`, `LanguageDetectEvent`, `LanguageTranslationEvent(waitTransactionCommit=true)`는 transaction 안에서 publish하고 각 listener의 AFTER_COMMIT 경계를 유지한다. structured audit logger는 global/character-scoped context를 명시적으로 구분하는 정도로만 추가하며 audit table이나 AOP framework는 만들지 않는다. + - GREEN: `AfterCommitExecutor`는 home news 같은 direct callback만 Spring transaction synchronization에 등록한다. 기존 `FcmEvent`, `LanguageDetectEvent`, `LanguageTranslationEvent(waitTransactionCommit=true)`는 transaction 안에서 publish하고 각 listener의 AFTER_COMMIT 경계를 유지한다. structured audit logger는 private factory로 global/character-scoped context와 action/resource/creator 조합을 강제하되 정상/과거 불일치 해제용 별도 command나 factory를 만들지 않으며, audit table이나 AOP framework도 만들지 않는다. - REFACTOR: 비밀번호, JWT, system prompt 전체, 댓글/게시글 본문, 업로드 파일 내용이 logger argument에 들어갈 수 없도록 audit context를 ID와 enum 중심으로 제한한다. - 기대 결과: 이후 facade와 event adapter가 같은 commit/audit 원칙을 반복 구현하지 않는다. @@ -402,7 +408,7 @@ Phase 0 기존 callback·소비자 계약 고정 - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkControllerContractTest.kt` - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/osiv/OsivLazyLoadingRegressionTest.kt` - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` - - RED: PRD 11.7의 8개 exact method/path, Query, 전체 envelope JSON field·nullable·UTC, 생성/수정 multipart part와 11개 key, 이미지 실제 MIME/GIF 거부, DELETE JSON body와 proxy 통과, ADMIN 인가와 정확한 400/404/409/500/502를 MockMvc로 고정한다. assignment response의 요청 순서 전체 ID와 작업 후 전체 `characterCount`, 같은 원작 멱등 ID, nullable `creatorId`, global CRUD audit의 nullable character field와 배정·해제 캐릭터별 audit도 검증한다. legacy 원작 5개 mutation route는 Method·Path·Request·성공 `data=null`을 유지하면서 같은 v2 원작 input port를 호출하고, legacy update의 null은 잠금 transaction 안에서 현재 값 유지로 병합되며 연결 삭제·부분 배정·오귀속 해제는 더 이상 성공하지 않는지 확인한다. + - RED: PRD 11.7의 8개 exact method/path, Query, 전체 envelope JSON field·nullable·UTC, 생성/수정 multipart part와 11개 key, 이미지 실제 MIME/GIF 거부, DELETE JSON body와 proxy 통과, ADMIN 인가와 정확한 400/404/409/500/502를 MockMvc로 고정한다. assignment response의 요청 순서 전체 ID와 작업 후 전체 `characterCount`, 같은 원작 멱등 ID, nullable `creatorId`, global CRUD audit의 nullable character field와 배정·해제 캐릭터별 audit도 검증한다. 해제 audit은 캐릭터의 `creatorMemberId`를 확인할 수 있으면 실제 값을 기록하고, 연결 정보가 없거나 해석할 수 없는 과거 불일치 상태에서만 `null`을 기록한다. legacy 원작 5개 mutation route는 Method·Path·Request·성공 `data=null`을 유지하면서 같은 v2 원작 input port를 호출하고, legacy update의 null은 잠금 transaction 안에서 현재 값 유지로 병합되며 연결 삭제·부분 배정·오귀속 해제는 더 이상 성공하지 않는지 확인한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminOriginalWorkControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest --tests kr.co.vividnext.sodalive.admin.chat.original.LegacyOriginalWorkMutationAdapterTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.osiv.OsivLazyLoadingRegressionTest` - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - GREEN: 신규 Controller는 `AdminJsonRequestParser`, `AdminImagePartValidator`와 실제 `adminMemberId`만 사용하고 facade는 원작 use case 호출, Response 변환과 audit만 담당한다. `ORIGINAL-WORK-08`은 `Content-Type: application/json` DELETE body를 명시적으로 매핑한다. legacy 원작 compatibility adapter는 legacy DTO와 multipart의 non-null update field만 v2 호환 patch command로 변환한다. 현재 값 병합은 adapter 선조회가 아니라 v2 application이 원작 row를 잠근 transaction 안에서 수행한다. 원작 조회는 기존 service를 유지하고 legacy 원작 5개 direct mutation만 v2 input으로 수렴한다. legacy 캐릭터가 아직 호출하는 `assignOneCharacter`는 Phase 10의 전체 캐릭터 호환 전환 전까지만 남기고, 나머지 미사용 direct mutation 메서드와 그로 인해 불필요해진 dependency만 제거해 OSIV 회귀 fixture의 constructor를 맞춘다. @@ -433,10 +439,10 @@ Phase 0 기존 callback·소비자 계약 고정 - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentUploadCompletionContractTest.kt` - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt` - Create: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentReleaseQueryTest.kt` - - RED: 기존 callback의 method/path/BOT·ADMIN/Request/`data={}`는 그대로인 상태에서 v2 생성과 동일한 기존 row가 정상 완료되는지 검증한다. 처리 중 삭제된 `releaseDate=null` row와 비활성 creator row는 callback이 output path·duration을 기록해도 raw `content.isActive=false`와 공개 FCM/home news 0회를 유지해야 한다. 비활성 creator의 과거 불일치 row가 raw `content.isActive=true`이면 callback 후 `false`로 보정한다. 활성 creator의 즉시 공개는 최초 false→true에서만 공개 side effect를 내며 동일 callback 재시도는 이를 중복하지 않아야 한다. 예약 공개 query는 `isActive=false`, non-null due releaseDate, non-null duration, 활성 creator를 모두 만족하는 row만 반환하는지 검증한다. + - RED: 기존 callback의 method/path/Request와 `ADMIN`/`BOT` 성공 `data={}`는 그대로이고, 인증 정보 없음·유효하지 않은 JWT는 `401`, 인증됐지만 `ADMIN`/`BOT`이 아니면 `403`인 상태에서 v2 생성과 동일한 기존 row가 정상 완료되는지 검증한다. 처리 중 삭제된 `releaseDate=null` row와 비활성 creator row는 callback이 output path·duration을 기록해도 raw `content.isActive=false`와 공개 FCM/home news 0회를 유지해야 한다. 비활성 creator의 과거 불일치 row가 raw `content.isActive=true`이면 callback 후 `false`로 보정한다. 활성 creator의 즉시 공개는 최초 false→true에서만 공개 side effect를 내며 동일 callback 재시도는 이를 중복하지 않아야 한다. 예약 공개 query는 `isActive=false`, non-null due releaseDate, non-null duration, 활성 creator를 모두 만족하는 row만 반환하는지 검증한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests kr.co.vividnext.sodalive.content.AudioContentReleaseQueryTest` - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: 기존 `AudioContentService.uploadComplete`의 공개 조건에 non-null releaseDate, 활성 creator와 최초 활성 전이를 추가하고 비활성 creator의 raw `content.isActive`를 `false`로 유지·보정한다. 기존 release query에는 활성 creator 조건을 추가한다. Controller, Request/Response, scheduler component의 cron·lock은 변경하지 않는다. + - GREEN: 기존 `AudioContentService.uploadComplete`의 공개 조건에 non-null releaseDate, 활성 creator와 최초 활성 전이를 추가하고 비활성 creator의 raw `content.isActive`를 `false`로 유지·보정한다. 기존 release query에는 활성 creator 조건을 추가한다. Controller의 Method·Path·Request와 `ADMIN`/`BOT` 성공 Response, scheduler component의 cron·lock은 변경하지 않는다. - REFACTOR: 신규 callback Controller/DTO/use case, V1/V2 dispatcher, pipeline metadata와 v2 scheduler가 생기지 않았는지 diff를 확인한다. - 기대 결과: 기존 callback과 scheduler를 모든 콘텐츠가 공용하면서 삭제 콘텐츠와 비활성 creator를 다시 공개하지 않는다. @@ -860,7 +866,7 @@ Phase 0 기존 callback·소비자 계약 고정 - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminInactiveStateEndToEndTest.kt` - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminAuditEndToEndTest.kt` - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` - - RED: route inventory가 PRD의 method/path 66개와 중복·누락·추가 없이 정확히 일치하는지 먼저 검증한다. 이어서 리소스군마다 다른 character 소유 Path ID는 404, Request body의 잘못된 parent/root 귀속은 400과 해당 field, inactive character의 GET·DELETE 허용과 그 외 mutation 409, inactive parent mutation 409, 논리 리소스 DELETE retry 200, 실제 ADMIN과 대행 creator 구분, 모든 mutation success/failure audit를 parameterized E2E로 작성한다. global 원작은 character 선택 없이 접근되고, 0 이하·미존재·삭제 ID의 정확한 400/404/409, 연결 존재 삭제 409, 이미 삭제된 상태 우선의 반복 DELETE 200, 배정·해제 전건 검증·원자성, 다른 원작 이동과 audit nullable/캐릭터별 규칙도 포함한다. 같은 원작 재배정은 요청 순서 ID 전체를 반환하는 멱등 성공이고 이미 해제된 관계의 ORIGINAL-WORK-08 재시도는 귀속 불일치 400인지 구분한다. legacy 원작 mutation 5개와 캐릭터 mutation 2개 전체도 각각 같은 v2 원작·캐릭터 command를 사용하며 동시 삭제·배정에서 삭제 원작 참조를 만들지 않는지 포함한다. + - RED: route inventory가 PRD의 method/path 66개와 중복·누락·추가 없이 정확히 일치하는지 먼저 검증한다. 이어서 리소스군마다 다른 character 소유 Path ID는 404, Request body의 잘못된 parent/root 귀속은 400과 해당 field, inactive character의 GET·DELETE 허용과 그 외 mutation 409, inactive parent mutation 409, 논리 리소스 DELETE retry 200, 실제 ADMIN과 대행 creator 구분, 모든 mutation success/failure audit를 parameterized E2E로 작성한다. global 원작은 character 선택 없이 접근되고, 0 이하·미존재·삭제 ID의 정확한 400/404/409, 연결 존재 삭제 409, 이미 삭제된 상태 우선의 반복 DELETE 200, 배정·해제 전건 검증·원자성, 다른 원작 이동과 audit nullable/캐릭터별 규칙도 포함한다. 정상/과거 불일치 해제를 별도 command나 factory로 나누지 않되, 해제 audit은 확인 가능한 실제 `creatorMemberId`를 기록하고 연결 정보가 없거나 해석할 수 없을 때만 `null`을 기록한다. 같은 원작 재배정은 요청 순서 ID 전체를 반환하는 멱등 성공이고 이미 해제된 관계의 ORIGINAL-WORK-08 재시도는 귀속 불일치 400인지 구분한다. legacy 원작 mutation 5개와 캐릭터 mutation 2개 전체도 각각 같은 v2 원작·캐릭터 command를 사용하며 동시 삭제·배정에서 삭제 원작 참조를 만들지 않는지 포함한다. - 실패 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdmin*EndToEndTest' --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest` - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - GREEN: 누락된 owner/status/audit 연결만 각 facade/use case에 보완한다. @@ -929,7 +935,7 @@ Phase 0 기존 callback·소비자 계약 고정 13. `awk '$0=="### 27.8 Copy-Paste Frontend Development Prompt"{section=1; next} section && $0=="```text"{capture=1} capture{print} capture && $0=="```"{exit}' docs/20260720_AI캐릭터_관리자기능/prd.md | shasum -a 256` 결과가 `5956ddc152c026937728381d625859bdea65b9a2f16a39b200f0d6b3660a73e1`인지 확인한다. 14. `find docs/20260720_AI캐릭터_관리자기능 -type f -name '*.sql' -print` 출력이 0건인지 확인한다. 15. `rg -o '^### Phase [0-9]+' docs/20260720_AI캐릭터_관리자기능/plan-task.md | wc -l` 결과가 12인지 확인한다. - 16. `rg -o '^- \[ \] \*\*Task [0-9]+\.[0-9]+:' docs/20260720_AI캐릭터_관리자기능/plan-task.md | wc -l` 결과가 37인지 확인한다. + 16. `rg -o '^- \[[ x]\] \*\*Task [0-9]+\.[0-9]+:' docs/20260720_AI캐릭터_관리자기능/plan-task.md | wc -l` 결과가 37인지 확인한다. 17. `rg -c '^ - 실패 확인:' docs/20260720_AI캐릭터_관리자기능/plan-task.md`와 `rg -c '^ - 통과 확인:' docs/20260720_AI캐릭터_관리자기능/plan-task.md` 결과가 각각 35인지 확인한다. 18. `rg -c '^ - RED: TDD 예외' docs/20260720_AI캐릭터_관리자기능/plan-task.md` 결과가 2인지 확인한다. 19. `rg -n $'\t| +$' docs/20260720_AI캐릭터_관리자기능/prd.md docs/20260720_AI캐릭터_관리자기능/plan-task.md docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md` 출력이 0건인지 확인하고, 각 문서의 code fence 개수가 짝수인지 확인한다. untracked 문서는 `git diff --check`만으로 검사되지 않으므로 파일 자체 검사도 수행한다. @@ -959,12 +965,36 @@ Phase 0 기존 callback·소비자 계약 고정 ## 5. 구현 시 검증 기록 -이 문서를 생성한 현재는 구현 전이므로 Task checkbox를 모두 미체크로 유지한다. 구현 에이전트는 각 Task에서 실제로 실행한 명령, RED 실패 원인, GREEN 성공 결과와 미실행 외부 Gate를 이 절에 누적한다. +구현 에이전트는 각 Task에서 실제로 실행한 명령, RED 실패 원인, GREEN 성공 결과와 미실행 외부 Gate를 이 절에 누적한다. Phase 0/1은 완료했고 Phase 2 이후 구현 Task는 미착수 상태로 유지한다. - 문서 생성 검증: 아래 “문서 자체 검증 기록”에만 기록한다. -- 코드 구현 검증: 아직 실행하지 않음. +- 코드 구현 검증: Phase 0/1 구현 중 아래 명령을 실행해 `BUILD SUCCESSFUL`을 확인했다. + - RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AdminPagePolicyTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterAdminTargetResolverTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest`가 미구현 클래스 참조로 실패했다. + - 리뷰 4차 RED 확인: parser trailing root·garbage 2건, 이미지 전체 decode·dimension·pixel 3건, audit invalid 조합 3건과 public `copy` 1건이 각각 기존 구현에서 예상한 이유로 실패했다. Phase 0 legacy fixed-wire 보강과 rollback→retry는 기존 동작을 고정하는 characterization test라 production 변경 없이 통과했다. + - 리뷰 5차 RED 확인: malformed GIF의 `IndexOutOfBoundsException`·입력 유래 `IllegalArgumentException`, 20,001px logical canvas, 40MP 초과 후속 frame 4건과 character-scoped `ORIGINAL_WORK`, `UNASSIGN` 외 `creatorMemberId=null` audit 2건이 기존 구현에서 예상한 이유로 실패했다. 빈 multipart JSON, 실제 legacy 검색 query, multipart downstream 전달 보강은 기존 동작을 고정하는 characterization test로 추가했다. + - Phase 0 baseline: `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.chat.original.controller.OriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest` 통과. + - Phase 1 GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AdminPagePolicyTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminSecurityIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminLoginJwtIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminExceptionHandlerTest --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterAdminTargetResolverTest --tests kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.persistence.DefaultAiCharacterPersistenceAdapterTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest` 통과. 이 명령의 `AdminImagePartValidatorTest`는 10MB 초과 거부와 JPEG/PNG/GIF 실제 MIME·GIF 허용 조건을 고정한다. + - Phase 0+1 통합 targeted: 위 Phase 0/1 대상 테스트 전체를 한 Gradle 명령으로 실행해 통과. + - 리뷰 보강 targeted: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminExceptionHandlerTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminSecurityIntegrationTest --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterAdminTargetResolverTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest` 통과. 이 보강은 legacy 캐릭터·원작 관리자 mutation 성공 `ApiResponse` surface, `/admin/ai-characters` segment 경계, exception handler status 행렬, target resolver 404/409 행렬, 기존 event listener AFTER_COMMIT 경계, audit `PIN` action을 포함한다. + - 리뷰 2차 보강 targeted: `./gradlew test --tests 'kr.co.vividnext.sodalive.admin.member.AdminMemberLoginServiceTest' --tests 'kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest' --tests 'kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest' --tests 'kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest'` 통과. 이 보강은 `/admin/member/login`이 생성한 token의 기존 `TokenProvider` 검증·인증 복원, legacy 캐릭터·원작 mutation의 `data` 부재 계약, 일반 event publish의 commit/rollback transactional listener 경계를 포함한다. + - 리뷰 3차 보강 targeted: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest`, `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest`, `./gradlew test --tests kr.co.vividnext.sodalive.chat.original.controller.OriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminLoginJwtIntegrationTest` 통과. 이 보강은 실제 FCM event listener의 commit, 언어 감지 listener의 commit/rollback, `waitTransactionCommit=true` 언어 번역 listener의 commit과 세 listener의 `AFTER_COMMIT` 선언, upload-complete production `SecurityConfig`의 ADMIN/BOT JWT 허용·USER 403·익명 401, 일반 원작·legacy 원작·legacy 캐릭터 응답 field, CONTENT_MANAGER 실제 로그인 JWT의 신규 관리자 API 403을 포함한다. + - Task 1.4 retry characterization targeted: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest` 통과. 실제 `TransactionTemplate`에서 첫 attempt rollback 후 동일 command를 재시도해 commit하면 direct callback은 두 attempt 합계 1회 실행됐고, 새 테스트가 production `AfterCommitExecutor` 변경 없이 통과해 현 구현의 회귀 계약으로 기록했다. + - 리뷰 4차 수정 targeted: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.chat.original.controller.OriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest` 통과. production 원작 공개 matcher, legacy 캐릭터·원작 검색과 캐릭터 multipart 전체 wire, trailing JSON 거부, 이미지 decode·dimension·pixel 경계, audit action/resource/creator 조합과 public `copy` 부재, rollback 후 direct callback 재시도를 함께 검증했다. + - 리뷰 5차 수정 targeted: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.chat.original.controller.OriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.legacy.LegacyAdminSearchQueryContractTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest` 통과. GIF logical canvas·모든 frame header와 malformed decoder 경계, audit global/character creator 불변식, 실제 JPA legacy 검색 field·상태·정렬·pagination, 캐릭터 외부 API exact method/path/JSON 및 원작 update DTO 전달을 함께 검증했다. + - 리뷰 6차 RED 확인: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest`에서 첫 frame은 정상이지만 LZW 데이터가 손상된 두 번째 frame을 기존 구현이 decode하지 않아 예외가 발생하지 않았고, 16건 중 신규 테스트 1건이 `AssertionFailedError`로 실패했다. + - 리뷰 6차 수정 targeted: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest` 통과. 33건의 실패·오류가 0건이며, GIF 모든 frame의 1x1 decode, callback의 `ADMIN`/`BOT` 성공·`USER` 403·유효하지 않은 JWT와 익명 401, 해제 audit factory의 nullable 경계를 함께 검증했다. + - Full regression: `./gradlew --no-daemon test` 통과. 리뷰 6차 GIF 구현 후 재실행도 `BUILD SUCCESSFUL in 4m 26s`로 통과했다. + - Lint: `./gradlew --no-daemon ktlintCheck` 통과. 리뷰 6차 최종 Kotlin 변경 후 재실행도 `BUILD SUCCESSFUL in 26s`로 통과했다. + - 리뷰 7차 RED 확인: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest`에서 PNG ancillary chunk와 GIF extension payload가 ImageIO custom provider까지 도달해 신규 preflight 테스트 2건이 실패했다. `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.legacy.LegacySodaExceptionHttpStatusContractTest`는 legacy malformed multipart 메시지 계약 변경을 신규 테스트로 고정했다. Phase 1 누락 테스트는 hard-coded ID 전달, missing character, `creatorMember=null`, JsonNode missing-key 경계를 보강했다. + - 리뷰 7차 수정 targeted: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest`, `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.legacy.LegacySodaExceptionHttpStatusContractTest`, `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterAdminTargetResolverTest --tests kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.persistence.DefaultAiCharacterPersistenceAdapterTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest`가 각각 통과했다. 이어서 위 5개 테스트 class를 한 Gradle 명령으로 묶어 재실행해 `BUILD SUCCESSFUL in 1m 2s`를 확인했다. + - 리뷰 7차 최종 검증: reviewer gate의 PNG length overflow와 정상 metadata 오탐 지적을 반영한 뒤 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest`가 `BUILD SUCCESSFUL in 30s`, `./gradlew --no-daemon ktlintCheck`가 `BUILD SUCCESSFUL in 23s`, `./gradlew --no-daemon test`가 `BUILD SUCCESSFUL in 7m 14s`로 통과했다. LSP는 기존과 동일하게 현재 환경에 `kotlin-ls`가 설치되어 있지 않아 실행하지 못했다. + - 리뷰 8차 RED 확인: PNG metadata reader flag, GIF extension 개수·sub-block 개수·누적 frame pixels·초과 LZW 출력 5건이 기존 구현에서 ImageIO 전에 거부되지 않아 `AdminImagePartValidatorTest`의 신규 assertion으로 실패했다. + - 리뷰 8차 수정 targeted: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminSecurityIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminLoginJwtIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminExceptionHandlerTest --tests kr.co.vividnext.sodalive.legacy.LegacySodaExceptionHttpStatusContractTest --tests kr.co.vividnext.sodalive.admin.member.AdminMemberLoginServiceTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AdminPagePolicyTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterAdminTargetResolverTest --tests kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.persistence.DefaultAiCharacterPersistenceAdapterTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest`가 `BUILD SUCCESSFUL in 4m 51s`로 통과했다. 정확한 PNG ancillary/chunk, GIF frame/extension/sub-block/cumulative pixel 및 LZW 조기 종료·연속 clear·EOI 뒤 data 경계와 실제 Spring Boot DispatcherServlet multipart pre-handler admin 400/legacy 200 계약을 포함한다. + - 리뷰 8차 lint: `./gradlew --no-daemon ktlintCheck`가 `BUILD SUCCESSFUL in 1m 3s`로 통과했다. + - 리뷰 8차 full regression: `./gradlew --no-daemon test`가 `BUILD SUCCESSFUL in 14m 7s`로 통과했다. + - LSP: 현재 환경에 `kotlin-ls`가 설치되어 있지 않아 `lsp_diagnostics`는 실행하지 못했다. - DB schema migration: 없음. -- 구현 기준 commit/기존 dirty·untracked manifest: 구현 시작 시 기록. +- 구현 기준 commit/기존 dirty·untracked manifest: 시작 기준 commit `5b700892`, 기존 dirty/untracked 없음. - staging worker/Trigger E2E: 아직 실행하지 않음. - production 배포: 이 계획의 코드 작성 단계만으로 완료 처리하지 않음. @@ -986,4 +1016,6 @@ Phase 0 기존 callback·소비자 계약 고정 - 원작 보강 후 계약 검증: PRD의 JSON code block 51개와 Frontend delta의 label JSON 값 20개(Operation Request/Response 12개, 공통 오류 3개, nullable 값 5개)가 모두 유효하다. PRD와 Frontend delta의 원작 `(Operation ID, Method, Path)`는 8/8, Operation Request/Response JSON의 key·value·배열·null 구조는 12/12 exact equality다. 원작 DTO nullable·배정 응답 의미, legacy mutation 5개와 캐릭터 mutation 2개의 v2 수렴, 잠금·보상·오류 경계는 PRD와 plan에서 일치한다. - 원작 보강 후 Markdown/DB 검증: 세 문서의 code fence 수가 각각 146개, 2개, 2개로 짝이 맞고 trailing whitespace·tab과 SQL 파일은 0건이다. - 원작 보강 후 Gradle 구성 검증: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`이다. +- 리뷰 6차 문서·Gradle 구성 검증: `./gradlew --no-daemon tasks --all`은 `BUILD SUCCESSFUL in 7s`, `git diff --check`와 세 문서의 trailing whitespace·tab 검사는 출력 0건이다. code fence 수는 각각 146개, 2개, 2개로 짝이 맞고 callback 전체 Response를 호환 대상으로 표현하는 잔여 문구도 0건이다. +- 리뷰 8차 문서 검증: Task 1.1/1.2 checkbox를 완료로 갱신했고 `git diff --check`, 세 문서의 trailing whitespace·tab 및 code fence 짝 검사를 실행해 이상 없음을 확인했다. `./gradlew tasks --all`은 기존 검증 결과를 유지하며, 이번 재실행은 Gradle wrapper lock 파일 권한 오류로 실행하지 못했다. - 미실행 외부 Gate: 구현 전 문서 검증 단계이므로 실제 운영 데이터의 삭제 원작 연결 건수, MySQL 8 동시성, staging worker/Trigger E2E와 production 배포는 실행하지 않았다. diff --git a/docs/20260720_AI캐릭터_관리자기능/prd.md b/docs/20260720_AI캐릭터_관리자기능/prd.md index 1d5493aa..34fcbb4e 100644 --- a/docs/20260720_AI캐릭터_관리자기능/prd.md +++ b/docs/20260720_AI캐릭터_관리자기능/prd.md @@ -1506,7 +1506,8 @@ Content-Type: application/json } ``` -- 위 Method, Path, 인증, Request와 Response 계약은 기존 AWS 연동 계약이며 이번 범위에서 변경하지 않는다. +- 위 Method, Path, Request와 `ADMIN`/`BOT`의 성공 Response 계약은 기존 AWS 연동 계약이며 이번 범위에서 변경하지 않는다. +- 인증 정보가 없거나 JWT가 유효하지 않으면 `401`, 인증됐지만 `ADMIN`/`BOT`이 아니면 `403`을 반환한다. 이 인가 status는 유지해야 하는 기존 보안 계약이다. - 이 Endpoint는 기존 API이므로 신규 Operation ID를 부여하거나 12.1의 신규 Endpoint 수에 포함하지 않는다. - v2 콘텐츠 생성도 기존과 같은 `content` 테이블 필드, S3 bucket의 `input/{contentId}/{contentId}-content-...` 경로와 object metadata 계약을 사용한다. object basename은 기존 `generateFileName(prefix = "${contentId}-content")` 규칙을 따르며 metadata는 기존 `generate_preview`, 선택 `preview_start_time`, `preview_end_time`만 전달한다. - v2 생성 여부를 저장하는 DB 컬럼이나 worker metadata를 추가하지 않는다. 기존 callback Controller·Request·Response·Service 호출 흐름에도 V1/V2 dispatcher를 추가하지 않는다. @@ -1970,7 +1971,7 @@ Content-Type: multipart/form-data - `content`는 trim 후 빈 값일 수 없고 `price`는 0 이상이다. - `price > 0`인 유료 게시글은 `postImage`가 필수다. - `audioFile`을 보내는 게시글은 가격과 관계없이 `postImage`가 필수다. -- 업로드 이미지의 실제 MIME type은 `image/*`여야 하며 GIF는 유료 게시글에서만 허용한다. +- 업로드 이미지의 실제 MIME type은 `image/jpeg`, `image/png`, `image/gif` 중 하나여야 하며 GIF는 유료 게시글에서만 허용한다. - `audioFile`은 빈 파일일 수 없다. v2 community web adapter가 파일명 확장자가 아니라 실제 bytes를 검사해 M4A/AAC 계열 MIME type인 `audio/mp4`, `audio/x-m4a`, `audio/aac`만 허용하고, 다른 codec이나 MIME type은 400으로 거부한다. #### Response Data @@ -2004,7 +2005,7 @@ Content-Type: multipart/form-data `postId`와 `isActive`는 body에서 받지 않는다. `price`와 `audioFile`은 등록 후 변경하거나 제거할 수 없다. 선택 `postImage`를 보내면 이미지를 교체하고, 생략하면 기존 이미지를 유지한다. 이미지 제거는 지원하지 않는다. -수정 시에도 `content`는 trim 후 빈 값일 수 없다. 교체 이미지는 실제 MIME type이 `image/*`여야 하며, 기존 게시글의 `price=0`이면 GIF를 허용하지 않는다. 기존 유료 또는 오디오 게시글은 이미지가 없는 상태로 변경할 수 없다. +수정 시에도 `content`는 trim 후 빈 값일 수 없다. 교체 이미지는 실제 MIME type이 `image/jpeg`, `image/png`, `image/gif` 중 하나여야 하며, 기존 게시글의 `price=0`이면 GIF를 허용하지 않는다. 기존 유료 또는 오디오 게시글은 이미지가 없는 상태로 변경할 수 없다. #### Response Data @@ -2658,7 +2659,7 @@ ADMIN JWT 이미 삭제된 원작의 반복 DELETE는 과거 불일치 연결이 남아 있어도 성공 200이므로 위 “연결 캐릭터가 남은 원작 삭제” 409보다 먼저 판정한다. 원작 이미지 보상 삭제가 실패해도 이미 발생한 로컬 transaction 실패의 500을 다른 성공이나 502로 바꾸지 않고 orphan 운영 로그를 남긴다. -기존 `/audio-content/upload-complete`의 인증·오류 응답 계약은 이번 범위에서 변경하지 않는다. 관리자 콘텐츠 조회에서 수행하는 canonical output key 검증은 기존 callback의 Request·Response 또는 오류 envelope를 변경하지 않는다. +기존 `/audio-content/upload-complete`는 인증 정보가 없거나 JWT가 유효하지 않으면 `401`, 인증됐지만 `ADMIN`/`BOT`이 아니면 `403`을 반환한다. 기존 Request와 `ADMIN`/`BOT` 성공 Response는 이번 범위에서 변경하지 않으며, 관리자 콘텐츠 조회에서 수행하는 canonical output key 검증도 callback의 Request·성공 Response를 변경하지 않는다. ## 21. Technical Requirements @@ -2792,7 +2793,7 @@ legacy Service를 호출하지 않더라도 다음 외부 관찰 가능 동작 - 외부 작업이나 보상 결과와 관계없이 DB 변경까지 완료되지 않은 요청은 성공으로 응답하지 않는다. - 파일 업로드 실패 시 부분 DB 리소스를 성공으로 반환하지 않는다. - v2 업로드 요청은 기존 worker가 이미 처리하는 S3 bucket, `input/{contentId}/{contentId}-content-...` key와 metadata 계약을 그대로 사용하며 callback URL, pipeline version 또는 새 분기 정보를 worker에 전달하지 않는다. -- 기존 `/audio-content/upload-complete`의 Method, Path, 인증과 Request/Response가 바뀌지 않는지 contract test로 검증한다. +- 기존 `/audio-content/upload-complete`의 Method, Path, Request와 `ADMIN`/`BOT` 성공 Response가 바뀌지 않고, 인증 정보 없음·유효하지 않은 JWT는 `401`, 인증됐지만 `ADMIN`/`BOT`이 아니면 `403`인지 contract test로 검증한다. - v2에서 생성한 기존 형식의 content row도 현재 `AudioContentService.uploadComplete`와 예약 공개 흐름이 처리하는지 통합 검증한다. - callback은 `releaseDate=null` 또는 연결 creator 비활성인 콘텐츠를 공개하지 않고, 기존 예약 공개 query는 활성 creator만 선택하도록 최소 안전 조건을 보강한다. `AudioContentReleaseScheduledTask`의 cron·lock과 worker 코드는 수정하지 않는다. - 캐릭터 삭제 시 raw `content.isActive=false`가 되므로 일반 사용자용 목록·검색·추천은 기존 공개 조건으로 이를 제외한다. 직접 상세 조회는 비활성 creator 또는 비활성 콘텐츠를 미구매 사용자에게 반환하지 않되, 기존 주문을 확인한 `KEEP`·`RENTAL` 구매자의 재생 경로는 유지한다. @@ -2812,23 +2813,24 @@ legacy Service를 호출하지 않더라도 다음 외부 관찰 가능 동작 |---|---| | `adminMemberId` | 실제 인증된 사람 관리자 | | `characterId` | 선택 AI 캐릭터. global 원작 CRUD에서는 `null`, 배정·해제에서는 캐릭터별 로그에 값 기록 | -| `creatorMemberId` | 연결 크리에이터 Member. global 원작 CRUD에서는 `null`, 배정에서는 캐릭터별 값, legacy 불일치 캐릭터 해제에서는 `null` 가능 | +| `creatorMemberId` | 연결 크리에이터 Member. global 원작 CRUD에서는 `null`, 배정에서는 캐릭터별 값, 해제에서는 해당 Member ID를 확인할 수 있으면 값이고 과거 불일치로 연결 정보가 없거나 해석할 수 없을 때만 `null` 가능 | | `action` | CREATE, UPDATE, DELETE, ASSIGN, UNASSIGN, PIN 등 | | `resourceType` | CHARACTER, ORIGINAL_WORK, ORIGINAL_WORK_CHARACTER, CONTENT, CONTENT_COMMENT, CONTENT_CATEGORY, SERIES, COMMUNITY_POST, COMMUNITY_COMMENT, FAN_TALK, FAN_TALK_REPLY, CHANNEL_NOTICE, CHANNEL_PROFILE | | `resourceId` | 변경 대상 ID | | `result` | SUCCESS 또는 FAILURE | -원작 배정·해제는 변경 캐릭터마다 `resourceType=ORIGINAL_WORK_CHARACTER`, `resourceId=originalWorkId`, 해당 `characterId`와 가능한 경우 `creatorMemberId`를 남긴다. 원작 생성·수정·삭제는 `resourceType=ORIGINAL_WORK`이며 두 캐릭터 field가 `null`이다. +원작 배정·해제는 변경 캐릭터마다 `resourceType=ORIGINAL_WORK_CHARACTER`, `resourceId=originalWorkId`, 해당 `characterId`와 가능한 경우 `creatorMemberId`를 남긴다. 원작 생성·수정·삭제는 `resourceType=ORIGINAL_WORK`이며 두 캐릭터 field가 `null`이다. 정상 해제와 과거 불일치 해제를 별도 command나 audit factory로 구분하지 않는다. 해제 호출자는 캐릭터에서 `creatorMemberId`를 확인할 수 있으면 반드시 전달하고, 연결 정보가 없거나 해석할 수 없는 과거 불일치 상태에서만 `null`을 전달한다. 영속 audit table 도입은 이번 범위가 아니며 구조화 application log를 요구한다. 비밀번호, JWT, system prompt 전체, 댓글 본문 또는 업로드 파일 내용은 로그에 기록하지 않는다. ### 21.7 Security - `/admin/ai-characters/**` Controller는 class level에서 `hasRole('ADMIN')`을 선언한다. -- 기존 `/audio-content/upload-complete`의 `hasAnyRole('BOT', 'ADMIN')`과 외부 응답 계약은 변경하지 않는다. +- 기존 `/audio-content/upload-complete`의 `hasAnyRole('BOT', 'ADMIN')`을 유지한다. 인증 정보가 없거나 JWT가 유효하지 않으면 `401`, 인증됐지만 `ADMIN`/`BOT`이 아니면 `403`이며, `ADMIN`/`BOT`의 기존 성공 응답 계약은 변경하지 않는다. - `/admin/ai-characters/**` RequestMatcher에만 적용되는 authentication entry point, access-denied handler 및 exception response 경계를 두어 legacy API의 HTTP status를 변경하지 않고 20장의 status와 `ApiResponse` 계약을 보장한다. - 클라이언트 메뉴·route guard 테스트와 Backend API 인가 테스트를 별도로 작성한다. -- Multipart 요청은 애플리케이션의 `max-file-size=1024MB`, `max-request-size=1024MB` 상한을 적용한다. 이미지 part는 공통 이미지 검증기를 v2 web adapter에서 사용해 실제 MIME type을 검증한다. GIF는 API에 별도 허용 조건이 있는 유료 커뮤니티 게시글 이미지만 허용하고, 원작·캐릭터·콘텐츠 커버·시리즈 이미지를 포함한 나머지 image part에서는 거부한다. +- Multipart JSON string part는 단일 JSON root만 허용하고 뒤에 이어진 추가 root나 garbage를 400으로 거부한다. +- Multipart 요청은 애플리케이션의 `max-file-size=1024MB`, `max-request-size=1024MB` 상한을 적용한다. 이미지 part는 공통 이미지 검증기를 v2 web adapter에서 사용해 bytes 적재 전에 10MB 초과를 거부하고, 실제 MIME type이 `image/jpeg`, `image/png`, `image/gif` 중 하나인지 검증한다. 실제 format 확인 decode는 출력 영역을 1x1로 제한하되 내부 decoder row/loop 때문에 한 변은 최대 20,000px, 총 픽셀은 최대 40,000,000 pixels로 제한한다. PNG는 ancillary payload 합계 1MB와 chunk 4,096개를 상한으로 두고 ImageIO 입력을 `ignoreMetadata=true`로 설정해 metadata를 읽지 않는다. GIF는 최대 500 frame, extension 1,024개, extension당 sub-block 64개, extension payload 합계 1MB, 모든 frame의 누적 40,000,000 pixels를 상한으로 둔다. GIF logical canvas와 모든 frame header에도 같은 dimension 상한을 적용하고 각 frame의 LZW 출력 pixel 수가 선언된 width와 height의 곱과 정확히 일치해야 한다. 조기 EOI·연속 clear·EOI 뒤 data가 있는 LZW와 첫 frame이 정상이지만 후속 frame decode가 손상된 입력은 거부한다. 첫 frame뿐 아니라 모든 frame을 각각 1x1 출력 영역으로 decode해 malformed header/frame을 400으로 거부한다. GIF는 API에 별도 허용 조건이 있는 유료 커뮤니티 게시글 이미지만 허용하고, 원작·캐릭터·콘텐츠 커버·시리즈 이미지를 포함한 나머지 image part에서는 거부한다. - 콘텐츠 원본 오디오는 빈 파일을 거부하고 worker에 전달한다. 지원 codec과 재생 가능 여부는 worker가 검증하며 실패한 콘텐츠를 `PUBLISHED`로 전환하지 않는다. - 관리자 응답에서 system prompt는 캐릭터 상세에만 포함하며 목록에는 포함하지 않는다. - `CONTENT-01`, `CONTENT-02`는 Signed URL이 포함될 수 있으므로 `Cache-Control: private, no-store`를 반환한다. @@ -2911,7 +2913,7 @@ legacy Service를 호출하지 않더라도 다음 외부 관찰 가능 동작 - [ ] 실제 관리자와 대행 AI 캐릭터를 구분하는 구조화 로그가 남는다. - [ ] v2 비즈니스 로직과 persistence adapter가 legacy Controller, Service, Repository 또는 web DTO를 호출하지 않는다. - [ ] 신규 upload-complete Endpoint를 만들지 않고 기존 AWS S3 Trigger worker의 코드·스케줄·설정을 변경하지 않는다. -- [ ] 기존 `/audio-content/upload-complete`의 Method, Path, 인증과 Request/Response가 그대로 유지된다. +- [ ] 기존 `/audio-content/upload-complete`의 Method, Path, Request와 `ADMIN`/`BOT` 성공 Response가 유지되고 인증 정보 없음·유효하지 않은 JWT는 `401`, 인증됐지만 `ADMIN`/`BOT`이 아니면 `403`을 반환한다. - [ ] v2 콘텐츠 생성이 기존 `content` row와 S3 key·metadata 계약을 따르고 별도 V1/V2 callback 분기 없이 기존 callback과 예약 공개 흐름으로 처리된다. - [ ] CONTENT-03 원본 object가 `input/{contentId}/{contentId}-content-...` key를 사용하고 worker 결과 basename이 기존 callback의 content ID 검증을 통과한다. - [ ] staging에서 v2 S3 input 저장부터 기존 callback 완료까지 E2E가 통과하고 DB commit 전 callback 도착 시 worker retry 동작이 확인된다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaException.kt b/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaException.kt index 86046fdc..96bf7e7e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaException.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaException.kt @@ -1,9 +1,12 @@ package kr.co.vividnext.sodalive.common +import org.springframework.http.HttpStatus + class SodaException( message: String? = null, val errorProperty: String? = null, - val messageKey: String? = null + val messageKey: String? = null, + val httpStatus: HttpStatus? = null ) : RuntimeException(message) class AdsChargeException( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt index ecb706c0..2ebdfae8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt @@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource import org.slf4j.LoggerFactory import org.springframework.dao.DataIntegrityViolationException import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity import org.springframework.security.access.AccessDeniedException import org.springframework.security.authentication.BadCredentialsException import org.springframework.security.authentication.InternalAuthenticationServiceException @@ -13,7 +14,9 @@ import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.multipart.MaxUploadSizeExceededException +import org.springframework.web.multipart.MultipartException import org.springframework.web.server.ResponseStatusException +import javax.servlet.http.HttpServletRequest @RestControllerAdvice class SodaExceptionHandler( @@ -38,12 +41,25 @@ class SodaExceptionHandler( ) } - @ExceptionHandler(MaxUploadSizeExceededException::class) - fun handleMaxUploadSizeExceededException(e: MaxUploadSizeExceededException) = run { - val logMessage = messageSource.getMessage("common.error.max_upload_size", logLang) + @ExceptionHandler(MaxUploadSizeExceededException::class, MultipartException::class) + fun handleMultipartException(e: MultipartException, request: HttpServletRequest) = run { + val isAiCharacterAdminPath = isAiCharacterAdminPath(request.requestURI) + val messageKey = if (e is MaxUploadSizeExceededException) { + "common.error.max_upload_size" + } else if (isAiCharacterAdminPath) { + "common.error.invalid_request" + } else { + "common.error.unknown" + } + val logMessage = messageSource.getMessage(messageKey, logLang) logger.error("API error: {}", logMessage, e) - val message = messageSource.getMessage("common.error.max_upload_size", langContext.lang) - ApiResponse.error(message = message) + val message = messageSource.getMessage(messageKey, langContext.lang) + val body = ApiResponse.error(message = message) + if (isAiCharacterAdminPath) { + ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body) + } else { + body + } } @ExceptionHandler(AccessDeniedException::class) @@ -99,4 +115,12 @@ class SodaExceptionHandler( val message = messageSource.getMessage("common.error.unknown", langContext.lang) ApiResponse.error(message) } + + private fun isAiCharacterAdminPath(requestUri: String): Boolean { + return requestUri == AI_CHARACTER_ADMIN_PATH_PREFIX || requestUri.startsWith("$AI_CHARACTER_ADMIN_PATH_PREFIX/") + } + + companion object { + private const val AI_CHARACTER_ADMIN_PATH_PREFIX = "/admin/ai-characters" + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt index 99ff4572..e3c96b9d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -6,6 +6,9 @@ import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint import kr.co.vividnext.sodalive.jwt.JwtFilter import kr.co.vividnext.sodalive.jwt.TokenProvider +import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAccessDeniedHandler +import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAuthenticationEntryPoint +import org.springframework.beans.factory.ObjectProvider import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.http.HttpMethod @@ -27,7 +30,9 @@ class SecurityConfig( private val objectMapper: ObjectMapper, private val tokenProvider: TokenProvider, private val accessDeniedHandler: JwtAccessDeniedHandler, - private val authenticationEntryPoint: JwtAuthenticationEntryPoint + private val authenticationEntryPoint: JwtAuthenticationEntryPoint, + private val aiCharacterAdminAuthenticationEntryPoint: ObjectProvider, + private val aiCharacterAdminAccessDeniedHandler: ObjectProvider ) { @Bean fun passwordEncoder(): PasswordEncoder { @@ -52,8 +57,22 @@ class SecurityConfig( .and() .csrf().disable() .exceptionHandling() - .authenticationEntryPoint(authenticationEntryPoint) - .accessDeniedHandler(accessDeniedHandler) + .authenticationEntryPoint { request, response, authException -> + val adminEntryPoint = aiCharacterAdminAuthenticationEntryPoint.getIfAvailable() + if (isAiCharacterAdminPath(request.requestURI) && adminEntryPoint != null) { + adminEntryPoint.commence(request, response, authException) + } else { + authenticationEntryPoint.commence(request, response, authException) + } + } + .accessDeniedHandler { request, response, accessDeniedException -> + val adminAccessDeniedHandler = aiCharacterAdminAccessDeniedHandler.getIfAvailable() + if (isAiCharacterAdminPath(request.requestURI) && adminAccessDeniedHandler != null) { + adminAccessDeniedHandler.handle(request, response, accessDeniedException) + } else { + accessDeniedHandler.handle(request, response, accessDeniedException) + } + } .and() .headers() .frameOptions() @@ -100,6 +119,7 @@ class SecurityConfig( .antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll() + .antMatchers(HttpMethod.PUT, "/audio-content/upload-complete").hasAnyRole("ADMIN", "BOT") .antMatchers(HttpMethod.POST, "/charge/payverse/webhook").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/home/recommendations").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/audio/recommendations").permitAll() @@ -110,8 +130,17 @@ class SecurityConfig( .antMatchers(HttpMethod.GET, "/api/v2/home/on-air-lives").authenticated() // 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수 .antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated() + .antMatchers(AI_CHARACTER_ADMIN_PATH_PREFIX, "$AI_CHARACTER_ADMIN_PATH_PREFIX/**").hasRole("ADMIN") .anyRequest().authenticated() .and() .build() } + + private fun isAiCharacterAdminPath(requestUri: String): Boolean { + return requestUri == AI_CHARACTER_ADMIN_PATH_PREFIX || requestUri.startsWith("$AI_CHARACTER_ADMIN_PATH_PREFIX/") + } + + companion object { + private const val AI_CHARACTER_ADMIN_PATH_PREFIX = "/admin/ai-characters" + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAccessDeniedHandler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAccessDeniedHandler.kt new file mode 100644 index 00000000..bb455bb4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAccessDeniedHandler.kt @@ -0,0 +1,27 @@ +package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.common.ApiResponse +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.web.access.AccessDeniedHandler +import org.springframework.stereotype.Component +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +@Component +class AiCharacterAdminAccessDeniedHandler( + private val objectMapper: ObjectMapper +) : AccessDeniedHandler { + override fun handle( + request: HttpServletRequest, + response: HttpServletResponse, + accessDeniedException: AccessDeniedException + ) { + response.status = HttpStatus.FORBIDDEN.value() + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.characterEncoding = Charsets.UTF_8.name() + response.writer.write(objectMapper.writeValueAsString(ApiResponse.error("권한이 없습니다."))) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAuthenticationEntryPoint.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAuthenticationEntryPoint.kt new file mode 100644 index 00000000..2e13f7d8 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAuthenticationEntryPoint.kt @@ -0,0 +1,27 @@ +package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.common.ApiResponse +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.stereotype.Component +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +@Component +class AiCharacterAdminAuthenticationEntryPoint( + private val objectMapper: ObjectMapper +) : AuthenticationEntryPoint { + override fun commence( + request: HttpServletRequest, + response: HttpServletResponse, + authException: AuthenticationException + ) { + response.status = HttpStatus.UNAUTHORIZED.value() + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.characterEncoding = Charsets.UTF_8.name() + response.writer.write(objectMapper.writeValueAsString(ApiResponse.error("로그인 정보를 확인해주세요."))) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminImagePartValidator.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminImagePartValidator.kt new file mode 100644 index 00000000..aeb3e4fa --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminImagePartValidator.kt @@ -0,0 +1,395 @@ +package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web + +import kr.co.vividnext.sodalive.common.SodaException +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.web.multipart.MultipartFile +import java.awt.Rectangle +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import javax.imageio.ImageIO + +@Component +class AdminImagePartValidator { + fun validate(image: MultipartFile, allowGif: Boolean): AdminValidatedImage { + if (image.isEmpty) { + throw invalidImage(image.name) + } + if (image.size > MAX_IMAGE_BYTES) { + throw invalidImage(image.name) + } + + val bytes = image.bytes + val signatureFormat = detectSignatureFormat(bytes) ?: throw invalidImage(image.name) + if (signatureFormat == "gif" && !allowGif) { + throw invalidImage(image.name) + } + if (!hasBoundedContainer(bytes = bytes, format = signatureFormat)) { + throw invalidImage(image.name) + } + val format = detectFormat(bytes) ?: throw invalidImage(image.name) + + val contentType = when (format) { + "jpg" -> "image/jpeg" + else -> "image/$format" + } + + return AdminValidatedImage( + bytes = bytes, + contentType = contentType, + extension = format + ) + } + + private fun detectSignatureFormat(bytes: ByteArray): String? { + return when { + bytes.size >= PNG_SIGNATURE.size && bytes.take(PNG_SIGNATURE.size) == PNG_SIGNATURE.toList() -> "png" + bytes.size >= 3 && bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() && bytes[2] == 0xFF.toByte() -> "jpg" + bytes.size >= 6 && bytes.copyOfRange(0, 6).decodeToString() in setOf("GIF87a", "GIF89a") -> "gif" + else -> null + } + } + + private fun detectFormat(bytes: ByteArray): String? { + ImageIO.createImageInputStream(ByteArrayInputStream(bytes)).use { stream -> + if (stream == null) return null + return try { + val readers = ImageIO.getImageReaders(stream) + if (!readers.hasNext()) return null + val reader = readers.next() + try { + reader.setInput(stream, false, true) + val format = normalizeFormat(reader.formatName) ?: return null + val frameCount = validatedFrameCount(reader = reader, format = format, bytes = bytes) ?: return null + + repeat(frameCount) { index -> + val readParam = reader.defaultReadParam + readParam.sourceRegion = Rectangle(0, 0, 1, 1) + reader.read(index, readParam) ?: return null + } + format + } finally { + reader.dispose() + } + } catch (e: java.io.IOException) { + null + } catch (e: IndexOutOfBoundsException) { + null + } catch (e: IllegalArgumentException) { + null + } catch (e: RuntimeException) { + null + } + } + } + + private fun hasBoundedContainer(bytes: ByteArray, format: String): Boolean { + return when (format) { + "png" -> hasBoundedPngChunks(bytes) + "gif" -> hasBoundedGifBlocks(bytes) + else -> true + } + } + + private fun hasBoundedPngChunks(bytes: ByteArray): Boolean { + var offset = PNG_SIGNATURE.size + var chunkCount = 0 + var ancillaryBytes = 0L + + while (offset + PNG_CHUNK_HEADER_BYTES + PNG_CHUNK_CRC_BYTES <= bytes.size) { + if (++chunkCount > MAX_PNG_CHUNKS) return false + val length = readBigEndianInt(bytes = bytes, offset = offset) + if (length < 0) return false + val typeOffset = offset + 4 + val dataOffset = typeOffset + 4 + val nextOffset = dataOffset.toLong() + length.toLong() + PNG_CHUNK_CRC_BYTES.toLong() + if (nextOffset > bytes.size) return false + + val isAncillary = (bytes[typeOffset].toInt() and 0x20) != 0 + if (isAncillary) { + ancillaryBytes += length.toLong() + if (length > MAX_IMAGE_METADATA_BYTES || ancillaryBytes > MAX_IMAGE_METADATA_BYTES) return false + } + if (bytes.copyOfRange(typeOffset, typeOffset + 4).decodeToString() == "IEND") return nextOffset == bytes.size.toLong() + offset = nextOffset.toInt() + } + + return false + } + + private fun hasBoundedGifBlocks(bytes: ByteArray): Boolean { + if (bytes.size < GIF_LOGICAL_SCREEN_END_OFFSET + 3) return false + var offset = GIF_LOGICAL_SCREEN_END_OFFSET + val packed = bytes[offset].toInt() and 0xFF + offset += 3 + if ((packed and 0x80) != 0) { + offset += 3 * (1 shl ((packed and 0x07) + 1)) + if (offset > bytes.size) return false + } + + var frameCount = 0 + var extensionCount = 0 + var extensionBytes = 0L + var framePixels = 0L + while (offset < bytes.size) { + when (bytes[offset++].toInt() and 0xFF) { + 0x2C -> { + if (++frameCount > MAX_GIF_FRAMES || offset + GIF_IMAGE_DESCRIPTOR_BYTES > bytes.size) return false + val width = readLittleEndianUnsignedShort(bytes = bytes, offset = offset + 4) + val height = readLittleEndianUnsignedShort(bytes = bytes, offset = offset + 6) + if (!hasValidDimensions(width = width, height = height)) return false + framePixels += width.toLong() * height + if (framePixels > MAX_IMAGE_PIXELS) return false + + val imagePacked = bytes[offset + 8].toInt() and 0xFF + offset += GIF_IMAGE_DESCRIPTOR_BYTES + if ((imagePacked and 0x80) != 0) { + offset += 3 * (1 shl ((imagePacked and 0x07) + 1)) + if (offset > bytes.size) return false + } + if (offset >= bytes.size) return false + val minimumCodeSize = bytes[offset++].toInt() and 0xFF + val scanned = scanGifSubBlocks(bytes = bytes, offset = offset, maxBlocks = null, collectPayload = true) + ?: return false + val payload = scanned.payload ?: return false + if (!hasExactGifLzwPixelCount(payload, minimumCodeSize, width.toLong() * height)) return false + offset = scanned.nextOffset + } + 0x21 -> { + if (++extensionCount > MAX_GIF_EXTENSIONS || offset >= bytes.size) return false + val label = bytes[offset++].toInt() and 0xFF + val scanned = when (label) { + 0xF9 -> scanGifGraphicControlExtension(bytes, offset) + 0x01 -> scanGifFixedHeaderExtension(bytes, offset, fixedHeaderSize = 12) + 0xFF -> scanGifFixedHeaderExtension(bytes, offset, fixedHeaderSize = 11) + else -> scanGifSubBlocks( + bytes = bytes, + offset = offset, + maxBlocks = MAX_GIF_EXTENSION_SUB_BLOCKS, + collectPayload = false + ) + } ?: return false + extensionBytes += scanned.payloadBytes + if (extensionBytes > MAX_IMAGE_METADATA_BYTES) return false + offset = scanned.nextOffset + } + 0x3B -> return offset == bytes.size && frameCount > 0 + else -> return false + } + } + + return false + } + + private fun scanGifSubBlocks( + bytes: ByteArray, + offset: Int, + maxBlocks: Int?, + collectPayload: Boolean + ): GifSubBlockScan? { + var currentOffset = offset + var payloadBytes = 0L + var blockCount = 0 + val payload = if (collectPayload) ByteArrayOutputStream() else null + while (currentOffset < bytes.size) { + val size = bytes[currentOffset++].toInt() and 0xFF + if (size == 0) { + return GifSubBlockScan( + nextOffset = currentOffset, + payloadBytes = payloadBytes, + payload = payload?.toByteArray() + ) + } + if (++blockCount > (maxBlocks ?: Int.MAX_VALUE)) return null + if (currentOffset + size > bytes.size) return null + payloadBytes += size.toLong() + payload?.write(bytes, currentOffset, size) + currentOffset += size + } + + return null + } + + private fun scanGifGraphicControlExtension(bytes: ByteArray, offset: Int): GifSubBlockScan? { + if (offset + 5 >= bytes.size || (bytes[offset].toInt() and 0xFF) != 4) return null + if (bytes[offset + 5].toInt() != 0) return null + return GifSubBlockScan(nextOffset = offset + 6, payloadBytes = 4, payload = null) + } + + private fun scanGifFixedHeaderExtension(bytes: ByteArray, offset: Int, fixedHeaderSize: Int): GifSubBlockScan? { + if (offset >= bytes.size || (bytes[offset].toInt() and 0xFF) != fixedHeaderSize) return null + val scanned = scanGifSubBlocks( + bytes = bytes, + offset = offset + fixedHeaderSize + 1, + maxBlocks = MAX_GIF_EXTENSION_SUB_BLOCKS, + collectPayload = false + ) ?: return null + return scanned.copy(payloadBytes = scanned.payloadBytes + fixedHeaderSize) + } + + private fun hasExactGifLzwPixelCount(data: ByteArray, minimumCodeSize: Int, expectedPixels: Long): Boolean { + if (minimumCodeSize !in MIN_GIF_LZW_CODE_SIZE..MAX_GIF_LZW_CODE_SIZE) return false + + val clearCode = 1 shl minimumCodeSize + val endOfInformationCode = clearCode + 1 + val codeLengths = IntArray(MAX_GIF_LZW_TABLE_SIZE) + repeat(clearCode) { codeLengths[it] = 1 } + val bitReader = GifLzwBitReader(data) + val maxCodes = expectedPixels * 2L + 2L + var codeCount = 0L + var codeSize = minimumCodeSize + 1 + var nextCode = endOfInformationCode + 1 + var previousCode = -1 + var decodedPixels = 0L + + while (++codeCount <= maxCodes) { + val code = bitReader.read(codeSize) ?: return false + when { + code == clearCode -> { + if (previousCode < 0 && codeCount > 1) return false + codeSize = minimumCodeSize + 1 + nextCode = endOfInformationCode + 1 + previousCode = -1 + } + code == endOfInformationCode -> { + return decodedPixels == expectedPixels && bitReader.hasOnlyPaddingBits() + } + previousCode < 0 -> { + if (code >= clearCode) return false + decodedPixels++ + if (decodedPixels > expectedPixels) return false + previousCode = code + } + else -> { + val decodedLength = when { + code < clearCode -> 1 + code < nextCode && codeLengths[code] > 0 -> codeLengths[code] + code == nextCode && nextCode < MAX_GIF_LZW_TABLE_SIZE -> codeLengths[previousCode] + 1 + else -> return false + } + decodedPixels += decodedLength + if (decodedPixels > expectedPixels) return false + + if (nextCode < MAX_GIF_LZW_TABLE_SIZE) { + codeLengths[nextCode++] = codeLengths[previousCode] + 1 + if (nextCode == (1 shl codeSize) && codeSize < MAX_GIF_LZW_BITS) codeSize++ + } + previousCode = code + } + } + } + + return false + } + + private fun validatedFrameCount(reader: javax.imageio.ImageReader, format: String, bytes: ByteArray): Int? { + if (format != "gif") { + return 1.takeIf { hasValidDimensions(width = reader.getWidth(0), height = reader.getHeight(0)) } + } + + if (bytes.size < GIF_LOGICAL_SCREEN_END_OFFSET) return null + val logicalWidth = readLittleEndianUnsignedShort(bytes = bytes, offset = GIF_LOGICAL_SCREEN_WIDTH_OFFSET) + val logicalHeight = readLittleEndianUnsignedShort(bytes = bytes, offset = GIF_LOGICAL_SCREEN_HEIGHT_OFFSET) + if (!hasValidDimensions(width = logicalWidth, height = logicalHeight)) return null + + val frameCount = reader.getNumImages(true) + if (frameCount < 1) return null + val hasInvalidFrame = (0 until frameCount).any { index -> + !hasValidDimensions(width = reader.getWidth(index), height = reader.getHeight(index)) + } + if (hasInvalidFrame) return null + return frameCount + } + + private fun hasValidDimensions(width: Int, height: Int): Boolean { + return width in 1..MAX_IMAGE_DIMENSION && + height in 1..MAX_IMAGE_DIMENSION && + width.toLong() * height <= MAX_IMAGE_PIXELS + } + + private fun readLittleEndianUnsignedShort(bytes: ByteArray, offset: Int): Int { + return (bytes[offset].toInt() and 0xFF) or ((bytes[offset + 1].toInt() and 0xFF) shl 8) + } + + private fun readBigEndianInt(bytes: ByteArray, offset: Int): Int { + return ((bytes[offset].toInt() and 0xFF) shl 24) or + ((bytes[offset + 1].toInt() and 0xFF) shl 16) or + ((bytes[offset + 2].toInt() and 0xFF) shl 8) or + (bytes[offset + 3].toInt() and 0xFF) + } + + private fun normalizeFormat(format: String): String? { + return when (format.lowercase()) { + "jpeg" -> "jpg" + "png", "jpg", "gif" -> format.lowercase() + else -> null + } + } + + private fun invalidImage(errorProperty: String): SodaException { + return SodaException( + messageKey = "admin.chat.character.image_format_invalid", + errorProperty = errorProperty.ifBlank { "image" }, + httpStatus = HttpStatus.BAD_REQUEST + ) + } + + companion object { + private const val MAX_IMAGE_BYTES = 10L * 1024L * 1024L + private const val MAX_IMAGE_METADATA_BYTES = 1024 * 1024 + private const val MAX_IMAGE_DIMENSION = 20_000 + private const val MAX_IMAGE_PIXELS = 40_000_000L + private const val MAX_PNG_CHUNKS = 4096 + private const val MAX_GIF_FRAMES = 500 + private const val MAX_GIF_EXTENSIONS = 1024 + private const val MAX_GIF_EXTENSION_SUB_BLOCKS = 64 + private const val MIN_GIF_LZW_CODE_SIZE = 2 + private const val MAX_GIF_LZW_CODE_SIZE = 8 + private const val MAX_GIF_LZW_BITS = 12 + private const val MAX_GIF_LZW_TABLE_SIZE = 1 shl MAX_GIF_LZW_BITS + private const val PNG_CHUNK_HEADER_BYTES = 8 + private const val PNG_CHUNK_CRC_BYTES = 4 + private const val GIF_LOGICAL_SCREEN_WIDTH_OFFSET = 6 + private const val GIF_LOGICAL_SCREEN_HEIGHT_OFFSET = 8 + private const val GIF_LOGICAL_SCREEN_END_OFFSET = 10 + private const val GIF_IMAGE_DESCRIPTOR_BYTES = 9 + private val PNG_SIGNATURE = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) + } +} + +private data class GifSubBlockScan( + val nextOffset: Int, + val payloadBytes: Long, + val payload: ByteArray? +) + +private class GifLzwBitReader( + private val data: ByteArray +) { + private var offset = 0 + private var bitBuffer = 0L + private var bufferedBits = 0 + + fun read(bitCount: Int): Int? { + while (bufferedBits < bitCount) { + if (offset >= data.size) return null + bitBuffer = bitBuffer or ((data[offset++].toInt() and 0xFF).toLong() shl bufferedBits) + bufferedBits += 8 + } + + val code = (bitBuffer and ((1L shl bitCount) - 1L)).toInt() + bitBuffer = bitBuffer ushr bitCount + bufferedBits -= bitCount + return code + } + + fun hasOnlyPaddingBits(): Boolean { + return offset == data.size && bufferedBits < 8 + } +} + +data class AdminValidatedImage( + val bytes: ByteArray, + val contentType: String, + val extension: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminJsonRequestParser.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminJsonRequestParser.kt new file mode 100644 index 00000000..ec24537f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminJsonRequestParser.kt @@ -0,0 +1,49 @@ +package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import kr.co.vividnext.sodalive.common.SodaException +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component + +@Component +class AdminJsonRequestParser(private val objectMapper: ObjectMapper) { + fun parseRequiredObject(rawJson: String, requiredKeys: Set): ObjectNode { + val node: JsonNode = try { + objectMapper.reader() + .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .readTree(rawJson) + } catch (e: Exception) { + throw SodaException( + messageKey = "common.error.invalid_request", + errorProperty = "request", + httpStatus = HttpStatus.BAD_REQUEST + ) + } + + return parseRequiredObject(node = node, requiredKeys = requiredKeys) + } + + fun parseRequiredObject(node: JsonNode, requiredKeys: Set): ObjectNode { + if (!node.isObject) { + throw SodaException( + messageKey = "common.error.invalid_request", + errorProperty = "request", + httpStatus = HttpStatus.BAD_REQUEST + ) + } + + val objectNode = node as ObjectNode + requiredKeys.firstOrNull { key -> !objectNode.has(key) }?.let { key -> + throw SodaException( + messageKey = "common.error.invalid_request", + errorProperty = key, + httpStatus = HttpStatus.BAD_REQUEST + ) + } + + return objectNode + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandler.kt new file mode 100644 index 00000000..4b48824a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandler.kt @@ -0,0 +1,106 @@ +package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web + +import com.fasterxml.jackson.databind.JsonMappingException +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import org.slf4j.LoggerFactory +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.security.access.AccessDeniedException +import org.springframework.web.bind.MissingServletRequestParameterException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException +import org.springframework.web.multipart.MultipartException +import org.springframework.web.multipart.support.MissingServletRequestPartException + +@RestControllerAdvice(basePackages = ["kr.co.vividnext.sodalive.v2.admin.aicharacter"]) +@Order(Ordered.HIGHEST_PRECEDENCE) +class AiCharacterAdminExceptionHandler( + private val langContext: LangContext, + private val messageSource: SodaMessageSource +) { + private val logger = LoggerFactory.getLogger(javaClass) + + @ExceptionHandler(SodaException::class) + fun handleSodaException(e: SodaException): ResponseEntity> { + val message = resolveMessage(e, langContext.lang) + logger.warn( + "AI character admin API error status={} errorProperty={} error={}", + e.httpStatus ?: HttpStatus.BAD_REQUEST, + e.errorProperty, + e.javaClass.simpleName + ) + return ResponseEntity + .status(e.httpStatus ?: HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(message = message, errorProperty = e.errorProperty)) + } + + @ExceptionHandler(AccessDeniedException::class) + fun handleAccessDeniedException(e: AccessDeniedException): ResponseEntity> { + val message = messageSource.getMessage("common.error.access_denied", langContext.lang) ?: "You do not have permission." + logger.warn("AI character admin API access denied error={}", e.javaClass.simpleName) + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error(message = message)) + } + + @ExceptionHandler( + HttpMessageNotReadableException::class, + MethodArgumentTypeMismatchException::class, + MissingServletRequestParameterException::class, + MissingServletRequestPartException::class, + MultipartException::class + ) + fun handleBadRequestException(e: Exception): ResponseEntity> { + val message = messageSource.getMessage("common.error.invalid_request", langContext.lang) ?: "Invalid request." + val errorProperty = resolveBadRequestErrorProperty(e) + logger.warn("AI character admin bad request error={} errorProperty={}", e.javaClass.simpleName, errorProperty) + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(message = message, errorProperty = errorProperty)) + } + + @ExceptionHandler(Exception::class) + fun handleException(e: Exception): ResponseEntity> { + val message = messageSource.getMessage("common.error.unknown", langContext.lang) ?: DEFAULT_UNKNOWN_MESSAGE + logger.error("AI character admin API error error={}", e.javaClass.simpleName) + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse.error(message = message)) + } + + private fun resolveBadRequestErrorProperty(e: Exception): String? { + return when (e) { + is MethodArgumentTypeMismatchException -> e.name + is MissingServletRequestParameterException -> e.parameterName + is MissingServletRequestPartException -> e.requestPartName + is HttpMessageNotReadableException -> resolveUnreadableMessageErrorProperty(e) + else -> null + } + } + + private fun resolveUnreadableMessageErrorProperty(e: HttpMessageNotReadableException): String { + val mappingException = generateSequence(e.cause) { it.cause } + .filterIsInstance() + .firstOrNull() + return mappingException?.path + ?.asSequence() + ?.mapNotNull { it.fieldName?.takeIf(String::isNotBlank) } + ?.firstOrNull() + ?: "request" + } + + private fun resolveMessage(e: SodaException, lang: Lang): String { + return e.messageKey?.takeIf { it.isNotBlank() }?.let { messageSource.getMessage(it, lang) } + ?: e.message?.takeIf { it.isNotBlank() }.orEmpty().ifBlank { null } + ?: messageSource.getMessage("common.error.unknown", lang) + ?: DEFAULT_UNKNOWN_MESSAGE + } + + companion object { + private const val DEFAULT_UNKNOWN_MESSAGE = "An unknown error occurred." + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicy.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicy.kt new file mode 100644 index 00000000..fe2c45ed --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicy.kt @@ -0,0 +1,26 @@ +package kr.co.vividnext.sodalive.v2.admin.aicharacter.application + +import kr.co.vividnext.sodalive.v2.admin.aicharacter.dto.AdminPageRequest + +object AdminPagePolicy { + fun normalize(page: Int?, size: Int?): AdminPageRequest { + val normalizedPage = page?.coerceAtLeast(0) ?: 0 + val normalizedSize = when { + size == null -> DEFAULT_SIZE + size < MIN_SIZE -> DEFAULT_SIZE + size > MAX_SIZE -> MAX_SIZE + else -> size + } + + return AdminPageRequest( + page = normalizedPage, + size = normalizedSize, + offset = normalizedPage.toLong() * normalizedSize, + limit = normalizedSize.toLong() + ) + } + + private const val DEFAULT_SIZE = 20 + private const val MIN_SIZE = 1 + private const val MAX_SIZE = 50 +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLogger.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLogger.kt new file mode 100644 index 00000000..b6a52684 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLogger.kt @@ -0,0 +1,128 @@ +package kr.co.vividnext.sodalive.v2.admin.aicharacter.application + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component + +@Component +class AiCharacterAdminAuditLogger { + private val logger = LoggerFactory.getLogger(javaClass) + + fun logSuccess(context: AiCharacterAdminAuditContext) { + logger.info( + "aiCharacterAdminAudit result={} adminMemberId={} characterId={} " + + "creatorMemberId={} action={} resourceType={} resourceId={}", + AiCharacterAdminAuditResult.SUCCESS, + context.adminMemberId, + context.characterId, + context.creatorMemberId, + context.action, + context.resourceType, + context.resourceId + ) + } + + fun logFailure(context: AiCharacterAdminAuditContext, exception: Exception) { + logger.warn( + "aiCharacterAdminAudit result={} adminMemberId={} characterId={} " + + "creatorMemberId={} action={} resourceType={} resourceId={} error={}", + AiCharacterAdminAuditResult.FAILURE, + context.adminMemberId, + context.characterId, + context.creatorMemberId, + context.action, + context.resourceType, + context.resourceId, + exception.javaClass.simpleName + ) + } +} + +class AiCharacterAdminAuditContext private constructor( + val adminMemberId: Long, + val characterId: Long?, + val creatorMemberId: Long?, + val action: AiCharacterAdminAuditAction, + val resourceType: AiCharacterAdminAuditResourceType, + val resourceId: Long? +) { + companion object { + fun globalOriginalWork( + adminMemberId: Long, + action: AiCharacterAdminAuditAction, + resourceType: AiCharacterAdminAuditResourceType, + resourceId: Long? + ): AiCharacterAdminAuditContext { + require(resourceType == AiCharacterAdminAuditResourceType.ORIGINAL_WORK) + require( + action == AiCharacterAdminAuditAction.CREATE || + action == AiCharacterAdminAuditAction.UPDATE || + action == AiCharacterAdminAuditAction.DELETE + ) + return AiCharacterAdminAuditContext( + adminMemberId = adminMemberId, + characterId = null, + creatorMemberId = null, + action = action, + resourceType = resourceType, + resourceId = resourceId + ) + } + + fun characterScoped( + adminMemberId: Long, + characterId: Long, + creatorMemberId: Long?, + action: AiCharacterAdminAuditAction, + resourceType: AiCharacterAdminAuditResourceType, + resourceId: Long? + ): AiCharacterAdminAuditContext { + val isAssignmentAction = + action == AiCharacterAdminAuditAction.ASSIGN || action == AiCharacterAdminAuditAction.UNASSIGN + val isOriginalWorkCharacter = resourceType == AiCharacterAdminAuditResourceType.ORIGINAL_WORK_CHARACTER + require(resourceType != AiCharacterAdminAuditResourceType.ORIGINAL_WORK) + require(isAssignmentAction == isOriginalWorkCharacter) + require(action == AiCharacterAdminAuditAction.UNASSIGN || creatorMemberId != null) + return AiCharacterAdminAuditContext( + adminMemberId = adminMemberId, + characterId = characterId, + creatorMemberId = creatorMemberId, + action = action, + resourceType = resourceType, + resourceId = resourceId + ) + } + } +} + +enum class AiCharacterAdminAuditAction { + CREATE, + UPDATE, + DELETE, + ASSIGN, + UNASSIGN, + PIN, + READ +} + +enum class AiCharacterAdminAuditResourceType { + CHARACTER, + ORIGINAL_WORK, + ORIGINAL_WORK_CHARACTER, + CONTENT, + CONTENT_COMMENT, + CONTENT_CATEGORY, + SERIES, + COMMUNITY_POST, + COMMUNITY_COMMENT, + FAN_TALK, + FAN_TALK_REPLY, + NOTICE, + CHANNEL_NOTICE, + CREATOR_TAG, + CHANNEL_PROFILE +} + +enum class AiCharacterAdminAuditResult { + SUCCESS, + FAILURE +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminCommonDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminCommonDtos.kt new file mode 100644 index 00000000..5e38b8b6 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminCommonDtos.kt @@ -0,0 +1,37 @@ +package kr.co.vividnext.sodalive.v2.admin.aicharacter.dto + +data class AdminPageRequest( + val page: Int, + val size: Int, + val offset: Long, + val limit: Long +) + +data class AdminPageResponse( + val totalCount: Long, + val items: List, + val page: Int, + val size: Int, + val hasNext: Boolean +) { + companion object { + fun of(totalCount: Long, content: List, page: Int, size: Int): AdminPageResponse { + return AdminPageResponse( + totalCount = totalCount, + items = content, + page = page, + size = size, + hasNext = ((page.toLong() + 1L) * size.toLong()) < totalCount + ) + } + } +} + +data class AdminMutationResponse( + val id: Long, + val isActive: Boolean +) + +data class AdminCommentUpdateRequest( + val content: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapter.kt new file mode 100644 index 00000000..0ff32708 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapter.kt @@ -0,0 +1,51 @@ +package kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.persistence + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.member.MemberKind +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.aicharacter.domain.AiCharacterAdminTarget +import kr.co.vividnext.sodalive.v2.aicharacter.port.out.AiCharacterPersistencePort +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import javax.persistence.EntityManager +import javax.persistence.NoResultException + +@Component +class DefaultAiCharacterPersistenceAdapter( + private val entityManager: EntityManager +) : AiCharacterPersistencePort { + @Transactional(readOnly = true) + override fun findAdminTarget(characterId: Long): AiCharacterAdminTarget? { + val character = findCharacter(characterId) ?: return null + val creatorMember = character.creatorMember ?: return null + if (creatorMember.role != MemberRole.CREATOR || creatorMember.memberKind != MemberKind.AI_CHARACTER) { + return null + } + + return AiCharacterAdminTarget( + characterId = character.id!!, + creatorMemberId = creatorMember.id!!, + characterIsActive = character.isActive, + creatorMemberIsActive = creatorMember.isActive, + creatorRole = creatorMember.role, + memberKind = creatorMember.memberKind + ) + } + + private fun findCharacter(characterId: Long): ChatCharacter? { + return try { + entityManager.createQuery( + """ + select c + from ChatCharacter c + left join fetch c.creatorMember + where c.id = :characterId + """.trimIndent(), + ChatCharacter::class.java + ).setParameter("characterId", characterId) + .singleResult + } catch (e: NoResultException) { + null + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterAdminTargetResolver.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterAdminTargetResolver.kt new file mode 100644 index 00000000..a3f2c5f4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterAdminTargetResolver.kt @@ -0,0 +1,44 @@ +package kr.co.vividnext.sodalive.v2.aicharacter.application + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.MemberKind +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.aicharacter.domain.AiCharacterAdminTarget +import kr.co.vividnext.sodalive.v2.aicharacter.port.out.AiCharacterPersistencePort +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service + +@Service +class AiCharacterAdminTargetResolver( + private val persistencePort: AiCharacterPersistencePort +) { + fun resolveActiveTarget(characterId: Long): AiCharacterAdminTarget { + val target = resolveExistingTarget(characterId) + if (!target.characterIsActive || !target.creatorMemberIsActive) { + throw SodaException( + messageKey = "common.error.invalid_request", + errorProperty = "characterId", + httpStatus = HttpStatus.CONFLICT + ) + } + + return target + } + + fun resolveExistingTarget(characterId: Long): AiCharacterAdminTarget { + val target = persistencePort.findAdminTarget(characterId) ?: throwNotFound() + if (target.creatorRole != MemberRole.CREATOR || target.memberKind != MemberKind.AI_CHARACTER) { + throwNotFound() + } + + return target + } + + private fun throwNotFound(): Nothing { + throw SodaException( + messageKey = "admin.chat.character.not_found", + errorProperty = "characterId", + httpStatus = HttpStatus.NOT_FOUND + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterAdminTarget.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterAdminTarget.kt new file mode 100644 index 00000000..43cd2f40 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterAdminTarget.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.v2.aicharacter.domain + +import kr.co.vividnext.sodalive.member.MemberKind +import kr.co.vividnext.sodalive.member.MemberRole + +data class AiCharacterAdminTarget( + val characterId: Long, + val creatorMemberId: Long, + val characterIsActive: Boolean, + val creatorMemberIsActive: Boolean, + val creatorRole: MemberRole, + val memberKind: MemberKind +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterPersistencePort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterPersistencePort.kt new file mode 100644 index 00000000..5c91b11d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterPersistencePort.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.v2.aicharacter.port.out + +import kr.co.vividnext.sodalive.v2.aicharacter.domain.AiCharacterAdminTarget + +interface AiCharacterPersistencePort { + fun findAdminTarget(characterId: Long): AiCharacterAdminTarget? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutor.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutor.kt new file mode 100644 index 00000000..dc955ccb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutor.kt @@ -0,0 +1,34 @@ +package kr.co.vividnext.sodalive.v2.common.application + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.springframework.transaction.support.TransactionSynchronization +import org.springframework.transaction.support.TransactionSynchronizationManager + +@Component +class AfterCommitExecutor { + private val logger = LoggerFactory.getLogger(javaClass) + + fun executeAfterCommit(callback: () -> Unit) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + executeCallback(callback) + return + } + + TransactionSynchronizationManager.registerSynchronization( + object : TransactionSynchronization { + override fun afterCommit() { + executeCallback(callback) + } + } + ) + } + + private fun executeCallback(callback: () -> Unit) { + try { + callback() + } catch (e: Exception) { + logger.warn("afterCommit callback failed error={}", e.javaClass.simpleName) + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterControllerTest.kt index cd4932e3..ebc5e5b6 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterControllerTest.kt @@ -1,14 +1,54 @@ package kr.co.vividnext.sodalive.admin.chat.character +import com.amazonaws.services.s3.AmazonS3Client +import com.fasterxml.jackson.databind.ObjectMapper +import com.sun.net.httpserver.HttpServer +import kr.co.vividnext.sodalive.admin.chat.character.dto.BackgroundResponse +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterBackgroundRequest +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterMemoryRequest +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterPersonalityRequest +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRelationshipRequest +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest +import kr.co.vividnext.sodalive.admin.chat.character.dto.MemoryResponse +import kr.co.vividnext.sodalive.admin.chat.character.dto.OriginalWorkBriefResponse +import kr.co.vividnext.sodalive.admin.chat.character.dto.PersonalityResponse +import kr.co.vividnext.sodalive.admin.chat.character.dto.RelationshipResponse import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.chat.character.CharacterType +import kr.co.vividnext.sodalive.chat.character.ChatCharacter import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor import org.mockito.Mockito import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.multipart.MultipartFile +import java.net.InetSocketAddress +import java.net.URL +import java.util.concurrent.CopyOnWriteArrayList class AdminChatCharacterControllerTest { private val controller = AdminChatCharacterController( @@ -35,6 +75,523 @@ class AdminChatCharacterControllerTest { return method.invoke(controller, region, gender) as String } + @Test + fun shouldKeepLegacyAdminCharacterBaseContract() { + val classMapping = AdminChatCharacterController::class.java.getAnnotation(RequestMapping::class.java) + val preAuthorize = AdminChatCharacterController::class.java.getAnnotation(PreAuthorize::class.java) + + assertEquals("/admin/chat/character", classMapping.value.single()) + assertEquals("hasRole('ADMIN')", preAuthorize.value) + } + + @Test + fun shouldKeepLegacyAdminCharacterRoutes() { + assertEquals( + "/list", + method("getCharacterList", Int::class.java, Int::class.java).getAnnotation(GetMapping::class.java).value.single() + ) + assertEquals( + "/search", + method("searchCharacters", String::class.java, Int::class.java, Int::class.java) + .getAnnotation(GetMapping::class.java).value.single() + ) + assertEquals( + "/{characterId}", + method("getCharacterDetail", Long::class.java).getAnnotation(GetMapping::class.java).value.single() + ) + assertEquals( + "/register", + method("registerCharacter", MultipartFile::class.java, String::class.java) + .getAnnotation(PostMapping::class.java).value.single() + ) + assertEquals( + "/update", + method("updateCharacter", MultipartFile::class.java, String::class.java) + .getAnnotation(PutMapping::class.java).value.single() + ) + } + + @Test + fun shouldKeepLegacyAdminCharacterMultipartRequestParts() { + val register = method("registerCharacter", MultipartFile::class.java, String::class.java) + val update = method("updateCharacter", MultipartFile::class.java, String::class.java) + + assertEquals("image", register.parameters[0].getAnnotation(RequestPart::class.java).value) + assertEquals("request", register.parameters[1].getAnnotation(RequestPart::class.java).value) + assertEquals("image", update.parameters[0].getAnnotation(RequestPart::class.java).value) + assertEquals(false, update.parameters[0].getAnnotation(RequestPart::class.java).required) + assertEquals("request", update.parameters[1].getAnnotation(RequestPart::class.java).value) + } + + @Test + fun shouldKeepLegacyAdminCharacterUpdateRequestFields() { + val request = ChatCharacterUpdateRequest( + id = 1L, + name = "character", + systemPrompt = "prompt", + description = "description", + age = null, + gender = null, + mbti = null, + speechPattern = null, + speechStyle = null, + appearance = null, + originalTitle = "title", + originalLink = null, + originalWorkId = null, + characterType = null, + isActive = null, + tags = emptyList(), + hobbies = emptyList(), + values = emptyList(), + goals = emptyList(), + relationships = emptyList(), + personalities = emptyList(), + backgrounds = emptyList(), + memories = emptyList() + ) + + assertEquals(1L, request.id) + assertEquals("character", request.name) + assertEquals("prompt", request.systemPrompt) + assertEquals("description", request.description) + assertEquals("title", request.originalTitle) + assertEquals(emptyList(), request.tags) + assertEquals(emptyList(), request.memories) + } + + @Test + fun shouldKeepLegacyAdminCharacterListResponseSurface() { + val adminService = Mockito.mock(AdminChatCharacterService::class.java) + Mockito.`when`(adminService.createDefaultPageRequest(0, 20)).thenReturn(PageRequest.of(0, 20)) + Mockito.`when`(adminService.getActiveChatCharacters(PageRequest.of(0, 20), "https://cdn.example.com")) + .thenReturn( + ChatCharacterListPageResponse( + totalCount = 1, + content = listOf(characterListResponse()) + ) + ) + val mockMvc = MockMvcBuilders.standaloneSetup(controller(adminService = adminService)).build() + + mockMvc.perform(get("/admin/chat/character/list").param("page", "0").param("size", "20")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.content[0].id").value(1)) + .andExpect(jsonPath("$.data.content[0].name").value("character")) + .andExpect(jsonPath("$.data.content[0].imageUrl").value("https://cdn.example.com/characters/1.png")) + .andExpect(jsonPath("$.data.content[0].description").value("description")) + .andExpect(jsonPath("$.data.content[0].gender").value("여성")) + .andExpect(jsonPath("$.data.content[0].age").value(20)) + .andExpect(jsonPath("$.data.content[0].mbti").value("INTJ")) + .andExpect(jsonPath("$.data.content[0].speechStyle").value("calm")) + .andExpect(jsonPath("$.data.content[0].speechPattern").value("polite")) + .andExpect(jsonPath("$.data.content[0].region").value("KR")) + .andExpect(jsonPath("$.data.content[0].tags[0]").value("tag")) + .andExpect(jsonPath("$.data.content[0].createdAt").value("2026-07-21 12:00:00")) + .andExpect(jsonPath("$.data.content[0].updatedAt").value("2026-07-21 12:00:01")) + } + + @Test + @DisplayName("legacy 캐릭터 검색은 query와 비기본 pagination을 service에 전달하고 기존 응답을 유지한다") + fun shouldKeepLegacyAdminCharacterSearchRequestAndResponseSurface() { + val adminService = Mockito.mock(AdminChatCharacterService::class.java) + val pageable = PageRequest.of(2, 7) + Mockito.`when`(adminService.createDefaultPageRequest(2, 7)).thenReturn(pageable) + Mockito.`when`(adminService.searchCharacters("character", pageable, "https://cdn.example.com")) + .thenReturn(PageImpl(listOf(characterListResponse()), pageable, 15)) + val mockMvc = MockMvcBuilders.standaloneSetup(controller(adminService = adminService)).build() + + mockMvc.perform( + get("/admin/chat/character/search") + .param("searchTerm", "character") + .param("page", "2") + .param("size", "7") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.totalCount").value(15)) + .andExpect(jsonPath("$.data.content").isArray) + .andExpect(jsonPath("$.data.content[0].id").value(1)) + .andExpect(jsonPath("$.data.content[0].name").value("character")) + .andExpect(jsonPath("$.data.content[0].imageUrl").value("https://cdn.example.com/characters/1.png")) + .andExpect(jsonPath("$.data.content[0].description").value("description")) + .andExpect(jsonPath("$.data.content[0].gender").value("여성")) + .andExpect(jsonPath("$.data.content[0].age").value(20)) + .andExpect(jsonPath("$.data.content[0].mbti").value("INTJ")) + .andExpect(jsonPath("$.data.content[0].speechStyle").value("calm")) + .andExpect(jsonPath("$.data.content[0].speechPattern").value("polite")) + .andExpect(jsonPath("$.data.content[0].region").value("KR")) + .andExpect(jsonPath("$.data.content[0].tags[0]").value("tag")) + .andExpect(jsonPath("$.data.content[0].createdAt").value("2026-07-21 12:00:00")) + .andExpect(jsonPath("$.data.content[0].updatedAt").value("2026-07-21 12:00:01")) + + Mockito.verify(adminService).createDefaultPageRequest(2, 7) + Mockito.verify(adminService).searchCharacters("character", pageable, "https://cdn.example.com") + } + + @Test + fun shouldKeepLegacyAdminCharacterDetailResponseSurface() { + val adminService = Mockito.mock(AdminChatCharacterService::class.java) + Mockito.`when`(adminService.getChatCharacterDetail(1L, "https://cdn.example.com")) + .thenReturn( + ChatCharacterDetailResponse( + id = 1L, + characterUUID = "uuid", + name = "character", + imageUrl = "https://cdn.example.com/characters/1.png", + description = "description", + systemPrompt = "prompt", + characterType = "Character", + age = 20, + gender = "여성", + mbti = "INTJ", + speechPattern = "polite", + speechStyle = "calm", + appearance = "appearance", + region = "KR", + isActive = true, + tags = listOf("tag"), + hobbies = listOf("hobby"), + values = listOf("value"), + goals = listOf("goal"), + relationships = listOf( + RelationshipResponse( + personName = "person", + relationshipName = "friend", + description = "relationship description", + importance = 5, + relationshipType = "ally", + currentStatus = "active" + ) + ), + personalities = listOf(PersonalityResponse(trait = "kind", description = "personality description")), + backgrounds = listOf(BackgroundResponse(topic = "past", description = "background description")), + memories = listOf(MemoryResponse(title = "memory", content = "memory content", emotion = "happy")), + originalWork = OriginalWorkBriefResponse( + id = 10L, + imageUrl = "https://cdn.example.com/originals/10.png", + title = "original title" + ) + ) + ) + val mockMvc = MockMvcBuilders.standaloneSetup(controller(adminService = adminService)).build() + + mockMvc.perform(get("/admin/chat/character/1")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.id").value(1)) + .andExpect(jsonPath("$.data.characterUUID").value("uuid")) + .andExpect(jsonPath("$.data.name").value("character")) + .andExpect(jsonPath("$.data.imageUrl").value("https://cdn.example.com/characters/1.png")) + .andExpect(jsonPath("$.data.description").value("description")) + .andExpect(jsonPath("$.data.systemPrompt").value("prompt")) + .andExpect(jsonPath("$.data.characterType").value("Character")) + .andExpect(jsonPath("$.data.age").value(20)) + .andExpect(jsonPath("$.data.gender").value("여성")) + .andExpect(jsonPath("$.data.mbti").value("INTJ")) + .andExpect(jsonPath("$.data.speechPattern").value("polite")) + .andExpect(jsonPath("$.data.speechStyle").value("calm")) + .andExpect(jsonPath("$.data.appearance").value("appearance")) + .andExpect(jsonPath("$.data.region").value("KR")) + .andExpect(jsonPath("$.data.isActive").value(true)) + .andExpect(jsonPath("$.data.tags[0]").value("tag")) + .andExpect(jsonPath("$.data.hobbies[0]").value("hobby")) + .andExpect(jsonPath("$.data.values[0]").value("value")) + .andExpect(jsonPath("$.data.goals[0]").value("goal")) + .andExpect(jsonPath("$.data.relationships[0].personName").value("person")) + .andExpect(jsonPath("$.data.relationships[0].relationshipName").value("friend")) + .andExpect(jsonPath("$.data.relationships[0].description").value("relationship description")) + .andExpect(jsonPath("$.data.relationships[0].importance").value(5)) + .andExpect(jsonPath("$.data.relationships[0].relationshipType").value("ally")) + .andExpect(jsonPath("$.data.relationships[0].currentStatus").value("active")) + .andExpect(jsonPath("$.data.personalities[0].trait").value("kind")) + .andExpect(jsonPath("$.data.personalities[0].description").value("personality description")) + .andExpect(jsonPath("$.data.backgrounds[0].topic").value("past")) + .andExpect(jsonPath("$.data.backgrounds[0].description").value("background description")) + .andExpect(jsonPath("$.data.memories[0].title").value("memory")) + .andExpect(jsonPath("$.data.memories[0].content").value("memory content")) + .andExpect(jsonPath("$.data.memories[0].emotion").value("happy")) + .andExpect(jsonPath("$.data.originalWork.id").value(10)) + .andExpect(jsonPath("$.data.originalWork.imageUrl").value("https://cdn.example.com/originals/10.png")) + .andExpect(jsonPath("$.data.originalWork.title").value("original title")) + } + + @Test + fun shouldKeepLegacyAdminCharacterRegisterResponseSurface() { + val outboundRequests = CopyOnWriteArrayList() + val server = externalCharacterApiServer(outboundRequests) + try { + val service = chatCharacterServiceFake() + val originalWorkService = Mockito.mock(AdminOriginalWorkService::class.java) + val mockMvc = MockMvcBuilders.standaloneSetup( + controllerForMutation(server, service, originalWorkService) + ).build() + val request = """ + { + "name":"character", + "systemPrompt":"prompt", + "description":"description", + "age":"20", + "gender":"여성", + "mbti":"INTJ", + "speechPattern":"polite", + "speechStyle":"calm", + "appearance":"appearance", + "region":"KR", + "originalTitle":"original title", + "originalLink":"https://original.test", + "originalWorkId":10, + "characterType":"Character", + "tags":["tag"], + "hobbies":["hobby"], + "values":["value"], + "goals":["goal"], + "relationships":[{ + "personName":"person", + "relationshipName":"friend", + "description":"relationship description", + "importance":5, + "relationshipType":"ally", + "currentStatus":"active" + }], + "personalities":[{"trait":"kind","description":"personality description"}], + "backgrounds":[{"topic":"past","description":"background description"}], + "memories":[{"title":"memory","content":"memory content","emotion":"happy"}] + } + """.trimIndent() + + mockMvc.perform( + multipart("/admin/chat/character/register") + .file(imageFile()) + .file(jsonPart(request)) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true)) + + val arguments = Mockito.mockingDetails(service).invocations + .single { it.method.name == "createChatCharacterWithDetails" } + .arguments + assertEquals("remote-id", arguments[0]) + assertEquals("character", arguments[1]) + assertEquals("description", arguments[2]) + assertEquals("prompt", arguments[3]) + assertEquals(20, arguments[4]) + assertEquals("여성", arguments[5]) + assertEquals("INTJ", arguments[6]) + assertEquals("polite", arguments[7]) + assertEquals("calm", arguments[8]) + assertEquals("appearance", arguments[9]) + assertEquals("original title", arguments[10]) + assertEquals("https://original.test", arguments[11]) + assertEquals(CharacterType.Character, arguments[12]) + assertEquals("KR", arguments[13]) + assertEquals(listOf("tag"), arguments[14]) + assertEquals(listOf("value"), arguments[15]) + assertEquals(listOf("hobby"), arguments[16]) + assertEquals(listOf("goal"), arguments[17]) + assertEquals(listOf(Triple("memory", "memory content", "happy")), arguments[18]) + assertEquals(listOf(Pair("kind", "personality description")), arguments[19]) + assertEquals(listOf(Pair("past", "background description")), arguments[20]) + assertEquals( + listOf( + ChatCharacterRelationshipRequest( + personName = "person", + relationshipName = "friend", + description = "relationship description", + importance = 5, + relationshipType = "ally", + currentStatus = "active" + ) + ), + arguments[21] + ) + Mockito.verify(originalWorkService).assignOneCharacter(10L, 1L) + assertRecordedRequest( + request = outboundRequests.single(), + method = "POST", + path = "/api/characters", + expectedBody = """ + { + "name":"character", + "systemPrompt":"prompt", + "description":"description", + "region":"KR", + "age":"20", + "gender":"여성", + "mbti":"INTJ", + "speechPattern":"polite", + "speechStyle":"calm", + "appearance":"appearance", + "tags":["tag"], + "hobbies":["hobby"], + "values":["value"], + "goals":["goal"], + "relationships":[{ + "personName":"person", + "relationshipName":"friend", + "description":"relationship description", + "importance":5, + "relationshipType":"ally", + "currentStatus":"active" + }], + "personalities":[{"trait":"kind","description":"personality description"}], + "backgrounds":[{"topic":"past","description":"background description"}], + "memories":[{"title":"memory","content":"memory content","emotion":"happy"}] + } + """.trimIndent() + ) + } finally { + server.stop(0) + } + } + + @Test + fun shouldKeepLegacyAdminCharacterUpdateResponseSurface() { + val outboundRequests = CopyOnWriteArrayList() + val server = externalCharacterApiServer(outboundRequests) + try { + val service = chatCharacterServiceFake() + val originalWorkService = Mockito.mock(AdminOriginalWorkService::class.java) + val mockMvc = MockMvcBuilders.standaloneSetup( + controllerForMutation(server, service, originalWorkService) + ).build() + val request = """ + { + "id":1, + "name":"updated character", + "systemPrompt":"updated prompt", + "description":"updated description", + "age":"21", + "gender":"남성", + "mbti":"ENTP", + "speechPattern":"casual", + "speechStyle":"bright", + "appearance":"updated appearance", + "originalTitle":"updated original title", + "originalLink":"https://updated-original.test", + "originalWorkId":11, + "characterType":"Character", + "isActive":true, + "tags":["updated tag"], + "hobbies":["updated hobby"], + "values":["updated value"], + "goals":["updated goal"], + "relationships":[{ + "personName":"updated person", + "relationshipName":"rival", + "description":"updated relationship description", + "importance":4, + "relationshipType":"opponent", + "currentStatus":"tense" + }], + "personalities":[{"trait":"bold","description":"updated personality description"}], + "backgrounds":[{"topic":"future","description":"updated background description"}], + "memories":[{"title":"updated memory","content":"updated memory content","emotion":"hopeful"}] + } + """.trimIndent() + + mockMvc.perform( + multipart("/admin/chat/character/update") + .file(jsonPart(request)) + .with { requestBuilder -> + requestBuilder.method = "PUT" + requestBuilder + } + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true)) + + val requestCaptor = ArgumentCaptor.forClass(ChatCharacterUpdateRequest::class.java) + Mockito.verify(service).updateChatCharacterWithDetails(Mockito.isNull(), captureRequest(requestCaptor)) + assertEquals( + ChatCharacterUpdateRequest( + id = 1L, + name = "updated character", + systemPrompt = "updated prompt", + description = "updated description", + age = "21", + gender = "남성", + mbti = "ENTP", + speechPattern = "casual", + speechStyle = "bright", + appearance = "updated appearance", + originalTitle = "updated original title", + originalLink = "https://updated-original.test", + originalWorkId = 11L, + characterType = "Character", + isActive = true, + tags = listOf("updated tag"), + hobbies = listOf("updated hobby"), + values = listOf("updated value"), + goals = listOf("updated goal"), + relationships = listOf( + ChatCharacterRelationshipRequest( + personName = "updated person", + relationshipName = "rival", + description = "updated relationship description", + importance = 4, + relationshipType = "opponent", + currentStatus = "tense" + ) + ), + personalities = listOf( + ChatCharacterPersonalityRequest("bold", "updated personality description") + ), + backgrounds = listOf( + ChatCharacterBackgroundRequest("future", "updated background description") + ), + memories = listOf( + ChatCharacterMemoryRequest("updated memory", "updated memory content", "hopeful") + ) + ), + requestCaptor.value + ) + Mockito.verify(originalWorkService).assignOneCharacter(11L, 1L) + assertRecordedRequest( + request = outboundRequests.single(), + method = "PUT", + path = "/api/characters/remote-id", + expectedBody = """ + { + "name":"updated character", + "systemPrompt":"updated prompt", + "description":"updated description", + "age":"21", + "gender":"남성", + "mbti":"ENTP", + "speechPattern":"casual", + "speechStyle":"bright", + "appearance":"updated appearance", + "tags":["updated tag"], + "hobbies":["updated hobby"], + "values":["updated value"], + "goals":["updated goal"], + "relationships":[{ + "personName":"updated person", + "relationshipName":"rival", + "description":"updated relationship description", + "importance":4, + "relationshipType":"opponent", + "currentStatus":"tense" + }], + "personalities":[{"trait":"bold","description":"updated personality description"}], + "backgrounds":[{"topic":"future","description":"updated background description"}], + "memories":[{ + "title":"updated memory", + "content":"updated memory content", + "emotion":"hopeful" + }] + } + """.trimIndent() + ) + } finally { + server.stop(0) + } + } + @Test fun shouldMapFemaleToJapaneseWhenRegionIsJp() { val mappedGender = mapGender(region = "JP", gender = "여성") @@ -62,4 +619,143 @@ class AdminChatCharacterControllerTest { assertEquals("여성", mappedGender) } + + private fun method(name: String, vararg parameterTypes: Class<*>): java.lang.reflect.Method { + return AdminChatCharacterController::class.java.getDeclaredMethod(name, *parameterTypes) + } + + private fun controller( + adminService: AdminChatCharacterService = Mockito.mock(AdminChatCharacterService::class.java) + ): AdminChatCharacterController { + return AdminChatCharacterController( + service = Mockito.mock(ChatCharacterService::class.java), + adminService = adminService, + s3Uploader = Mockito.mock(S3Uploader::class.java), + originalWorkService = Mockito.mock(AdminOriginalWorkService::class.java), + creatorMemberService = Mockito.mock(ChatCharacterCreatorMemberService::class.java), + applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java), + apiKey = "test-api-key", + apiUrl = "https://example.com", + s3Bucket = "test-bucket", + imageHost = "https://cdn.example.com" + ) + } + + private fun controllerForMutation( + server: HttpServer? = null, + service: ChatCharacterService = chatCharacterServiceFake(), + originalWorkService: AdminOriginalWorkService = Mockito.mock(AdminOriginalWorkService::class.java) + ): AdminChatCharacterController { + val amazonS3Client = Mockito.mock(AmazonS3Client::class.java) { invocation -> + if (invocation.method.name == "getUrl") { + URL("https://cdn.example.com/characters/1/character.png") + } else { + null + } + } + return AdminChatCharacterController( + service = service, + adminService = Mockito.mock(AdminChatCharacterService::class.java), + s3Uploader = S3Uploader(amazonS3Client), + originalWorkService = originalWorkService, + creatorMemberService = Mockito.mock(ChatCharacterCreatorMemberService::class.java), + applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java), + apiKey = "test-api-key", + apiUrl = server?.let { "http://127.0.0.1:${it.address.port}" } ?: "https://example.com", + s3Bucket = "test-bucket", + imageHost = "https://cdn.example.com" + ) + } + + private fun chatCharacterServiceFake(): ChatCharacterService { + val character = chatCharacter(id = 1L, uuid = "remote-id") + return Mockito.mock(ChatCharacterService::class.java) { invocation -> + when (invocation.method.name) { + "findByName" -> null + "findById" -> character + "createChatCharacterWithDetails" -> character + "saveChatCharacter" -> invocation.arguments[0] + "updateChatCharacterWithDetails" -> character + else -> null + } + } + } + + private fun chatCharacter(id: Long, uuid: String): ChatCharacter { + return ChatCharacter( + characterUUID = uuid, + name = "character", + description = "description", + systemPrompt = "prompt", + characterType = CharacterType.Character + ).apply { this.id = id } + } + + private fun characterListResponse(): ChatCharacterListResponse { + return ChatCharacterListResponse( + id = 1L, + name = "character", + imageUrl = "https://cdn.example.com/characters/1.png", + description = "description", + gender = "여성", + age = 20, + mbti = "INTJ", + speechStyle = "calm", + speechPattern = "polite", + region = "KR", + tags = listOf("tag"), + createdAt = "2026-07-21 12:00:00", + updatedAt = "2026-07-21 12:00:01" + ) + } + + private fun captureRequest(captor: ArgumentCaptor): ChatCharacterUpdateRequest { + return captor.capture() ?: ChatCharacterUpdateRequest(id = 0L) + } + + private fun externalCharacterApiServer(requests: MutableList): HttpServer { + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/api/characters") { exchange -> + requests += RecordedHttpRequest( + method = exchange.requestMethod, + path = exchange.requestURI.path, + body = exchange.requestBody.bufferedReader().use { it.readText() } + ) + val response = """{"success":true,"data":{"id":"remote-id"}}""".toByteArray() + exchange.sendResponseHeaders(200, response.size.toLong()) + exchange.responseBody.use { it.write(response) } + } + server.start() + return server + } + + private fun assertRecordedRequest( + request: RecordedHttpRequest, + method: String, + path: String, + expectedBody: String + ) { + assertEquals(method, request.method) + assertEquals(path, request.path) + assertEquals(ObjectMapper().readTree(expectedBody), ObjectMapper().readTree(request.body)) + } + + private fun imageFile(): MockMultipartFile { + return MockMultipartFile("image", "character.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1, 2, 3)) + } + + private fun jsonPart(json: String): MockMultipartFile { + return MockMultipartFile("request", "", MediaType.APPLICATION_JSON_VALUE, json.toByteArray()) + } + + private data class RecordedHttpRequest( + val method: String, + val path: String, + val body: String + ) + + companion object { + private const val LEGACY_EMPTY_SUCCESS_RESPONSE = + """{"success":true,"message":null,"data":null,"errorProperty":null}""" + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkControllerContractTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkControllerContractTest.kt new file mode 100644 index 00000000..c6daa1b7 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkControllerContractTest.kt @@ -0,0 +1,469 @@ +package kr.co.vividnext.sodalive.admin.chat.original + +import com.amazonaws.services.s3.AmazonS3Client +import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkAssignCharactersRequest +import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest +import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest +import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.chat.original.OriginalWork +import kr.co.vividnext.sodalive.chat.original.OriginalWorkLink +import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag +import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.multipart.MultipartFile +import java.net.URL + +class AdminOriginalWorkControllerContractTest { + @Test + @DisplayName("legacy 원작 관리자 API는 기존 base path와 ADMIN 권한 계약을 유지한다") + fun shouldKeepLegacyAdminOriginalWorkBaseContract() { + val classMapping = AdminOriginalWorkController::class.java.getAnnotation(RequestMapping::class.java) + val preAuthorize = AdminOriginalWorkController::class.java.getAnnotation(PreAuthorize::class.java) + + assertEquals("/admin/chat/original", classMapping.value.single()) + assertEquals("hasRole('ADMIN')", preAuthorize.value) + } + + @Test + @DisplayName("legacy 원작 관리자 mutation 경로와 method를 유지한다") + fun shouldKeepLegacyAdminOriginalWorkMutationRoutes() { + assertEquals( + "/register", + method("register", MultipartFile::class.java, String::class.java) + .getAnnotation(PostMapping::class.java).value.single() + ) + assertEquals( + "/update", + method("update", MultipartFile::class.java, String::class.java) + .getAnnotation(PutMapping::class.java).value.single() + ) + assertEquals( + "/{id}", + method("delete", Long::class.java).getAnnotation(DeleteMapping::class.java).value.single() + ) + assertEquals( + "/{id}/assign-characters", + method("assignCharacters", Long::class.java, OriginalWorkAssignCharactersRequest::class.java) + .getAnnotation(PostMapping::class.java).value.single() + ) + assertEquals( + "/{id}/unassign-characters", + method("unassignCharacters", Long::class.java, OriginalWorkAssignCharactersRequest::class.java) + .getAnnotation(PostMapping::class.java).value.single() + ) + } + + @Test + @DisplayName("legacy 원작 관리자 mutation request annotation을 유지한다") + fun shouldKeepLegacyAdminOriginalWorkMutationRequestAnnotations() { + val register = method("register", MultipartFile::class.java, String::class.java) + val update = method("update", MultipartFile::class.java, String::class.java) + val delete = method("delete", Long::class.java) + val assign = method("assignCharacters", Long::class.java, OriginalWorkAssignCharactersRequest::class.java) + val unassign = method("unassignCharacters", Long::class.java, OriginalWorkAssignCharactersRequest::class.java) + + assertEquals("image", register.parameters[0].getAnnotation(RequestPart::class.java).value) + assertEquals("request", register.parameters[1].getAnnotation(RequestPart::class.java).value) + assertEquals("image", update.parameters[0].getAnnotation(RequestPart::class.java).value) + assertEquals(false, update.parameters[0].getAnnotation(RequestPart::class.java).required) + assertEquals("request", update.parameters[1].getAnnotation(RequestPart::class.java).value) + assertEquals(true, delete.parameters[0].isAnnotationPresent(PathVariable::class.java)) + assertEquals(true, assign.parameters[0].isAnnotationPresent(PathVariable::class.java)) + assertEquals(true, assign.parameters[1].isAnnotationPresent(RequestBody::class.java)) + assertEquals(true, unassign.parameters[0].isAnnotationPresent(PathVariable::class.java)) + assertEquals(true, unassign.parameters[1].isAnnotationPresent(RequestBody::class.java)) + } + + @Test + @DisplayName("legacy 원작 관리자 조회 경로와 request DTO 기본 field를 유지한다") + fun shouldKeepLegacyAdminOriginalWorkReadRoutesAndDtos() { + assertEquals( + "/list", + method("list", Int::class.java, Int::class.java).getAnnotation(GetMapping::class.java).value.single() + ) + assertEquals( + "/search", + method("search", String::class.java).getAnnotation(GetMapping::class.java).value.single() + ) + assertEquals( + "/{id}", + method("detail", Long::class.java).getAnnotation(GetMapping::class.java).value.single() + ) + assertEquals( + "/{id}/characters", + method("listCharactersOfOriginal", Long::class.java, Int::class.java, Int::class.java) + .getAnnotation(GetMapping::class.java).value.single() + ) + + val register = OriginalWorkRegisterRequest(title = "title", contentType = "type", category = "category") + val registerWithAllFields = OriginalWorkRegisterRequest( + title = "title", + contentType = "type", + category = "category", + isAdult = true, + description = "description", + originalWork = "source", + originalLink = "https://source.test", + writer = "writer", + studio = "studio", + originalLinks = listOf("https://link.test"), + tags = listOf("tag") + ) + val update = OriginalWorkUpdateRequest( + id = 1L, + title = "title", + contentType = "type", + category = "category", + isAdult = null, + description = "description", + originalWork = "source", + originalLink = "https://source.test", + writer = "writer", + studio = "studio", + originalLinks = listOf("https://link.test"), + tags = listOf("tag") + ) + + assertEquals("title", register.title) + assertEquals(false, register.isAdult) + assertEquals(true, registerWithAllFields.isAdult) + assertEquals("source", registerWithAllFields.originalWork) + assertEquals("https://source.test", registerWithAllFields.originalLink) + assertEquals("writer", registerWithAllFields.writer) + assertEquals("studio", registerWithAllFields.studio) + assertEquals(listOf("https://link.test"), registerWithAllFields.originalLinks) + assertEquals(listOf("tag"), registerWithAllFields.tags) + assertEquals(1L, update.id) + assertEquals("type", update.contentType) + assertEquals("category", update.category) + assertEquals(null, update.isAdult) + assertEquals("description", update.description) + assertEquals("source", update.originalWork) + assertEquals("https://source.test", update.originalLink) + assertEquals("writer", update.writer) + assertEquals("studio", update.studio) + assertEquals(listOf("https://link.test"), update.originalLinks) + assertEquals(listOf("tag"), update.tags) + } + + @Test + @DisplayName("legacy 원작 관리자 목록은 기존 성공 응답 surface를 유지한다") + fun shouldKeepLegacyAdminOriginalWorkListResponseSurface() { + val service = Mockito.mock(AdminOriginalWorkService::class.java) + Mockito.`when`(service.getOriginalWorkPage(0, 20)) + .thenReturn(PageImpl(listOf(originalWork()), PageRequest.of(0, 20), 1)) + val controller = AdminOriginalWorkController( + originalWorkService = service, + s3Uploader = Mockito.mock(S3Uploader::class.java), + s3Bucket = "test-bucket", + imageHost = "https://cdn.test" + ) + val mockMvc = MockMvcBuilders.standaloneSetup(controller).build() + + mockMvc.perform(get("/admin/chat/original/list").param("page", "0").param("size", "20")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.content").isArray) + .andExpect(jsonPath("$.data.content[0].id").value(1)) + .andExpect(jsonPath("$.data.content[0].title").value("title")) + .andExpect(jsonPath("$.data.content[0].contentType").value("webtoon")) + .andExpect(jsonPath("$.data.content[0].category").value("romance")) + .andExpect(jsonPath("$.data.content[0].isAdult").value(false)) + .andExpect(jsonPath("$.data.content[0].description").value("description")) + .andExpect(jsonPath("$.data.content[0].originalWork").value("source")) + .andExpect(jsonPath("$.data.content[0].originalLink").value("https://source.test")) + .andExpect(jsonPath("$.data.content[0].writer").value("writer")) + .andExpect(jsonPath("$.data.content[0].studio").value("studio")) + .andExpect(jsonPath("$.data.content[0].originalLinks[0]").value("https://link.test")) + .andExpect(jsonPath("$.data.content[0].tags[0]").value("tag")) + .andExpect(jsonPath("$.data.content[0].imageUrl").value("https://cdn.test/originals/1.png")) + } + + @Test + @DisplayName("legacy 원작 검색은 searchTerm을 service에 전달하고 기존 성공 응답을 유지한다") + fun shouldKeepLegacyAdminOriginalWorkSearchRequestAndResponseSurface() { + val service = Mockito.mock(AdminOriginalWorkService::class.java) + Mockito.`when`(service.searchOriginalWorksAll("title")).thenReturn(listOf(originalWork())) + val mockMvc = MockMvcBuilders.standaloneSetup(controller(service = service)).build() + + mockMvc.perform(get("/admin/chat/original/search").param("searchTerm", "title")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").isArray) + .andExpect(jsonPath("$.data[0].id").value(1)) + .andExpect(jsonPath("$.data[0].title").value("title")) + .andExpect(jsonPath("$.data[0].contentType").value("webtoon")) + .andExpect(jsonPath("$.data[0].category").value("romance")) + .andExpect(jsonPath("$.data[0].isAdult").value(false)) + .andExpect(jsonPath("$.data[0].description").value("description")) + .andExpect(jsonPath("$.data[0].originalWork").value("source")) + .andExpect(jsonPath("$.data[0].originalLink").value("https://source.test")) + .andExpect(jsonPath("$.data[0].writer").value("writer")) + .andExpect(jsonPath("$.data[0].studio").value("studio")) + .andExpect(jsonPath("$.data[0].originalLinks[0]").value("https://link.test")) + .andExpect(jsonPath("$.data[0].tags[0]").value("tag")) + .andExpect(jsonPath("$.data[0].imageUrl").value("https://cdn.test/originals/1.png")) + + Mockito.verify(service).searchOriginalWorksAll("title") + } + + @Test + @DisplayName("legacy 원작 관리자 상세은 기존 성공 응답 surface를 유지한다") + fun shouldKeepLegacyAdminOriginalWorkDetailResponseSurface() { + val service = Mockito.mock(AdminOriginalWorkService::class.java) + Mockito.`when`(service.getOriginalWork(1L)).thenReturn(originalWork()) + val mockMvc = MockMvcBuilders.standaloneSetup(controller(service = service)).build() + + mockMvc.perform(get("/admin/chat/original/1")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.id").value(1)) + .andExpect(jsonPath("$.data.title").value("title")) + .andExpect(jsonPath("$.data.contentType").value("webtoon")) + .andExpect(jsonPath("$.data.category").value("romance")) + .andExpect(jsonPath("$.data.isAdult").value(false)) + .andExpect(jsonPath("$.data.description").value("description")) + .andExpect(jsonPath("$.data.originalWork").value("source")) + .andExpect(jsonPath("$.data.originalLink").value("https://source.test")) + .andExpect(jsonPath("$.data.writer").value("writer")) + .andExpect(jsonPath("$.data.studio").value("studio")) + .andExpect(jsonPath("$.data.originalLinks[0]").value("https://link.test")) + .andExpect(jsonPath("$.data.tags[0]").value("tag")) + .andExpect(jsonPath("$.data.imageUrl").value("https://cdn.test/originals/1.png")) + } + + @Test + @DisplayName("legacy 원작 연결 캐릭터 목록은 기존 성공 응답 surface를 유지한다") + fun shouldKeepLegacyAdminOriginalWorkCharactersResponseSurface() { + val service = Mockito.mock(AdminOriginalWorkService::class.java) + val character = ChatCharacter( + characterUUID = "uuid", + name = "character", + description = "description", + systemPrompt = "prompt" + ).apply { + id = 10L + imagePath = "characters/10.png" + } + Mockito.`when`(service.getCharactersOfOriginalWorkPage(1L, 0, 20)) + .thenReturn(PageImpl(listOf(character), PageRequest.of(0, 20), 1)) + val mockMvc = MockMvcBuilders.standaloneSetup(controller(service = service)).build() + + mockMvc.perform(get("/admin/chat/original/1/characters").param("page", "0").param("size", "20")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.content").isArray) + .andExpect(jsonPath("$.data.content[0].id").value(10)) + .andExpect(jsonPath("$.data.content[0].name").value("character")) + .andExpect(jsonPath("$.data.content[0].imagePath").value("https://cdn.test/characters/10.png")) + } + + @Test + @DisplayName("legacy 원작 관리자 mutation은 기존 성공 응답 surface를 유지한다") + fun shouldKeepLegacyAdminOriginalWorkMutationResponseSurface() { + val service = Mockito.mock(AdminOriginalWorkService::class.java) + val amazonS3Client = Mockito.mock(AmazonS3Client::class.java) { invocation -> + if (invocation.method.name == "getUrl") { + URL("https://cdn.test/originals/1/original.png") + } else { + null + } + } + val s3Uploader = S3Uploader(amazonS3Client) + val saved = OriginalWork(title = "title", contentType = "type", category = "category").apply { id = 1L } + val createRequest = OriginalWorkRegisterRequest( + title = "title", + contentType = "type", + category = "category", + isAdult = false, + description = "", + originalWork = "source", + originalLink = "https://source.test", + writer = "writer", + studio = "studio", + originalLinks = listOf("https://link.test"), + tags = listOf("tag") + ) + Mockito.`when`(service.createOriginalWork(createRequest)).thenReturn(saved) + val mockMvc = MockMvcBuilders.standaloneSetup(controller(service = service, s3Uploader = s3Uploader)).build() + + val registerRequest = """ + { + "title":"title", + "contentType":"type", + "category":"category", + "isAdult":false, + "description":"", + "originalWork":"source", + "originalLink":"https://source.test", + "writer":"writer", + "studio":"studio", + "originalLinks":["https://link.test"], + "tags":["tag"] + } + """.trimIndent() + mockMvc.perform(multipart("/admin/chat/original/register").file(imageFile()).file(jsonPart("request", registerRequest))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true)) + + val updateRequest = """ + { + "id":1, + "title":"title", + "contentType":"type", + "category":"category", + "isAdult":null, + "description":"description", + "originalWork":"source", + "originalLink":"https://source.test", + "writer":"writer", + "studio":"studio", + "originalLinks":["https://link.test"], + "tags":["tag"] + } + """.trimIndent() + mockMvc.perform( + multipart("/admin/chat/original/update") + .file(jsonPart("request", updateRequest)) + .with { request -> + request.method = "PUT" + request + } + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true)) + + val updateRequestCaptor = ArgumentCaptor.forClass(OriginalWorkUpdateRequest::class.java) + Mockito.verify(service).updateOriginalWork(captureUpdateRequest(updateRequestCaptor), Mockito.isNull()) + assertEquals( + OriginalWorkUpdateRequest( + id = 1L, + title = "title", + contentType = "type", + category = "category", + isAdult = null, + description = "description", + originalWork = "source", + originalLink = "https://source.test", + writer = "writer", + studio = "studio", + originalLinks = listOf("https://link.test"), + tags = listOf("tag") + ), + updateRequestCaptor.value + ) + + mockMvc.perform(delete("/admin/chat/original/1")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true)) + Mockito.verify(service).deleteOriginalWork(1L) + + val assignBody = """{"characterIds":[1]}""" + mockMvc.perform( + post("/admin/chat/original/1/assign-characters") + .contentType(MediaType.APPLICATION_JSON) + .content(assignBody) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true)) + Mockito.verify(service).assignCharacters(1L, listOf(1L)) + + mockMvc.perform( + post("/admin/chat/original/1/unassign-characters") + .contentType(MediaType.APPLICATION_JSON) + .content(assignBody) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true)) + Mockito.verify(service).unassignCharacters(1L, listOf(1L)) + } + + private fun controller( + service: AdminOriginalWorkService = Mockito.mock(AdminOriginalWorkService::class.java), + s3Uploader: S3Uploader = Mockito.mock(S3Uploader::class.java) + ): AdminOriginalWorkController { + return AdminOriginalWorkController( + originalWorkService = service, + s3Uploader = s3Uploader, + s3Bucket = "test-bucket", + imageHost = "https://cdn.test" + ) + } + + private fun imageFile(): MockMultipartFile { + return MockMultipartFile("image", "original.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1, 2, 3)) + } + + private fun originalWork(): OriginalWork { + val originalWork = OriginalWork( + title = "title", + contentType = "webtoon", + category = "romance", + isAdult = false, + description = "description", + originalWork = "source", + originalLink = "https://source.test", + writer = "writer", + studio = "studio" + ).apply { + id = 1L + imagePath = "originals/1.png" + } + originalWork.originalLinks += OriginalWorkLink("https://link.test", originalWork) + originalWork.tagMappings += OriginalWorkTagMapping(originalWork, OriginalWorkTag("tag")) + return originalWork + } + + private fun jsonPart(name: String, json: String): MockMultipartFile { + return MockMultipartFile(name, "", MediaType.APPLICATION_JSON_VALUE, json.toByteArray()) + } + + private fun method(name: String, vararg parameterTypes: Class<*>): java.lang.reflect.Method { + return AdminOriginalWorkController::class.java.getDeclaredMethod(name, *parameterTypes) + } + + private fun captureUpdateRequest( + captor: ArgumentCaptor + ): OriginalWorkUpdateRequest { + return captor.capture() ?: OriginalWorkUpdateRequest(id = 0L) + } + + companion object { + private const val LEGACY_EMPTY_SUCCESS_RESPONSE = + """{"success":true,"message":null,"data":null,"errorProperty":null}""" + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberLoginServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberLoginServiceTest.kt index 0ebfbbb5..2572a80b 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberLoginServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberLoginServiceTest.kt @@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.login.LoginRequest +import kr.co.vividnext.sodalive.member.token.MemberToken import kr.co.vividnext.sodalive.member.token.MemberTokenRepository import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertThrows @@ -18,19 +19,22 @@ import org.springframework.security.crypto.password.PasswordEncoder class AdminMemberLoginServiceTest { private lateinit var repository: AdminMemberRepository + private lateinit var memberRepository: MemberRepository private lateinit var passwordEncoder: PasswordEncoder private lateinit var tokenRepository: MemberTokenRepository + private lateinit var tokenProvider: TokenProvider private lateinit var service: AdminMemberLoginService @BeforeEach fun setup() { repository = mock() + memberRepository = mock() passwordEncoder = mock() tokenRepository = mock() - val tokenProvider = TokenProvider( + tokenProvider = TokenProvider( secret = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", tokenValidityInSeconds = 3600, - repository = mock(), + repository = memberRepository, tokenRepository = tokenRepository ) tokenProvider.afterPropertiesSet() @@ -54,6 +58,27 @@ class AdminMemberLoginServiceTest { assertEquals(MemberRole.ADMIN, response.role) } + @Test + @DisplayName("관리자 로그인 token은 기존 TokenProvider로 검증하고 인증 정보를 복원할 수 있다") + fun shouldCreateUsableAdminToken() { + val member = createMember(id = 1L, role = MemberRole.ADMIN) + var savedToken: MemberToken? = null + Mockito.`when`(repository.findByEmail("admin@test.com")).thenReturn(member) + Mockito.`when`(memberRepository.findById(Mockito.eq(1L))).thenReturn(java.util.Optional.of(member)) + Mockito.`when`(tokenRepository.findById(Mockito.eq(1L))).thenAnswer { + java.util.Optional.ofNullable(savedToken) + } + Mockito.`when`(tokenRepository.save(Mockito.any(MemberToken::class.java))).thenAnswer { invocation -> + (invocation.arguments[0] as MemberToken).also { savedToken = it } + } + Mockito.`when`(passwordEncoder.matches("password", "encoded-password")).thenReturn(true) + + val response = service.login(LoginRequest(email = "admin@test.com", password = "password")) + + assertTrue(tokenProvider.validateToken(response.token)) + assertEquals(member.email, tokenProvider.getAuthentication(response.token).name) + } + @Test @DisplayName("콘텐츠 관리자는 관리자 로그인 API로 token과 role을 받는다") fun shouldLoginContentManager() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkControllerContractTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkControllerContractTest.kt new file mode 100644 index 00000000..030061b3 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkControllerContractTest.kt @@ -0,0 +1,276 @@ +package kr.co.vividnext.sodalive.chat.original.controller + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.chat.character.image.CharacterImageRepository +import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository +import kr.co.vividnext.sodalive.chat.original.OriginalWork +import kr.co.vividnext.sodalive.chat.original.OriginalWorkLink +import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag +import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping +import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkQueryService +import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkTranslationService +import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository +import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.configs.SecurityConfig +import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler +import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint +import kr.co.vividnext.sodalive.jwt.TokenProvider +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.annotation.Import +import org.springframework.core.MethodParameter +import org.springframework.data.domain.PageImpl +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer +import java.time.LocalDateTime + +@WebMvcTest(OriginalWorkController::class) +@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class) +class OriginalWorkControllerContractTest @Autowired constructor( + private val securityMockMvc: MockMvc +) { + @MockBean + private lateinit var securedQueryService: OriginalWorkQueryService + + @MockBean + private lateinit var securedCharacterImageRepository: CharacterImageRepository + + @MockBean + private lateinit var securedMemberContentPreferenceService: MemberContentPreferenceService + + @MockBean + private lateinit var securedLangContext: LangContext + + @MockBean + private lateinit var securedOriginalWorkTranslationService: OriginalWorkTranslationService + + @MockBean + private lateinit var securedOriginalWorkTranslationRepository: OriginalWorkTranslationRepository + + @MockBean + private lateinit var securedAiCharacterTranslationRepository: AiCharacterTranslationRepository + + @MockBean + private lateinit var tokenProvider: TokenProvider + + @MockBean + private lateinit var countryContext: CountryContext + + @MockBean + private lateinit var sodaMessageSource: SodaMessageSource + + @Test + @DisplayName("일반 사용자 원작 API는 기존 base path와 공개 목록 경로를 유지한다") + fun shouldKeepConsumerOriginalWorkListRoute() { + val classMapping = OriginalWorkController::class.java.getAnnotation(RequestMapping::class.java) + val list = OriginalWorkController::class.java.getDeclaredMethod( + "list", + Int::class.java, + Int::class.java, + Member::class.java + ) + + assertEquals("/api/chat/original", classMapping.value.single()) + assertEquals("/list", list.getAnnotation(GetMapping::class.java).value.single()) + assertNull(list.getAnnotation(PreAuthorize::class.java)) + } + + @Test + @DisplayName("일반 사용자 원작 상세 API는 기존 경로를 유지한다") + fun shouldKeepConsumerOriginalWorkDetailRoute() { + val detail = OriginalWorkController::class.java.getDeclaredMethod("detail", Long::class.java, Member::class.java) + + assertEquals("/{id}", detail.getAnnotation(GetMapping::class.java).value.single()) + } + + @Test + @DisplayName("production security matcher는 익명 원작 목록은 허용하고 상세는 거부한다") + fun shouldApplyProductionSecurityMatcherToAnonymousOriginalWorkRequests() { + Mockito.`when`(securedQueryService.listForAppPage(false, 0, 20)).thenReturn(PageImpl(emptyList())) + + securityMockMvc.perform(get("/api/chat/original/list").with(anonymous())) + .andExpect(status().isOk) + + securityMockMvc.perform(get("/api/chat/original/1").with(anonymous())) + .andExpect(status().isUnauthorized) + } + + @Test + @DisplayName("일반 사용자 원작 목록은 기존 성공 응답 surface를 유지한다") + fun shouldKeepConsumerOriginalWorkListResponseSurface() { + val queryService = Mockito.mock(OriginalWorkQueryService::class.java) + val originalWork = originalWork() + Mockito.`when`(queryService.listForAppPage(false, 0, 20)).thenReturn(PageImpl(listOf(originalWork))) + val langContext = Mockito.mock(LangContext::class.java) + Mockito.`when`(langContext.lang).thenReturn(Lang.KO) + val originalWorkTranslationRepository = Mockito.mock(OriginalWorkTranslationRepository::class.java) + Mockito.`when`(originalWorkTranslationRepository.findByOriginalWorkIdInAndLocale(setOf(1L), "ko")) + .thenReturn(emptyList()) + val controller = OriginalWorkController( + queryService = queryService, + characterImageRepository = Mockito.mock(CharacterImageRepository::class.java), + memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java), + langContext = langContext, + originalWorkTranslationService = Mockito.mock(OriginalWorkTranslationService::class.java), + originalWorkTranslationRepository = originalWorkTranslationRepository, + aiCharacterTranslationRepository = Mockito.mock(AiCharacterTranslationRepository::class.java), + imageHost = "https://cdn.test" + ) + val mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setCustomArgumentResolvers(AnonymousMemberArgumentResolver()) + .build() + + mockMvc.perform(get("/api/chat/original/list").param("page", "0").param("size", "20")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.content").isArray) + .andExpect(jsonPath("$.data.content[0].id").value(1)) + .andExpect(jsonPath("$.data.content[0].imageUrl").value("https://cdn.test/originals/1.png")) + .andExpect(jsonPath("$.data.content[0].title").value("title")) + .andExpect(jsonPath("$.data.content[0].contentType").value("webtoon")) + } + + @Test + @DisplayName("일반 사용자 원작 상세는 기존 성공 응답 field를 유지한다") + fun shouldKeepConsumerOriginalWorkDetailResponseSurface() { + val queryService = Mockito.mock(OriginalWorkQueryService::class.java) + val originalWork = originalWork() + val character = ChatCharacter( + characterUUID = "character-uuid", + name = "character", + description = "character description", + systemPrompt = "prompt" + ).apply { + id = 10L + imagePath = "characters/10.png" + } + Mockito.`when`(queryService.getOriginalWork(1L)).thenReturn(originalWork) + Mockito.`when`(queryService.getActiveCharactersPage(1L, 0, 20)).thenReturn(PageImpl(listOf(character))) + val characterImageRepository = Mockito.mock(CharacterImageRepository::class.java) + Mockito.`when`( + characterImageRepository.findCharacterIdsWithRecentImages( + Mockito.eq(listOf(10L)) ?: listOf(10L), + Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now() + ) + ) + .thenReturn(listOf(10L)) + val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java) + val member = Member(email = "user@test.com", password = "password", nickname = "user").apply { id = 20L } + Mockito.`when`(memberContentPreferenceService.getStoredPreference(member)) + .thenReturn(ViewerContentPreference("KR", true, ContentType.ALL, true)) + val langContext = Mockito.mock(LangContext::class.java) + Mockito.`when`(langContext.lang).thenReturn(Lang.KO) + val originalWorkTranslationService = Mockito.mock(OriginalWorkTranslationService::class.java) + Mockito.`when`(originalWorkTranslationService.ensureTranslated(originalWork, "ko")).thenReturn(null) + val aiCharacterTranslationRepository = Mockito.mock(AiCharacterTranslationRepository::class.java) + Mockito.`when`(aiCharacterTranslationRepository.findByCharacterIdInAndLocale(listOf(10L), "ko")) + .thenReturn(emptyList()) + val controller = OriginalWorkController( + queryService = queryService, + characterImageRepository = characterImageRepository, + memberContentPreferenceService = memberContentPreferenceService, + langContext = langContext, + originalWorkTranslationService = originalWorkTranslationService, + originalWorkTranslationRepository = Mockito.mock(OriginalWorkTranslationRepository::class.java), + aiCharacterTranslationRepository = aiCharacterTranslationRepository, + imageHost = "https://cdn.test" + ) + val mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setCustomArgumentResolvers(MemberArgumentResolver(member)) + .build() + + mockMvc.perform(get("/api/chat/original/1")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.imageUrl").value("https://cdn.test/originals/1.png")) + .andExpect(jsonPath("$.data.title").value("title")) + .andExpect(jsonPath("$.data.contentType").value("webtoon")) + .andExpect(jsonPath("$.data.category").value("romance")) + .andExpect(jsonPath("$.data.isAdult").value(false)) + .andExpect(jsonPath("$.data.description").value("description")) + .andExpect(jsonPath("$.data.originalWork").value("source")) + .andExpect(jsonPath("$.data.originalLink").value("https://source.test")) + .andExpect(jsonPath("$.data.writer").value("writer")) + .andExpect(jsonPath("$.data.studio").value("studio")) + .andExpect(jsonPath("$.data.originalLinks[0]").value("https://link.test")) + .andExpect(jsonPath("$.data.tags[0]").value("tag")) + .andExpect(jsonPath("$.data.characters[0].characterId").value(10)) + .andExpect(jsonPath("$.data.characters[0].name").value("character")) + .andExpect(jsonPath("$.data.characters[0].description").value("character description")) + .andExpect(jsonPath("$.data.characters[0].imageUrl").value("https://cdn.test/characters/10.png")) + .andExpect(jsonPath("$.data.characters[0].isNew").value(true)) + .andExpect(jsonPath("$.data.translated").doesNotExist()) + } + + private class AnonymousMemberArgumentResolver : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean { + return parameter.hasParameterAnnotation(AuthenticationPrincipal::class.java) + } + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory? + ): Any? = null + } + + private class MemberArgumentResolver(private val member: Member) : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean { + return parameter.hasParameterAnnotation(AuthenticationPrincipal::class.java) + } + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory? + ): Any = member + } + + private fun originalWork(): OriginalWork { + val originalWork = OriginalWork( + title = "title", + contentType = "webtoon", + category = "romance", + isAdult = false, + description = "description", + originalWork = "source", + originalLink = "https://source.test", + writer = "writer", + studio = "studio" + ).apply { + id = 1L + imagePath = "originals/1.png" + } + originalWork.originalLinks += OriginalWorkLink("https://link.test", originalWork) + originalWork.tagMappings += OriginalWorkTagMapping(originalWork, OriginalWorkTag("tag")) + return originalWork + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentUploadCompletionContractTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentUploadCompletionContractTest.kt new file mode 100644 index 00000000..57db0037 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentUploadCompletionContractTest.kt @@ -0,0 +1,162 @@ +package kr.co.vividnext.sodalive.content + +import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.configs.SecurityConfig +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler +import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint +import kr.co.vividnext.sodalive.jwt.TokenProvider +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import org.hamcrest.Matchers.anEmptyMap +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.mockito.Mockito.verify +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.annotation.Import +import org.springframework.http.MediaType +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestMapping + +@WebMvcTest(AudioContentController::class) +@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class) +class AudioContentUploadCompletionContractTest @Autowired constructor( + private val mockMvc: MockMvc +) { + @MockBean + private lateinit var service: AudioContentService + + @MockBean + private lateinit var memberContentPreferenceService: MemberContentPreferenceService + + @MockBean + private lateinit var tokenProvider: TokenProvider + + @MockBean + private lateinit var countryContext: CountryContext + + @MockBean + private lateinit var langContext: LangContext + + @MockBean + private lateinit var sodaMessageSource: SodaMessageSource + + @Test + @DisplayName("upload-complete callback은 기존 PUT 경로와 ADMIN/BOT 권한 계약을 유지한다") + fun shouldKeepUploadCompleteRouteAndRoles() { + val classMapping = AudioContentController::class.java.getAnnotation(RequestMapping::class.java) + val method = AudioContentController::class.java.getDeclaredMethod( + "uploadComplete", + UploadCompleteRequest::class.java, + kr.co.vividnext.sodalive.member.Member::class.java + ) + val putMapping = method.getAnnotation(PutMapping::class.java) + val preAuthorize = method.getAnnotation(PreAuthorize::class.java) + + assertEquals("/audio-content", classMapping.value.single()) + assertEquals("/upload-complete", putMapping.value.single()) + assertEquals("hasAnyRole('ADMIN', 'BOT')", preAuthorize.value) + } + + @Test + @DisplayName("upload-complete request는 기존 contentId/contentPath/duration field를 유지한다") + fun shouldKeepUploadCompleteRequestFields() { + val request = UploadCompleteRequest(contentId = 1L, contentPath = "1/output.mp3", duration = "00:01:00") + + assertEquals(1L, request.contentId) + assertEquals("1/output.mp3", request.contentPath) + assertEquals("00:01:00", request.duration) + } + + @Test + @DisplayName("upload-complete callback은 기존 성공 응답 surface를 유지한다") + fun shouldKeepUploadCompleteSuccessResponseSurface() { + givenToken("bot-token", MemberRole.BOT) + + mockMvc.perform( + put("/audio-content/upload-complete") + .header("Authorization", "Bearer bot-token") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"contentId":1,"contentPath":"1/output.mp3","duration":"00:01:00"}""") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").value(anEmptyMap())) + .andExpect(content().json(LEGACY_UPLOAD_COMPLETE_SUCCESS_RESPONSE, true)) + + verify(service).uploadComplete(1L, "1/output.mp3", "00:01:00") + } + + @Test + @DisplayName("upload-complete callback은 ADMIN/BOT JWT만 허용하고 USER/invalid JWT/익명 요청은 거부한다") + fun shouldAuthorizeUploadCompleteWithProductionSecurityChain() { + givenToken("admin-token", MemberRole.ADMIN) + givenToken("bot-token", MemberRole.BOT) + givenToken("user-token", MemberRole.USER) + Mockito.`when`(tokenProvider.validateToken("invalid-token")).thenReturn(false) + + mockMvc.perform(uploadCompleteRequest("user-token")) + .andExpect(status().isForbidden) + + mockMvc.perform(uploadCompleteRequest("invalid-token")) + .andExpect(status().isUnauthorized) + + mockMvc.perform(uploadCompleteRequest("admin-token")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + + mockMvc.perform(uploadCompleteRequest("bot-token")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + + mockMvc.perform( + put("/audio-content/upload-complete") + .contentType(MediaType.APPLICATION_JSON) + .content(UPLOAD_COMPLETE_REQUEST) + ) + .andExpect(status().isUnauthorized) + } + + private fun uploadCompleteRequest(token: String) = put("/audio-content/upload-complete") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .content(UPLOAD_COMPLETE_REQUEST) + + private fun givenToken(token: String, role: MemberRole) { + val member = Member( + email = "${role.name.lowercase()}@test.com", + password = "password", + nickname = role.name.lowercase(), + role = role + ).apply { id = role.ordinal.toLong() + 1 } + val authentication = UsernamePasswordAuthenticationToken( + MemberAdapter(member), + token, + MemberAdapter(member).authorities + ) + Mockito.`when`(tokenProvider.validateToken(token)).thenReturn(true) + Mockito.`when`(tokenProvider.getAuthentication(token)).thenReturn(authentication) + } + + companion object { + private const val UPLOAD_COMPLETE_REQUEST = + """{"contentId":1,"contentPath":"1/output.mp3","duration":"00:01:00"}""" + + private const val LEGACY_UPLOAD_COMPLETE_SUCCESS_RESPONSE = + """{"success":true,"message":null,"data":{},"errorProperty":null}""" + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/legacy/LegacyAdminSearchQueryContractTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/legacy/LegacyAdminSearchQueryContractTest.kt new file mode 100644 index 00000000..40f5824c --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/legacy/LegacyAdminSearchQueryContractTest.kt @@ -0,0 +1,210 @@ +package kr.co.vividnext.sodalive.legacy + +import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService +import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.chat.character.ChatCharacterTag +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository +import kr.co.vividnext.sodalive.chat.original.OriginalWork +import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository +import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.member.Member +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.context.annotation.Import +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@DataJpaTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:legacy-admin-search-contract;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(QueryDslConfig::class) +class LegacyAdminSearchQueryContractTest @Autowired constructor( + private val chatCharacterRepository: ChatCharacterRepository, + private val originalWorkRepository: OriginalWorkRepository, + private val entityManager: EntityManager +) { + private val characterService = AdminChatCharacterService(chatCharacterRepository) + private val originalWorkService = AdminOriginalWorkService( + originalWorkRepository = originalWorkRepository, + chatCharacterRepository = chatCharacterRepository, + originalWorkTagRepository = Mockito.mock(OriginalWorkTagRepository::class.java), + applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + ) + + @Test + @DisplayName("legacy 캐릭터 검색은 이름, 설명, MBTI, 태그를 검색하고 비활성 캐릭터를 제외한다") + fun shouldSearchActiveLegacyCharactersByEverySupportedField() { + val nameId = saveCharacter(name = "NameNeedle", description = "plain-name-description").id!! + val descriptionId = saveCharacter(name = "description-character", description = "DescriptionNeedle").id!! + val mbtiId = saveCharacter( + name = "mbti-character", + description = "plain-mbti-description", + mbti = "MbtiNeedle" + ).id!! + val tagId = saveCharacter(name = "tag-character", description = "plain-tag-description", tag = "TagNeedle").id!! + saveCharacter(name = "InactiveNeedle", description = "inactive-description", isActive = false) + entityManager.clear() + + assertEquals(listOf(nameId), searchCharacterIds("nameneedle")) + assertEquals(listOf(descriptionId), searchCharacterIds("descriptionneedle")) + assertEquals(listOf(mbtiId), searchCharacterIds("mbtineedle")) + assertEquals(listOf(tagId), searchCharacterIds("tagneedle")) + assertEquals(emptyList(), searchCharacterIds("inactiveneedle")) + } + + @Test + @DisplayName("legacy 캐릭터 검색은 최신순과 비기본 page, size, totalCount 계약을 유지한다") + fun shouldPageLegacyCharacterSearchByCreatedAtDescending() { + val baseTime = LocalDateTime.of(2026, 7, 21, 0, 0) + repeat(5) { index -> + saveCharacter( + name = "paging-needle-$index", + description = "paging-description-$index", + createdAt = baseTime.plusMinutes(index.toLong()) + ) + } + saveCharacter( + name = "paging-needle-inactive", + description = "paging-inactive-description", + isActive = false, + createdAt = baseTime.plusMinutes(10) + ) + entityManager.clear() + + val pageable = characterService.createDefaultPageRequest(page = 1, size = 2) + val result = characterService.searchCharacters("paging-needle", pageable) + + assertEquals(5L, result.totalElements) + assertEquals(1, result.number) + assertEquals(2, result.size) + assertEquals(listOf("paging-needle-2", "paging-needle-1"), result.content.map { it.name }) + } + + @Test + @DisplayName("legacy 원작 검색은 제목, 콘텐츠 타입, 카테고리를 검색하고 삭제 원작을 제외한다") + fun shouldSearchNonDeletedLegacyOriginalWorksByEverySupportedField() { + val titleId = saveOriginalWork( + title = "TitleNeedle", + contentType = "plain-title-type", + category = "plain-title-category" + ).id!! + val contentTypeId = saveOriginalWork( + title = "content-type-work", + contentType = "ContentTypeNeedle", + category = "plain-content-type-category" + ).id!! + val categoryId = saveOriginalWork( + title = "category-work", + contentType = "plain-category-type", + category = "CategoryNeedle" + ).id!! + saveOriginalWork( + title = "DeletedNeedle", + contentType = "deleted-type", + category = "deleted-category", + isDeleted = true + ) + entityManager.clear() + + assertEquals(listOf(titleId), searchOriginalWorkIds("titleneedle")) + assertEquals(listOf(contentTypeId), searchOriginalWorkIds("contenttypeneedle")) + assertEquals(listOf(categoryId), searchOriginalWorkIds("categoryneedle")) + assertEquals(emptyList(), searchOriginalWorkIds("deletedneedle")) + } + + @Test + @DisplayName("legacy 원작 검색은 최신순 무페이징 목록 계약을 유지한다") + fun shouldReturnAllLegacyOriginalWorkSearchResultsByCreatedAtDescending() { + val baseTime = LocalDateTime.of(2026, 7, 21, 0, 0) + repeat(3) { index -> + saveOriginalWork( + title = "ordering-needle-$index", + contentType = "ordering-type-$index", + category = "ordering-category-$index", + createdAt = baseTime.plusMinutes(index.toLong()) + ) + } + entityManager.clear() + + val result = originalWorkService.searchOriginalWorksAll("ordering-needle") + + assertEquals( + listOf("ordering-needle-2", "ordering-needle-1", "ordering-needle-0"), + result.map { it.title } + ) + } + + private fun searchCharacterIds(searchTerm: String): List { + val pageable = characterService.createDefaultPageRequest(page = 0, size = 20) + return characterService.searchCharacters(searchTerm, pageable).content.map { it.id } + } + + private fun searchOriginalWorkIds(searchTerm: String): List { + return originalWorkService.searchOriginalWorksAll(searchTerm).map { it.id!! } + } + + private fun saveCharacter( + name: String, + description: String, + mbti: String? = null, + tag: String? = null, + isActive: Boolean = true, + createdAt: LocalDateTime? = null + ): ChatCharacter { + val character = ChatCharacter( + characterUUID = "$name-uuid", + name = name, + description = description, + systemPrompt = "$name-system-prompt", + mbti = mbti, + isActive = isActive + ) + character.creatorMember = Member( + email = "$name@test.com", + password = "password", + nickname = "$name-creator" + ).also(entityManager::persist) + tag?.let { tagName -> + val tagEntity = ChatCharacterTag(tagName).also(entityManager::persist) + character.addTag(tagEntity) + } + chatCharacterRepository.saveAndFlush(character) + if (createdAt != null) { + character.createdAt = createdAt + entityManager.flush() + } + return character + } + + private fun saveOriginalWork( + title: String, + contentType: String, + category: String, + isDeleted: Boolean = false, + createdAt: LocalDateTime? = null + ): OriginalWork { + val originalWork = OriginalWork( + title = title, + contentType = contentType, + category = category + ).apply { this.isDeleted = isDeleted } + originalWorkRepository.saveAndFlush(originalWork) + if (createdAt != null) { + originalWork.createdAt = createdAt + entityManager.flush() + } + return originalWork + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/legacy/LegacySodaExceptionHttpStatusContractTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/legacy/LegacySodaExceptionHttpStatusContractTest.kt new file mode 100644 index 00000000..8a819553 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/legacy/LegacySodaExceptionHttpStatusContractTest.kt @@ -0,0 +1,119 @@ +package kr.co.vividnext.sodalive.legacy + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.annotation.Import +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MaxUploadSizeExceededException +import org.springframework.web.multipart.MultipartException +import org.springframework.web.multipart.MultipartResolver +import org.springframework.web.servlet.DispatcherServlet +import javax.servlet.http.HttpServletRequest + +@SpringBootTest( + properties = [ + "cloud.aws.cloud-front.host=https://cdn.test", + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:legacy-soda-exception-http-contract;" + + "MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@AutoConfigureMockMvc +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@Import(LegacySodaExceptionHttpStatusContractTest.TestLegacyController::class) +class LegacySodaExceptionHttpStatusContractTest @Autowired constructor( + private val mockMvc: MockMvc +) { + @MockBean(name = DispatcherServlet.MULTIPART_RESOLVER_BEAN_NAME) + private lateinit var multipartResolver: MultipartResolver + + @Test + @DisplayName("legacy route의 SodaException은 기존 HTTP 200 오류 envelope를 유지한다") + fun shouldPreserveLegacySodaExceptionHttp200() { + mockMvc.perform(get("/legacy-ai-character-admin-test").with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + } + + @Test + @DisplayName("legacy route는 httpStatus가 있는 SodaException도 HTTP 200 오류 envelope를 유지한다") + fun shouldPreserveLegacySodaExceptionHttp200WhenExceptionHasHttpStatus() { + mockMvc.perform(get("/legacy-ai-character-admin-test/conflict").with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + } + + @Test + @DisplayName("admin ai-character multipart 크기 오류는 handler 선택 전에도 HTTP 400을 반환한다") + fun shouldReturnBadRequestForAiCharacterAdminMaxUploadSizeExceeded() { + givenMultipartResolutionFailure(MaxUploadSizeExceededException(1L)) + + mockMvc.perform( + post("/admin/ai-characters/upload-size-test") + .with(user("admin").roles("ADMIN")) + .contentType(MediaType.MULTIPART_FORM_DATA) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("파일용량은 최대 1024MB까지 저장할 수 있습니다.")) + + Mockito.verify(multipartResolver).resolveMultipart(Mockito.any(HttpServletRequest::class.java)) + } + + @Test + @DisplayName("legacy multipart 형식 오류는 handler 선택 전에도 기존 HTTP 200 unknown 오류를 유지한다") + fun shouldPreserveLegacyUnknownMessageForMalformedMultipartBeforeHandlerSelection() { + givenMultipartResolutionFailure(MultipartException("malformed multipart")) + + mockMvc.perform( + post("/legacy-ai-character-admin-test/upload") + .with(user("admin").roles("ADMIN")) + .contentType(MediaType.MULTIPART_FORM_DATA) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")) + + Mockito.verify(multipartResolver).resolveMultipart(Mockito.any(HttpServletRequest::class.java)) + } + + private fun givenMultipartResolutionFailure(exception: MultipartException) { + Mockito.`when`(multipartResolver.isMultipart(Mockito.any(HttpServletRequest::class.java))).thenReturn(true) + Mockito.`when`(multipartResolver.resolveMultipart(Mockito.any(HttpServletRequest::class.java))).thenThrow(exception) + } + + @RestController + @RequestMapping("/legacy-ai-character-admin-test") + class TestLegacyController { + @GetMapping + fun legacy(): ApiResponse { + throw SodaException(messageKey = "common.error.invalid_request") + } + + @GetMapping("/conflict") + fun legacyWithHttpStatus(): ApiResponse { + throw SodaException(messageKey = "common.error.invalid_request", httpStatus = HttpStatus.CONFLICT) + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminImagePartValidatorTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminImagePartValidatorTest.kt new file mode 100644 index 00000000..4c87f55f --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminImagePartValidatorTest.kt @@ -0,0 +1,1054 @@ +package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web + +import kr.co.vividnext.sodalive.common.SodaException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.web.multipart.MultipartFile +import java.awt.Rectangle +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.util.Locale +import java.util.zip.CRC32 +import java.util.zip.DeflaterOutputStream +import javax.imageio.IIOException +import javax.imageio.ImageIO +import javax.imageio.ImageReadParam +import javax.imageio.ImageReader +import javax.imageio.ImageTypeSpecifier +import javax.imageio.metadata.IIOMetadata +import javax.imageio.spi.IIORegistry +import javax.imageio.spi.ImageReaderSpi +import javax.imageio.stream.ImageInputStream + +class AdminImagePartValidatorTest { + private val validator = AdminImagePartValidator() + + @Test + @DisplayName("PNG 이미지는 실제 bytes 기준으로 통과한다") + fun shouldAcceptPngByActualBytes() { + val image = multipartImage(name = "image", extension = "png", contentType = MediaType.TEXT_PLAIN_VALUE) + + val validated = validator.validate(image = image, allowGif = false) + + assertEquals("image/png", validated.contentType) + assertEquals("png", validated.extension) + } + + @Test + @DisplayName("JPEG 이미지는 실제 bytes 기준으로 통과한다") + fun shouldAcceptJpegByActualBytes() { + val image = multipartImage(name = "image", extension = "jpg", contentType = MediaType.TEXT_PLAIN_VALUE) + + val validated = validator.validate(image = image, allowGif = false) + + assertEquals("image/jpeg", validated.contentType) + assertEquals("jpg", validated.extension) + } + + @Test + @DisplayName("GIF는 allowGif가 false이면 거부한다") + fun shouldRejectGifWhenGifIsNotAllowed() { + val image = multipartImage(name = "image", extension = "gif", contentType = MediaType.IMAGE_GIF_VALUE) + + assertThrows(SodaException::class.java) { + validator.validate(image = image, allowGif = false) + } + } + + @Test + @DisplayName("GIF는 allowGif가 true이면 통과한다") + fun shouldAcceptGifWhenGifIsAllowed() { + val image = multipartImage(name = "image", extension = "gif", contentType = MediaType.IMAGE_GIF_VALUE) + + val validated = validator.validate(image = image, allowGif = true) + + assertEquals("image/gif", validated.contentType) + assertEquals("gif", validated.extension) + } + + @Test + @DisplayName("LZW dictionary 크기가 증가하는 유효 GIF도 통과한다") + fun shouldAcceptGifWithGrowingLzwDictionary() { + val bufferedImage = BufferedImage(128, 128, BufferedImage.TYPE_INT_RGB) + repeat(bufferedImage.height) { y -> + repeat(bufferedImage.width) { x -> + val shade = (x * 37 + y * 17) and 0xFF + bufferedImage.setRGB(x, y, (shade shl 16) or (shade shl 8) or shade) + } + } + val output = ByteArrayOutputStream() + ImageIO.write(bufferedImage, "gif", output) + val image = MockMultipartFile("image", "image.gif", MediaType.IMAGE_GIF_VALUE, output.toByteArray()) + + val validated = validator.validate(image = image, allowGif = true) + + assertEquals("gif", validated.extension) + } + + @Test + @DisplayName("이미지가 아닌 bytes는 거부한다") + fun shouldRejectNonImageBytes() { + val file = MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, "not-image".toByteArray()) + + assertThrows(SodaException::class.java) { + validator.validate(image = file, allowGif = true) + } + } + + @Test + @DisplayName("손상된 JPEG bytes는 거부한다") + fun shouldRejectCorruptJpegBytes() { + val file = MockMultipartFile("image", "image.jpg", MediaType.IMAGE_JPEG_VALUE, byteArrayOf(0xFF.toByte(), 0xD8.toByte())) + + assertThrows(SodaException::class.java) { + validator.validate(image = file, allowGif = true) + } + } + + @Test + @DisplayName("frame이 없는 GIF는 400 invalid image로 거부한다") + fun shouldRejectGifWithoutImageFrameAsBadRequest() { + val file = multipartGif(logicalWidth = 1, logicalHeight = 1) + + val exception = assertThrows(SodaException::class.java) { + validator.validate(image = file, allowGif = true) + } + + assertEquals(HttpStatus.BAD_REQUEST, exception.httpStatus) + } + + @Test + @DisplayName("ImageIO reader의 입력 유래 IllegalArgumentException은 400 invalid image로 거부한다") + fun shouldRejectInputCausedIllegalArgumentExceptionAsBadRequest() { + val provider = OnePixelRegionImageReaderSpi(dimensionFailure = IllegalArgumentException("malformed dimensions")) + withImageReaderProvider(provider) { + val file = MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, TRACKING_IMAGE_BYTES) + + val exception = assertThrows(SodaException::class.java) { + validator.validate(image = file, allowGif = false) + } + + assertTrue(provider.widthRequested) + assertEquals(HttpStatus.BAD_REQUEST, exception.httpStatus) + } + } + + @Test + @DisplayName("ImageIO reader의 입력 유래 RuntimeException은 400 invalid image로 거부한다") + fun shouldRejectInputCausedRuntimeExceptionAsBadRequest() { + val provider = OnePixelRegionImageReaderSpi(readFailure = IllegalStateException("malformed raster")) + withImageReaderProvider(provider) { + val file = MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, TRACKING_IMAGE_BYTES) + + val exception = assertThrows(SodaException::class.java) { + validator.validate(image = file, allowGif = false) + } + + assertTrue(provider.readRequested) + assertEquals(HttpStatus.BAD_REQUEST, exception.httpStatus) + } + } + + @Test + @DisplayName("format 확인은 metadata를 읽지 않고 1x1 source region만 디코딩한다") + fun shouldDecodeOnlyOnePixelRegionWhenDetectingFormat() { + val provider = OnePixelRegionImageReaderSpi() + withImageReaderProvider(provider) { + val file = MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, TRACKING_IMAGE_BYTES) + + val validated = validator.validate(image = file, allowGif = false) + + assertEquals("png", validated.extension) + assertEquals(true, provider.ignoreMetadataRequested) + } + } + + @Test + @DisplayName("압축 PNG metadata는 payload 상한 안에서 유효 이미지로 처리한다") + fun shouldAcceptPngWithCompressedTextMetadata() { + val file = MockMultipartFile( + "image", + "image.png", + MediaType.IMAGE_PNG_VALUE, + pngWithCompressedTextChunk(uncompressedSize = 2 * 1024 * 1024) + ) + + val validated = validator.validate(image = file, allowGif = false) + + assertEquals("png", validated.extension) + } + + @Test + @DisplayName("1KB를 넘는 유효 PNG ancillary payload는 통과한다") + fun shouldAcceptValidPngWithAncillaryPayloadOverOneKilobyte() { + val file = MockMultipartFile( + "image", + "image.png", + MediaType.IMAGE_PNG_VALUE, + pngWithTextChunk(payloadSize = 1_025) + ) + + val validated = validator.validate(image = file, allowGif = false) + + assertEquals("png", validated.extension) + } + + @Test + @DisplayName("PNG ancillary chunk payload가 상한을 넘으면 ImageIO 전에 거부한다") + fun shouldRejectPngWithOversizedAncillaryChunkBeforeImageIo() { + val provider = OnePixelRegionImageReaderSpi() + withImageReaderProvider(provider) { + val file = MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, pngWithOversizedTextChunk()) + + assertThrows(SodaException::class.java) { + validator.validate(image = file, allowGif = false) + } + + assertEquals(false, provider.decodeInputRequested) + } + } + + @Test + @DisplayName("PNG ancillary payload가 정확히 1MB이면 통과한다") + fun shouldAcceptPngWithMaximumAncillaryPayload() { + val file = MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, pngWithTextChunk(payloadSize = 1024 * 1024)) + + assertEquals("png", validator.validate(file, allowGif = false).extension) + } + + @Test + @DisplayName("PNG chunk가 정확히 4096개이면 통과하고 4097개이면 ImageIO 전에 거부한다") + fun shouldEnforcePngChunkCountBoundaryBeforeImageIo() { + val acceptedProvider = OnePixelRegionImageReaderSpi() + withImageReaderProvider(acceptedProvider) { + val accepted = MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, pngWithTotalChunkCount(4_096)) + assertEquals("png", validator.validate(accepted, allowGif = false).extension) + } + + val rejectedProvider = OnePixelRegionImageReaderSpi() + withImageReaderProvider(rejectedProvider) { + val rejected = MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, pngWithTotalChunkCount(4_097)) + assertThrows(SodaException::class.java) { validator.validate(rejected, allowGif = false) } + assertEquals(false, rejectedProvider.decodeInputRequested) + } + } + + @Test + @DisplayName("PNG chunk 길이가 overflow되는 malformed 입력도 400 invalid image로 거부한다") + fun shouldRejectPngWithOverflowingChunkLengthAsBadRequest() { + val output = ByteArrayOutputStream() + output.write(PNG_SIGNATURE_BYTES) + output.writeInt(Int.MAX_VALUE) + output.write("IHDR".toByteArray(Charsets.US_ASCII)) + output.writeInt(0) + val file = MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, output.toByteArray()) + + val exception = assertThrows(SodaException::class.java) { + validator.validate(image = file, allowGif = false) + } + + assertEquals(HttpStatus.BAD_REQUEST, exception.httpStatus) + } + + @Test + @DisplayName("GIF extension payload가 상한을 넘으면 ImageIO 전에 거부한다") + fun shouldRejectGifWithOversizedExtensionPayloadBeforeImageIo() { + val provider = TrackingGifImageReaderSpi(frameCount = 1) + withImageReaderProvider(provider) { + val file = MockMultipartFile("image", "image.gif", MediaType.IMAGE_GIF_VALUE, gifWithOversizedCommentExtension()) + + assertThrows(SodaException::class.java) { + validator.validate(image = file, allowGif = true) + } + + assertEquals(false, provider.decodeInputRequested) + } + } + + @Test + @DisplayName("GIF extension payload가 정확히 1MB이면 통과하고 1MB 초과면 거부한다") + fun shouldEnforceGifExtensionPayloadBoundary() { + val acceptedProvider = TrackingGifImageReaderSpi(frameCount = 1) + withImageReaderProvider(acceptedProvider) { + val accepted = MockMultipartFile("image", "image.gif", MediaType.IMAGE_GIF_VALUE, gifWithCommentPayload(1024 * 1024)) + assertEquals("gif", validator.validate(accepted, allowGif = true).extension) + } + + val rejectedProvider = TrackingGifImageReaderSpi(frameCount = 1) + withImageReaderProvider(rejectedProvider) { + val rejected = MockMultipartFile( + "image", + "image.gif", + MediaType.IMAGE_GIF_VALUE, + gifWithCommentPayload(1024 * 1024 + 1) + ) + assertThrows(SodaException::class.java) { validator.validate(rejected, allowGif = true) } + assertEquals(false, rejectedProvider.decodeInputRequested) + } + } + + @Test + @DisplayName("GIF extension 1024개와 sub-block 64개는 통과한다") + fun shouldAcceptGifAtExtensionAndSubBlockBoundaries() { + val extensionProvider = TrackingGifImageReaderSpi(frameCount = 1) + withImageReaderProvider(extensionProvider) { + val file = MockMultipartFile("image", "image.gif", MediaType.IMAGE_GIF_VALUE, gifWithCommentExtensions(1_024, 0)) + assertEquals("gif", validator.validate(file, allowGif = true).extension) + } + + val subBlockProvider = TrackingGifImageReaderSpi(frameCount = 1) + withImageReaderProvider(subBlockProvider) { + val file = MockMultipartFile("image", "image.gif", MediaType.IMAGE_GIF_VALUE, gifWithCommentExtensions(1, 64)) + assertEquals("gif", validator.validate(file, allowGif = true).extension) + } + } + + @Test + @DisplayName("GIF frame 500개는 통과하고 501개는 ImageIO 전에 거부한다") + fun shouldEnforceGifFrameCountBoundaryBeforeImageIo() { + val acceptedProvider = TrackingGifImageReaderSpi(frameCount = 500) + withImageReaderProvider(acceptedProvider) { + val file = multipartGif(1, 1, *Array(500) { 1 to 1 }) + assertEquals("gif", validator.validate(file, allowGif = true).extension) + } + + val rejectedProvider = TrackingGifImageReaderSpi(frameCount = 501) + withImageReaderProvider(rejectedProvider) { + val file = multipartGif(1, 1, *Array(501) { 1 to 1 }) + assertThrows(SodaException::class.java) { validator.validate(file, allowGif = true) } + assertEquals(false, rejectedProvider.decodeInputRequested) + } + } + + @Test + @DisplayName("GIF extension 개수가 상한을 넘으면 ImageIO 전에 거부한다") + fun shouldRejectGifWithTooManyExtensionsBeforeImageIo() { + val provider = TrackingGifImageReaderSpi(frameCount = 1) + withImageReaderProvider(provider) { + val file = MockMultipartFile( + "image", + "image.gif", + MediaType.IMAGE_GIF_VALUE, + gifWithCommentExtensions(extensionCount = 1_025, subBlocksPerExtension = 0) + ) + + assertThrows(SodaException::class.java) { + validator.validate(image = file, allowGif = true) + } + + assertEquals(false, provider.decodeInputRequested) + } + } + + @Test + @DisplayName("GIF extension의 sub-block 개수가 상한을 넘으면 ImageIO 전에 거부한다") + fun shouldRejectGifWithTooManyExtensionSubBlocksBeforeImageIo() { + val provider = TrackingGifImageReaderSpi(frameCount = 1) + withImageReaderProvider(provider) { + val file = MockMultipartFile( + "image", + "image.gif", + MediaType.IMAGE_GIF_VALUE, + gifWithCommentExtensions(extensionCount = 1, subBlocksPerExtension = 65) + ) + + assertThrows(SodaException::class.java) { + validator.validate(image = file, allowGif = true) + } + + assertEquals(false, provider.decodeInputRequested) + } + } + + @Test + @DisplayName("GIF frame 누적 픽셀이 40000000px를 넘으면 ImageIO 전에 거부한다") + fun shouldRejectGifWhoseCumulativeFramePixelsExceedMaximumBeforeImageIo() { + val provider = TrackingGifImageReaderSpi(frameCount = 2) + withImageReaderProvider(provider) { + val file = MockMultipartFile( + "image", + "image.gif", + MediaType.IMAGE_GIF_VALUE, + gifWithRepeatedPixelFrames(5_000 to 5_000, 5_000 to 5_000) + ) + + assertThrows(SodaException::class.java) { + validator.validate(image = file, allowGif = true) + } + + assertEquals(false, provider.decodeInputRequested) + } + } + + @Test + @DisplayName("GIF frame 누적 픽셀이 정확히 40000000px이면 통과한다") + fun shouldAcceptGifWhoseCumulativeFramePixelsEqualMaximum() { + val provider = TrackingGifImageReaderSpi(frameCount = 2) + withImageReaderProvider(provider) { + val file = MockMultipartFile( + "image", + "image.gif", + MediaType.IMAGE_GIF_VALUE, + gifWithRepeatedPixelFrames(5_000 to 4_000, 5_000 to 4_000) + ) + + val validated = validator.validate(image = file, allowGif = true) + + assertEquals("gif", validated.extension) + } + } + + @Test + @DisplayName("GIF LZW 출력이 선언 pixel 수를 넘으면 ImageIO 전에 거부한다") + fun shouldRejectGifWhoseLzwOutputExceedsDeclaredPixelsBeforeImageIo() { + val provider = TrackingGifImageReaderSpi(frameCount = 1) + withImageReaderProvider(provider) { + val file = MockMultipartFile( + "image", + "image.gif", + MediaType.IMAGE_GIF_VALUE, + gifWithFrame(width = 1, height = 1, lzwData = packGifLzwCodes(listOf(4, 0, 0, 5))) + ) + + assertThrows(SodaException::class.java) { + validator.validate(image = file, allowGif = true) + } + + assertEquals(false, provider.decodeInputRequested) + } + } + + @Test + @DisplayName("GIF LZW 출력이 선언 pixel 수보다 부족하면 ImageIO 전에 거부한다") + fun shouldRejectGifWhoseLzwOutputIsShorterThanDeclaredPixelsBeforeImageIo() { + val provider = TrackingGifImageReaderSpi(frameCount = 1) + withImageReaderProvider(provider) { + val file = MockMultipartFile( + "image", + "image.gif", + MediaType.IMAGE_GIF_VALUE, + gifWithFrame(2, 1, packGifLzwCodes(listOf(4, 0, 5))) + ) + assertThrows(SodaException::class.java) { validator.validate(file, allowGif = true) } + assertEquals(false, provider.decodeInputRequested) + } + } + + @Test + @DisplayName("GIF LZW의 연속 clear와 EOI 뒤 data는 거부한다") + fun shouldRejectGifLzwStateMismatchesBeforeImageIo() { + val consecutiveClearProvider = TrackingGifImageReaderSpi(frameCount = 1) + withImageReaderProvider(consecutiveClearProvider) { + val file = MockMultipartFile( + "image", + "image.gif", + MediaType.IMAGE_GIF_VALUE, + gifWithFrame(1, 1, packGifLzwCodes(listOf(4, 4, 0, 5))) + ) + assertThrows(SodaException::class.java) { validator.validate(file, allowGif = true) } + assertEquals(false, consecutiveClearProvider.decodeInputRequested) + } + + val trailingDataProvider = TrackingGifImageReaderSpi(frameCount = 1) + withImageReaderProvider(trailingDataProvider) { + val file = MockMultipartFile( + "image", + "image.gif", + MediaType.IMAGE_GIF_VALUE, + gifWithFrame(1, 1, packGifLzwCodes(listOf(4, 0, 5)) + byteArrayOf(1)) + ) + assertThrows(SodaException::class.java) { validator.validate(file, allowGif = true) } + assertEquals(false, trailingDataProvider.decodeInputRequested) + } + } + + @Test + @DisplayName("GIF 모든 frame은 1x1 source region으로 디코딩한다") + fun shouldDecodeEveryGifFrameWithOnePixelRegion() { + val provider = TrackingGifImageReaderSpi(frameCount = 2) + withImageReaderProvider(provider) { + val file = MockMultipartFile( + "image", + "image.gif", + MediaType.IMAGE_GIF_VALUE, + multipartGif(1, 1, 1 to 1, 1 to 1).bytes + ) + + val validated = validator.validate(image = file, allowGif = true) + + assertEquals("gif", validated.extension) + assertEquals(listOf(Rectangle(0, 0, 1, 1), Rectangle(0, 0, 1, 1)), provider.sourceRegions) + } + } + + @Test + @DisplayName("단일 변이 20000px를 초과하는 이미지는 작은 raster로도 거부한다") + fun shouldRejectImageExceedingMaximumDimension() { + val file = multipartPng(width = 20_001, height = 1) + + assertThrows(SodaException::class.java) { + validator.validate(image = file, allowGif = true) + } + } + + @Test + @DisplayName("총 픽셀이 40000000px를 초과하는 이미지는 작은 raster로도 거부한다") + fun shouldRejectImageExceedingMaximumPixels() { + val file = multipartPng(width = 5_000, height = 8_001) + + assertThrows(SodaException::class.java) { + validator.validate(image = file, allowGif = true) + } + } + + @Test + @DisplayName("첫 frame이 작아도 GIF logical canvas가 20000px를 초과하면 거부한다") + fun shouldRejectGifWhoseLogicalCanvasExceedsMaximumDimension() { + val file = multipartGif( + logicalWidth = 20_001, + logicalHeight = 1, + 1 to 1 + ) + + assertThrows(SodaException::class.java) { + validator.validate(image = file, allowGif = true) + } + } + + @Test + @DisplayName("첫 frame이 작아도 GIF 후속 frame이 40000000px를 초과하면 거부한다") + fun shouldRejectGifWhoseLaterFrameExceedsMaximumPixels() { + val file = multipartGif( + logicalWidth = 1, + logicalHeight = 1, + 1 to 1, + 5_000 to 8_001 + ) + + assertThrows(SodaException::class.java) { + validator.validate(image = file, allowGif = true) + } + } + + @Test + @DisplayName("첫 frame이 정상이더라도 손상된 GIF 후속 frame은 400 invalid image로 거부한다") + fun shouldRejectGifWhoseLaterFrameIsMalformed() { + val file = multipartGifWithMalformedSecondFrame() + + val exception = assertThrows(SodaException::class.java) { + validator.validate(image = file, allowGif = true) + } + + assertEquals(HttpStatus.BAD_REQUEST, exception.httpStatus) + } + + @Test + @DisplayName("이미지 검증 실패 errorProperty는 multipart field name을 반환한다") + fun shouldReturnMultipartFieldNameForInvalidImage() { + val file = MockMultipartFile("thumbnailImage", "image.png", MediaType.IMAGE_PNG_VALUE, "not-image".toByteArray()) + + val exception = assertThrows(SodaException::class.java) { + validator.validate(image = file, allowGif = true) + } + + assertEquals("thumbnailImage", exception.errorProperty) + } + + @Test + @DisplayName("이미지 전용 상한 초과 파일은 bytes를 읽기 전에 거부한다") + fun shouldRejectOversizedImageBeforeReadingBytes() { + val file = OversizedMultipartFile(name = "image") + + val exception = assertThrows(SodaException::class.java) { + validator.validate(image = file, allowGif = true) + } + + assertEquals("image", exception.errorProperty) + } + + private fun multipartImage(name: String, extension: String, contentType: String): MockMultipartFile { + val output = ByteArrayOutputStream() + ImageIO.write(BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB), extension, output) + return MockMultipartFile(name, "image.$extension", contentType, output.toByteArray()) + } + + private fun multipartPng(width: Int, height: Int): MockMultipartFile { + val output = ByteArrayOutputStream() + ImageIO.write(BufferedImage(width, height, BufferedImage.TYPE_BYTE_BINARY), "png", output) + return MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, output.toByteArray()) + } + + private fun multipartGif(logicalWidth: Int, logicalHeight: Int, vararg frames: Pair): MockMultipartFile { + val output = ByteArrayOutputStream() + output.write("GIF89a".toByteArray(Charsets.US_ASCII)) + output.writeLittleEndianShort(logicalWidth) + output.writeLittleEndianShort(logicalHeight) + output.write(byteArrayOf(0x80.toByte(), 0, 0)) + output.write(byteArrayOf(0, 0, 0, 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte())) + + frames.forEach { (width, height) -> + output.write(0x2C) + output.writeLittleEndianShort(0) + output.writeLittleEndianShort(0) + output.writeLittleEndianShort(width) + output.writeLittleEndianShort(height) + output.write(0) + output.write(2) + output.write(2) + output.write(0x44) + output.write(0x01) + output.write(0) + } + + output.write(0x3B) + return MockMultipartFile("image", "image.gif", MediaType.IMAGE_GIF_VALUE, output.toByteArray()) + } + + private fun multipartGifWithMalformedSecondFrame(): MockMultipartFile { + val firstFrameGif = multipartGif(logicalWidth = 1, logicalHeight = 1, 1 to 1).bytes + val output = ByteArrayOutputStream() + output.write(firstFrameGif, 0, firstFrameGif.size - 1) + output.write(0x2C) + repeat(2) { output.writeLittleEndianShort(0) } + repeat(2) { output.writeLittleEndianShort(1) } + output.write(0) + output.write(9) + output.write(1) + output.write(0) + output.write(0) + output.write(0x3B) + return MockMultipartFile("image", "image.gif", MediaType.IMAGE_GIF_VALUE, output.toByteArray()) + } + + private fun pngWithOversizedTextChunk(): ByteArray { + return pngWithTextChunk(payloadSize = 1024 * 1024 + 1) + } + + private fun pngWithTextChunk(payloadSize: Int): ByteArray { + require(payloadSize >= 8) + val basePng = multipartImage(name = "image", extension = "png", contentType = MediaType.IMAGE_PNG_VALUE).bytes + val iendOffset = basePng.indexOfPngChunk("IEND") + val textData = "Comment\u0000".toByteArray(Charsets.ISO_8859_1) + ByteArray(payloadSize - 8) { 'a'.code.toByte() } + val textChunk = pngChunk(type = "tEXt", data = textData) + + val output = ByteArrayOutputStream() + output.write(basePng, 0, iendOffset) + output.write(textChunk) + output.write(basePng, iendOffset, basePng.size - iendOffset) + return output.toByteArray() + } + + private fun pngWithCompressedTextChunk(uncompressedSize: Int): ByteArray { + val compressedText = ByteArrayOutputStream().also { compressed -> + DeflaterOutputStream(compressed).use { deflater -> + deflater.write(ByteArray(uncompressedSize) { 'a'.code.toByte() }) + } + }.toByteArray() + val data = "Comment\u0000".toByteArray(Charsets.ISO_8859_1) + byteArrayOf(0) + compressedText + return pngWithAncillaryChunk(type = "zTXt", data = data) + } + + private fun pngWithTotalChunkCount(totalChunkCount: Int): ByteArray { + val basePng = multipartImage("image", "png", MediaType.IMAGE_PNG_VALUE).bytes + val iendOffset = basePng.indexOfPngChunk("IEND") + val currentChunkCount = basePng.countPngChunks() + val output = ByteArrayOutputStream() + output.write(basePng, 0, iendOffset) + repeat(totalChunkCount - currentChunkCount) { + output.write(pngChunk("tEXt", byteArrayOf('K'.code.toByte(), 0))) + } + output.write(basePng, iendOffset, basePng.size - iendOffset) + return output.toByteArray() + } + + private fun ByteArray.countPngChunks(): Int { + var offset = PNG_SIGNATURE_BYTES.size + var count = 0 + while (offset + 12 <= size) { + val length = ((this[offset].toInt() and 0xFF) shl 24) or + ((this[offset + 1].toInt() and 0xFF) shl 16) or + ((this[offset + 2].toInt() and 0xFF) shl 8) or + (this[offset + 3].toInt() and 0xFF) + count++ + offset += 12 + length + if (copyOfRange(offset - length - 8, offset - length - 4).decodeToString() == "IEND") break + } + return count + } + + private fun pngWithAncillaryChunk(type: String, data: ByteArray): ByteArray { + val basePng = multipartImage(name = "image", extension = "png", contentType = MediaType.IMAGE_PNG_VALUE).bytes + val iendOffset = basePng.indexOfPngChunk("IEND") + val output = ByteArrayOutputStream() + output.write(basePng, 0, iendOffset) + output.write(pngChunk(type = type, data = data)) + output.write(basePng, iendOffset, basePng.size - iendOffset) + return output.toByteArray() + } + + private fun ByteArray.indexOfPngChunk(type: String): Int { + val typeBytes = type.toByteArray(Charsets.US_ASCII) + for (index in PNG_SIGNATURE_BYTES.size until size - 4) { + if (copyOfRange(index, index + 4).contentEquals(typeBytes)) return index - 4 + } + error("PNG chunk $type not found") + } + + private fun pngChunk(type: String, data: ByteArray): ByteArray { + val output = ByteArrayOutputStream() + val typeBytes = type.toByteArray(Charsets.US_ASCII) + output.writeInt(data.size) + output.write(typeBytes) + output.write(data) + val crc = CRC32() + crc.update(typeBytes) + crc.update(data) + output.writeInt(crc.value.toInt()) + return output.toByteArray() + } + + private fun gifWithOversizedCommentExtension(): ByteArray { + return gifWithCommentPayload(1024 * 1024 + 1) + } + + private fun gifWithCommentPayload(payloadSize: Int): ByteArray { + val output = ByteArrayOutputStream() + output.writeGifHeader(width = 1, height = 1) + var remaining = payloadSize + while (remaining > 0) { + output.write(0x21) + output.write(0xFE) + var blocks = 0 + while (remaining > 0 && blocks < 64) { + val blockSize = minOf(255, remaining) + output.write(blockSize) + repeat(blockSize) { output.write('a'.code) } + remaining -= blockSize + blocks++ + } + output.write(0) + } + output.writeGifFrame(width = 1, height = 1, lzwData = packGifLzwCodes(listOf(4, 0, 5))) + output.write(0x3B) + return output.toByteArray() + } + + private fun gifWithCommentExtensions(extensionCount: Int, subBlocksPerExtension: Int): ByteArray { + val output = ByteArrayOutputStream() + output.writeGifHeader(width = 1, height = 1) + repeat(extensionCount) { + output.write(0x21) + output.write(0xFE) + repeat(subBlocksPerExtension) { + output.write(1) + output.write('a'.code) + } + output.write(0) + } + output.writeGifFrame(width = 1, height = 1, lzwData = packGifLzwCodes(listOf(4, 0, 5))) + output.write(0x3B) + return output.toByteArray() + } + + private fun gifWithRepeatedPixelFrames(vararg frames: Pair): ByteArray { + val output = ByteArrayOutputStream() + val logicalWidth = frames.maxOf { it.first } + val logicalHeight = frames.maxOf { it.second } + output.writeGifHeader(width = logicalWidth, height = logicalHeight) + frames.forEach { (width, height) -> + output.writeGifFrame( + width = width, + height = height, + lzwData = repeatedZeroGifLzwData(width.toLong() * height) + ) + } + output.write(0x3B) + return output.toByteArray() + } + + private fun gifWithFrame(width: Int, height: Int, lzwData: ByteArray): ByteArray { + val output = ByteArrayOutputStream() + output.writeGifHeader(width = width, height = height) + output.writeGifFrame(width = width, height = height, lzwData = lzwData) + output.write(0x3B) + return output.toByteArray() + } + + private fun ByteArrayOutputStream.writeGifHeader(width: Int, height: Int) { + write("GIF89a".toByteArray(Charsets.US_ASCII)) + writeLittleEndianShort(width) + writeLittleEndianShort(height) + write(byteArrayOf(0x80.toByte(), 0, 0)) + write(byteArrayOf(0, 0, 0, 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte())) + } + + private fun ByteArrayOutputStream.writeGifFrame(width: Int, height: Int, lzwData: ByteArray) { + write(0x2C) + repeat(2) { writeLittleEndianShort(0) } + writeLittleEndianShort(width) + writeLittleEndianShort(height) + write(0) + write(2) + lzwData.asList().chunked(255).forEach { block -> + write(block.size) + block.forEach { write(it.toInt() and 0xFF) } + } + write(0) + } + + private fun repeatedZeroGifLzwData(pixelCount: Long): ByteArray { + require(pixelCount > 0) + val codes = mutableListOf() + var remaining = pixelCount + while (remaining > 0) { + codes += 4 + var runLength = 1L + while (runLength <= 4_091L && remaining >= runLength) { + codes += if (runLength == 1L) 0 else (runLength + 4L).toInt() + remaining -= runLength + runLength++ + } + if (remaining in 1 until runLength) { + codes += if (remaining == 1L) 0 else (remaining + 4L).toInt() + remaining = 0 + } + } + codes += 5 + return packGifLzwCodes(codes) + } + + private fun packGifLzwCodes(codes: List): ByteArray { + val output = ByteArrayOutputStream() + var bitBuffer = 0L + var bufferedBits = 0 + var codeSize = 3 + var nextCode = 6 + var previousCode = -1 + + codes.forEach { code -> + bitBuffer = bitBuffer or (code.toLong() shl bufferedBits) + bufferedBits += codeSize + while (bufferedBits >= 8) { + output.write((bitBuffer and 0xFF).toInt()) + bitBuffer = bitBuffer ushr 8 + bufferedBits -= 8 + } + + when (code) { + 4 -> { + codeSize = 3 + nextCode = 6 + previousCode = -1 + } + 5 -> Unit + else -> { + if (previousCode >= 0 && nextCode < 4_096) { + nextCode++ + if (nextCode == 1 shl codeSize && codeSize < 12) codeSize++ + } + previousCode = code + } + } + } + if (bufferedBits > 0) output.write(bitBuffer.toInt() and 0xFF) + return output.toByteArray() + } + + private fun ByteArrayOutputStream.writeLittleEndianShort(value: Int) { + require(value in 0..0xFFFF) + write(value and 0xFF) + write(value ushr 8 and 0xFF) + } + + private fun ByteArrayOutputStream.writeInt(value: Int) { + write(value ushr 24 and 0xFF) + write(value ushr 16 and 0xFF) + write(value ushr 8 and 0xFF) + write(value and 0xFF) + } + + private fun withImageReaderProvider(provider: ImageReaderSpi, block: () -> Unit) { + val registry = IIORegistry.getDefaultInstance() + registry.registerServiceProvider(provider) + val orderedAfter = registry.getServiceProviders(ImageReaderSpi::class.java, true).asSequence() + .filter { it !== provider } + .toList() + orderedAfter.forEach { registry.setOrdering(ImageReaderSpi::class.java, provider, it) } + + try { + block() + } finally { + orderedAfter.forEach { registry.unsetOrdering(ImageReaderSpi::class.java, provider, it) } + registry.deregisterServiceProvider(provider) + } + } + + private class OnePixelRegionImageReaderSpi( + private val dimensionFailure: IllegalArgumentException? = null, + private val readFailure: RuntimeException? = null + ) : ImageReaderSpi() { + var widthRequested: Boolean = false + private set + var readRequested: Boolean = false + private set + var decodeInputRequested: Boolean = false + private set + var ignoreMetadataRequested: Boolean? = null + private set + + override fun canDecodeInput(source: Any): Boolean { + if (source !is ImageInputStream) return false + decodeInputRequested = true + val position = source.streamPosition + return try { + source.readInt() == TRACKING_IMAGE_MAGIC + } finally { + source.seek(position) + } + } + + override fun createReaderInstance(extension: Any?): ImageReader { + return OnePixelRegionImageReader( + provider = this, + readFailure = readFailure, + onGetWidth = { + widthRequested = true + dimensionFailure?.let { throw it } + }, + onRead = { readRequested = true }, + onSetInput = { ignoreMetadataRequested = it } + ) + } + + override fun getInputTypes(): Array> = arrayOf(ImageInputStream::class.java) + + override fun getFormatNames(): Array = arrayOf("png") + + override fun getDescription(locale: Locale?): String = "One-pixel region test image reader" + } + + private class OnePixelRegionImageReader( + provider: ImageReaderSpi, + private val readFailure: RuntimeException? = null, + private val onGetWidth: () -> Unit = {}, + private val onRead: () -> Unit = {}, + private val onSetInput: (Boolean) -> Unit = {} + ) : ImageReader(provider) { + override fun setInput(input: Any?, seekForwardOnly: Boolean, ignoreMetadata: Boolean) { + super.setInput(input, seekForwardOnly, ignoreMetadata) + onSetInput(ignoreMetadata) + } + + override fun getNumImages(allowSearch: Boolean): Int = 1 + + override fun getWidth(imageIndex: Int): Int { + onGetWidth() + return 2 + } + + override fun getHeight(imageIndex: Int): Int = 2 + + override fun getImageTypes(imageIndex: Int): MutableIterator { + return mutableListOf(ImageTypeSpecifier.createFromBufferedImageType(BufferedImage.TYPE_INT_RGB)).iterator() + } + + override fun getStreamMetadata(): IIOMetadata? = null + + override fun getImageMetadata(imageIndex: Int): IIOMetadata? = null + + override fun read(imageIndex: Int, param: ImageReadParam?): BufferedImage { + onRead() + readFailure?.let { throw it } + if (param?.sourceRegion != Rectangle(0, 0, 1, 1)) { + throw IIOException("full image read requested") + } + return BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB) + } + } + + private class TrackingGifImageReaderSpi( + private val frameCount: Int + ) : ImageReaderSpi() { + var decodeInputRequested: Boolean = false + private set + val sourceRegions = mutableListOf() + + override fun canDecodeInput(source: Any): Boolean { + if (source !is ImageInputStream) return false + decodeInputRequested = true + val position = source.streamPosition + return try { + source.readByte() == 'G'.code.toByte() && + source.readByte() == 'I'.code.toByte() && + source.readByte() == 'F'.code.toByte() + } finally { + source.seek(position) + } + } + + override fun createReaderInstance(extension: Any?): ImageReader { + return TrackingGifImageReader(this, frameCount, sourceRegions) + } + + override fun getInputTypes(): Array> = arrayOf(ImageInputStream::class.java) + + override fun getFormatNames(): Array = arrayOf("gif") + + override fun getDescription(locale: Locale?): String = "Tracking GIF image reader" + } + + private class TrackingGifImageReader( + provider: ImageReaderSpi, + private val frameCount: Int, + private val sourceRegions: MutableList + ) : ImageReader(provider) { + override fun getNumImages(allowSearch: Boolean): Int = frameCount + + override fun getWidth(imageIndex: Int): Int = 2 + + override fun getHeight(imageIndex: Int): Int = 2 + + override fun getImageTypes(imageIndex: Int): MutableIterator { + return mutableListOf(ImageTypeSpecifier.createFromBufferedImageType(BufferedImage.TYPE_INT_RGB)).iterator() + } + + override fun getStreamMetadata(): IIOMetadata? = null + + override fun getImageMetadata(imageIndex: Int): IIOMetadata? = null + + override fun read(imageIndex: Int, param: ImageReadParam?): BufferedImage { + sourceRegions.add(param?.sourceRegion) + return BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB) + } + } + + private class OversizedMultipartFile( + private val name: String + ) : MultipartFile { + override fun getName(): String = name + override fun getOriginalFilename(): String = "oversized.png" + override fun getContentType(): String = MediaType.IMAGE_PNG_VALUE + override fun isEmpty(): Boolean = false + override fun getSize(): Long = 10L * 1024L * 1024L + 1L + override fun getBytes(): ByteArray = error("oversized image bytes must not be loaded") + override fun getInputStream(): InputStream = error("oversized image stream must not be opened") + override fun transferTo(dest: java.io.File) = error("oversized image must not be transferred") + } + + companion object { + private const val TRACKING_IMAGE_MAGIC = 0x89504E47.toInt() + private val PNG_SIGNATURE_BYTES = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) + private val TRACKING_IMAGE_BYTES = PNG_SIGNATURE_BYTES + byteArrayOf( + 0, 0, 0, 0, + 'I'.code.toByte(), 'E'.code.toByte(), 'N'.code.toByte(), 'D'.code.toByte(), + 0, 0, 0, 0 + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminJsonRequestParserTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminJsonRequestParserTest.kt new file mode 100644 index 00000000..3ba358e8 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminJsonRequestParserTest.kt @@ -0,0 +1,93 @@ +package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.common.SodaException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +class AdminJsonRequestParserTest { + private val parser = AdminJsonRequestParser(ObjectMapper()) + + @Test + @DisplayName("multipart request JSON은 명시적 null key를 누락으로 보지 않는다") + fun shouldKeepExplicitNullRequiredKey() { + val node = parser.parseRequiredObject( + rawJson = """{"name":null,"tags":[]}""", + requiredKeys = setOf("name", "tags") + ) + + assertTrue(node.has("name")) + assertTrue(node.get("name").isNull) + } + + @Test + @DisplayName("multipart request JSON은 필수 key 누락을 errorProperty로 반환한다") + fun shouldRejectMissingRequiredKey() { + val exception = assertThrows(SodaException::class.java) { + parser.parseRequiredObject(rawJson = """{"name":"Soda"}""", requiredKeys = setOf("name", "tags")) + } + + assertEquals("tags", exception.errorProperty) + } + + @Test + @DisplayName("multipart request JSON 뒤에 다른 root가 이어지면 거부한다") + fun shouldRejectTrailingJsonRoot() { + val exception = assertThrows(SodaException::class.java) { + parser.parseRequiredObject( + rawJson = """{"name":"Soda"}{"name":"Pop"}""", + requiredKeys = setOf("name") + ) + } + + assertEquals("request", exception.errorProperty) + } + + @Test + @DisplayName("multipart request JSON 뒤에 garbage가 이어지면 거부한다") + fun shouldRejectTrailingGarbage() { + val exception = assertThrows(SodaException::class.java) { + parser.parseRequiredObject( + rawJson = """{"name":"Soda"} trailing""", + requiredKeys = setOf("name") + ) + } + + assertEquals("request", exception.errorProperty) + } + + @Test + @DisplayName("빈 multipart request JSON은 잘못된 요청으로 거부한다") + fun shouldRejectBlankJson() { + val exception = assertThrows(SodaException::class.java) { + parser.parseRequiredObject(rawJson = " ", requiredKeys = setOf("name")) + } + + assertEquals("request", exception.errorProperty) + } + + @Test + @DisplayName("일반 JsonNode body도 같은 필수 key 계약을 사용한다") + fun shouldParseJsonNodeWithSameRequiredKeyPolicy() { + val node = ObjectMapper().readTree("""{"comment":null}""") + + val parsed = parser.parseRequiredObject(node = node, requiredKeys = setOf("comment")) + + assertTrue(parsed.get("comment").isNull) + } + + @Test + @DisplayName("일반 JsonNode body도 필수 key가 없으면 errorProperty로 반환한다") + fun shouldRejectJsonNodeMissingRequiredKey() { + val node = ObjectMapper().readTree("""{"comment":"hello"}""") + + val exception = assertThrows(SodaException::class.java) { + parser.parseRequiredObject(node = node, requiredKeys = setOf("comment", "author")) + } + + assertEquals("author", exception.errorProperty) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandlerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandlerTest.kt new file mode 100644 index 00000000..0247a148 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandlerTest.kt @@ -0,0 +1,126 @@ +package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.JsonMappingException +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.v2.admin.aicharacter.dto.AdminCommentUpdateRequest +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.http.HttpStatus +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.mock.http.MockHttpInputMessage +import org.springframework.web.bind.MissingServletRequestParameterException +import org.springframework.web.multipart.support.MissingServletRequestPartException + +class AiCharacterAdminExceptionHandlerTest { + private val handler = AiCharacterAdminExceptionHandler( + langContext = LangContext(), + messageSource = SodaMessageSource() + ) + + @Test + @DisplayName("SodaException은 지정 HTTP status와 errorProperty를 ApiResponse로 반환한다") + fun shouldReturnConfiguredStatusAndErrorProperty() { + listOf( + HttpStatus.BAD_REQUEST, + HttpStatus.NOT_FOUND, + HttpStatus.CONFLICT, + HttpStatus.INTERNAL_SERVER_ERROR, + HttpStatus.BAD_GATEWAY + ).forEach { status -> + val response = handler.handleSodaException( + SodaException( + messageKey = "common.error.invalid_request", + errorProperty = "characterId", + httpStatus = status + ) + ) + + assertAll( + { assertEquals(status, response.statusCode) }, + { assertEquals(false, response.body?.success) }, + { assertEquals("characterId", response.body?.errorProperty) } + ) + } + } + + @Test + @DisplayName("HTTP status가 없는 SodaException은 관리자 경계에서 400으로 응답한다") + fun shouldDefaultSodaExceptionToBadRequest() { + val response = handler.handleSodaException(SodaException(messageKey = "common.error.invalid_request")) + + assertAll( + { assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) }, + { assertEquals(false, response.body?.success) } + ) + } + + @Test + @DisplayName("알 수 없는 예외는 500 ApiResponse로 응답한다") + fun shouldReturnInternalServerErrorForUnknownException() { + val response = handler.handleException(IllegalStateException("sensitive-body")) + + assertAll( + { assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.statusCode) }, + { assertEquals(false, response.body?.success) } + ) + } + + @Test + @DisplayName("잘못된 request parameter는 400 errorProperty를 반환한다") + fun shouldReturnErrorPropertyForMissingRequestParameter() { + val response = handler.handleBadRequestException(MissingServletRequestParameterException("page", "Int")) + + assertAll( + { assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) }, + { assertEquals(false, response.body?.success) }, + { assertEquals("page", response.body?.errorProperty) } + ) + } + + @Test + @DisplayName("누락된 multipart part는 400 errorProperty를 반환한다") + fun shouldReturnErrorPropertyForMissingRequestPart() { + val response = handler.handleBadRequestException(MissingServletRequestPartException("image")) + + assertAll( + { assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) }, + { assertEquals(false, response.body?.success) }, + { assertEquals("image", response.body?.errorProperty) } + ) + } + + @Test + @DisplayName("읽을 수 없는 JSON body는 Jackson path의 field를 errorProperty로 반환한다") + fun shouldReturnJsonBodyFieldNameForUnreadableBody() { + val cause = JsonMappingException.from(null as JsonParser?, "missing content") + .also { it.prependPath(AdminCommentUpdateRequest::class.java, "content") } + val exception = HttpMessageNotReadableException("bad request", cause, MockHttpInputMessage(ByteArray(0))) + + val response = handler.handleBadRequestException(exception) + + assertAll( + { assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) }, + { assertEquals(false, response.body?.success) }, + { assertEquals("content", response.body?.errorProperty) } + ) + } + + @Test + @DisplayName("읽을 수 없는 JSON body에 Jackson field path가 없으면 request를 errorProperty로 반환한다") + fun shouldFallbackToRequestForUnreadableBodyWithoutJsonPath() { + val exception = HttpMessageNotReadableException("bad request", MockHttpInputMessage(ByteArray(0))) + + val response = handler.handleBadRequestException(exception) + + assertAll( + { assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) }, + { assertEquals(false, response.body?.success) }, + { assertEquals("request", response.body?.errorProperty) } + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminLoginJwtIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminLoginJwtIntegrationTest.kt new file mode 100644 index 00000000..5827a765 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminLoginJwtIntegrationTest.kt @@ -0,0 +1,165 @@ +package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web + +import kr.co.vividnext.sodalive.admin.member.AdminMemberLoginController +import kr.co.vividnext.sodalive.admin.member.AdminMemberLoginService +import kr.co.vividnext.sodalive.admin.member.AdminMemberRepository +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.configs.SecurityConfig +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler +import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint +import kr.co.vividnext.sodalive.jwt.TokenProvider +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.token.MemberToken +import kr.co.vividnext.sodalive.member.token.MemberTokenRepository +import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAccessDeniedHandler +import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAuthenticationEntryPoint +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.annotation.Import +import org.springframework.http.MediaType +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.test.context.TestPropertySource +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.Optional + +@WebMvcTest( + controllers = [ + AdminMemberLoginController::class, + AiCharacterAdminLoginJwtIntegrationTest.TestAiCharacterAdminController::class + ] +) +@Import( + SecurityConfig::class, + JwtAuthenticationEntryPoint::class, + JwtAccessDeniedHandler::class, + AiCharacterAdminAuthenticationEntryPoint::class, + AiCharacterAdminAccessDeniedHandler::class, + AiCharacterAdminExceptionHandler::class, + AiCharacterAdminLoginJwtIntegrationTest.TestAiCharacterAdminController::class, + AdminMemberLoginService::class, + TokenProvider::class, + LangContext::class, + SodaMessageSource::class +) +@TestPropertySource( + properties = [ + "jwt.secret=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "jwt.token-validity-in-seconds=3600" + ] +) +class AiCharacterAdminLoginJwtIntegrationTest @Autowired constructor( + private val mockMvc: MockMvc +) { + @MockBean + private lateinit var adminMemberRepository: AdminMemberRepository + + @MockBean + private lateinit var memberRepository: MemberRepository + + @MockBean + private lateinit var memberTokenRepository: MemberTokenRepository + + @MockBean + private lateinit var passwordEncoder: PasswordEncoder + + @MockBean + private lateinit var countryContext: CountryContext + + @Test + @DisplayName("관리자 로그인으로 받은 실제 JWT는 신규 관리자 API 인증에 사용된다") + fun shouldCallAiCharacterAdminApiWithTokenFromAdminLogin() { + val member = Member(email = "admin@test.com", password = "encoded-password", nickname = "admin", role = MemberRole.ADMIN) + .apply { id = 1L } + var savedToken: MemberToken? = null + Mockito.`when`(adminMemberRepository.findByEmail("admin@test.com")).thenReturn(member) + Mockito.`when`(memberRepository.findById(Mockito.eq(1L))).thenReturn(Optional.of(member)) + Mockito.`when`(memberTokenRepository.findById(Mockito.eq(1L))).thenAnswer { Optional.ofNullable(savedToken) } + Mockito.`when`(memberTokenRepository.save(Mockito.any(MemberToken::class.java))).thenAnswer { invocation -> + (invocation.arguments[0] as MemberToken).also { savedToken = it } + } + Mockito.`when`(passwordEncoder.matches("password", "encoded-password")).thenReturn(true) + + val loginResult = mockMvc.perform( + post("/admin/member/login") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"email":"admin@test.com","password":"password"}""") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.role").value("ADMIN")) + .andReturn() + + val token = Regex(""""token":"([^"]+)"""") + .find(loginResult.response.contentAsString) + ?.groupValues + ?.get(1) + + mockMvc.perform(get("/admin/ai-characters/login-jwt-test").header("Authorization", "Bearer $token")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").value("ok")) + } + + @Test + @DisplayName("CONTENT_MANAGER 로그인으로 받은 실제 JWT는 신규 관리자 API에서 403으로 거부된다") + fun shouldRejectContentManagerTokenFromAdminLoginForAiCharacterAdminApi() { + val member = Member( + email = "content@test.com", + password = "encoded-password", + nickname = "content-manager", + role = MemberRole.CONTENT_MANAGER + ).apply { id = 2L } + var savedToken: MemberToken? = null + Mockito.`when`(adminMemberRepository.findByEmail("content@test.com")).thenReturn(member) + Mockito.`when`(memberRepository.findById(Mockito.eq(2L))).thenReturn(Optional.of(member)) + Mockito.`when`(memberTokenRepository.findById(Mockito.eq(2L))).thenAnswer { Optional.ofNullable(savedToken) } + Mockito.`when`(memberTokenRepository.save(Mockito.any(MemberToken::class.java))).thenAnswer { invocation -> + (invocation.arguments[0] as MemberToken).also { savedToken = it } + } + Mockito.`when`(passwordEncoder.matches("password", "encoded-password")).thenReturn(true) + + val loginResult = mockMvc.perform( + post("/admin/member/login") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"email":"content@test.com","password":"password"}""") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.role").value("CONTENT_MANAGER")) + .andReturn() + + val token = Regex(""""token":"([^"]+)"""") + .find(loginResult.response.contentAsString) + ?.groupValues + ?.get(1) + + mockMvc.perform(get("/admin/ai-characters/login-jwt-test").header("Authorization", "Bearer $token")) + .andExpect(status().isForbidden) + .andExpect(jsonPath("$.success").value(false)) + } + + @RestController + @RequestMapping("/admin/ai-characters/login-jwt-test") + @PreAuthorize("hasRole('ADMIN')") + class TestAiCharacterAdminController { + @GetMapping + fun ok(): ApiResponse = ApiResponse.ok("ok") + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminSecurityIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminSecurityIntegrationTest.kt new file mode 100644 index 00000000..8eecab1b --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminSecurityIntegrationTest.kt @@ -0,0 +1,215 @@ +package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.configs.SecurityConfig +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler +import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint +import kr.co.vividnext.sodalive.jwt.TokenProvider +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAccessDeniedHandler +import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAuthenticationEntryPoint +import kr.co.vividnext.sodalive.v2.admin.aicharacter.dto.AdminCommentUpdateRequest +import org.hamcrest.Matchers.containsString +import org.hamcrest.Matchers.not +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.annotation.Import +import org.springframework.http.HttpStatus +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.stream.Stream + +@WebMvcTest( + controllers = [ + AiCharacterAdminSecurityIntegrationTest.TestAiCharacterAdminController::class, + AiCharacterAdminSecurityIntegrationTest.TestAdjacentAdminController::class + ] +) +@Import( + SecurityConfig::class, + JwtAuthenticationEntryPoint::class, + JwtAccessDeniedHandler::class, + AiCharacterAdminAuthenticationEntryPoint::class, + AiCharacterAdminAccessDeniedHandler::class, + AiCharacterAdminExceptionHandler::class, + AiCharacterAdminSecurityIntegrationTest.TestAiCharacterAdminController::class, + AiCharacterAdminSecurityIntegrationTest.TestAdjacentAdminController::class, + LangContext::class, + SodaMessageSource::class +) +class AiCharacterAdminSecurityIntegrationTest @Autowired constructor( + private val mockMvc: MockMvc +) { + @MockBean + private lateinit var tokenProvider: TokenProvider + + @MockBean + private lateinit var countryContext: CountryContext + + @Test + @DisplayName("/admin/ai-characters 전용 API는 ADMIN 권한이면 통과한다") + fun shouldAllowAdminRole() { + mockMvc.perform(get("/admin/ai-characters/test").with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").value("ok")) + } + + @Test + @DisplayName("기존 JWT 인증 결과가 ADMIN이면 신규 관리자 API를 호출할 수 있다") + fun shouldAllowAdminJwtAuthentication() { + val admin = Member(email = "admin@test.com", password = "password", nickname = "admin", role = MemberRole.ADMIN) + .apply { id = 1L } + val authentication = UsernamePasswordAuthenticationToken( + MemberAdapter(admin), + "admin-token", + MemberAdapter(admin).authorities + ) + Mockito.`when`(tokenProvider.validateToken("admin-token")).thenReturn(true) + Mockito.`when`(tokenProvider.getAuthentication("admin-token")).thenReturn(authentication) + + mockMvc.perform(get("/admin/ai-characters/test").header("Authorization", "Bearer admin-token")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + } + + @Test + @DisplayName("/admin/ai-characters 전용 API는 비로그인을 401 ApiResponse로 거부한다") + fun shouldRejectAnonymousWithJson401() { + mockMvc.perform(get("/admin/ai-characters/test").with(anonymous())) + .andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.success").value(false)) + } + + @Test + @DisplayName("/admin/ai-characters 전용 API는 ADMIN 외 권한을 403 ApiResponse로 거부한다") + fun shouldRejectNonAdminWithJson403() { + Stream.of("USER", "CREATOR", "AGENT", "CONTENT_MANAGER").forEach { role -> + mockMvc.perform(get("/admin/ai-characters/test").with(user(role.lowercase()).roles(role))) + .andExpect(status().isForbidden) + .andExpect(jsonPath("$.success").value(false)) + } + } + + @Test + @DisplayName("ADMIN 외 권한은 request body 역직렬화 전에 403으로 거부된다") + fun shouldRejectNonAdminBeforeRequestBodyBinding() { + mockMvc.perform( + post("/admin/ai-characters/test/body") + .with(user("user").roles("USER")) + .contentType("application/json") + .content("{") + ) + .andExpect(status().isForbidden) + .andExpect(jsonPath("$.success").value(false)) + } + + @Test + @DisplayName("잘못된 JWT는 401 ApiResponse로 거부한다") + fun shouldRejectInvalidJwtWithJson401() { + Mockito.`when`(tokenProvider.validateToken("invalid-token")).thenReturn(false) + + mockMvc.perform(get("/admin/ai-characters/test").header("Authorization", "Bearer invalid-token")) + .andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.success").value(false)) + } + + @Test + @DisplayName("/admin/ai-characters 인접 prefix는 신규 관리자 오류 handler 대상이 아니다") + fun shouldNotUseAdminHandlerForAdjacentPrefix() { + mockMvc.perform(get("/admin/ai-characters-shadow/test").with(anonymous())) + .andExpect(status().isUnauthorized) + .andExpect(content().string(not(containsString("success")))) + } + + @Test + @DisplayName("/admin/ai-characters 전용 예외는 지정 HTTP status와 errorProperty를 응답한다") + fun shouldUseAdminExceptionBoundary() { + mockMvc.perform(get("/admin/ai-characters/test/conflict").with(user("admin").roles("ADMIN"))) + .andExpect(status().isConflict) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.errorProperty").value("characterId")) + } + + @Test + @DisplayName("/admin/ai-characters 전용 예외는 400/404/500/502 status를 보존한다") + fun shouldUseAdminExceptionBoundaryForCommonStatuses() { + mapOf( + "bad-request" to HttpStatus.BAD_REQUEST, + "not-found" to HttpStatus.NOT_FOUND, + "server-error" to HttpStatus.INTERNAL_SERVER_ERROR, + "bad-gateway" to HttpStatus.BAD_GATEWAY + ).forEach { (path, status) -> + mockMvc.perform(get("/admin/ai-characters/test/$path").with(user("admin").roles("ADMIN"))) + .andExpect(status().`is`(status.value())) + .andExpect(jsonPath("$.success").value(false)) + } + } + + @RestController + @RequestMapping("/admin/ai-characters/test") + @PreAuthorize("hasRole('ADMIN')") + class TestAiCharacterAdminController { + @GetMapping + fun ok(): ApiResponse = ApiResponse.ok("ok") + + @PostMapping("/body") + fun body(@RequestBody request: AdminCommentUpdateRequest): ApiResponse = ApiResponse.ok(request.content) + + @GetMapping("/conflict") + fun conflict(): ApiResponse { + throw SodaException( + messageKey = "common.error.invalid_request", + errorProperty = "characterId", + httpStatus = HttpStatus.CONFLICT + ) + } + + @GetMapping("/bad-request") + fun badRequest(): ApiResponse = throwStatus(HttpStatus.BAD_REQUEST) + + @GetMapping("/not-found") + fun notFound(): ApiResponse = throwStatus(HttpStatus.NOT_FOUND) + + @GetMapping("/server-error") + fun serverError(): ApiResponse = throwStatus(HttpStatus.INTERNAL_SERVER_ERROR) + + @GetMapping("/bad-gateway") + fun badGateway(): ApiResponse = throwStatus(HttpStatus.BAD_GATEWAY) + + private fun throwStatus(status: HttpStatus): ApiResponse { + throw SodaException(messageKey = "common.error.invalid_request", httpStatus = status) + } + } + + @RestController + @RequestMapping("/admin/ai-characters-shadow/test") + @PreAuthorize("hasRole('ADMIN')") + class TestAdjacentAdminController { + @GetMapping + fun ok(): ApiResponse = ApiResponse.ok("shadow") + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicyTest.kt new file mode 100644 index 00000000..d618a8bf --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicyTest.kt @@ -0,0 +1,85 @@ +package kr.co.vividnext.sodalive.v2.admin.aicharacter.application + +import kr.co.vividnext.sodalive.v2.admin.aicharacter.dto.AdminCommentUpdateRequest +import kr.co.vividnext.sodalive.v2.admin.aicharacter.dto.AdminMutationResponse +import kr.co.vividnext.sodalive.v2.admin.aicharacter.dto.AdminPageResponse +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +class AdminPagePolicyTest { + @Test + @DisplayName("관리자 목록 page와 size 기본값을 정규화한다") + fun shouldNormalizeDefaultPageRequest() { + val request = AdminPagePolicy.normalize(page = null, size = null) + + assertEquals(0, request.page) + assertEquals(20, request.size) + assertEquals(0, request.offset) + assertEquals(20, request.limit) + } + + @Test + @DisplayName("관리자 목록 page와 size 경계값을 정규화한다") + fun shouldNormalizePageRequestBoundaries() { + val lowerBound = AdminPagePolicy.normalize(page = -1, size = 0) + val upperBound = AdminPagePolicy.normalize(page = 2, size = 51) + + assertEquals(0, lowerBound.page) + assertEquals(20, lowerBound.size) + assertEquals(2, upperBound.page) + assertEquals(50, upperBound.size) + assertEquals(100, upperBound.offset) + assertEquals(50, upperBound.limit) + } + + @Test + @DisplayName("관리자 목록 page 0과 size 1/20/50 유효 경계값을 유지한다") + fun shouldKeepValidPageRequestBoundaries() { + listOf(1, 20, 50).forEach { size -> + val request = AdminPagePolicy.normalize(page = 0, size = size) + + assertEquals(0, request.page) + assertEquals(size, request.size) + assertEquals(0, request.offset) + assertEquals(size.toLong(), request.limit) + } + } + + @Test + @DisplayName("관리자 page 응답은 hasNext를 계산한다") + fun shouldCalculateHasNextForPageResponse() { + val firstPage = AdminPageResponse.of(totalCount = 3, content = listOf(1, 2), page = 0, size = 2) + val lastPage = AdminPageResponse.of(totalCount = 3, content = listOf(3), page = 1, size = 2) + + assertTrue(firstPage.hasNext) + assertFalse(lastPage.hasNext) + assertEquals(listOf(1, 2), firstPage.items) + } + + @Test + @DisplayName("관리자 page 응답은 Int overflow 없이 hasNext를 계산한다") + fun shouldCalculateHasNextWithoutIntOverflow() { + val response = AdminPageResponse.of( + totalCount = Long.MAX_VALUE, + content = emptyList(), + page = Int.MAX_VALUE, + size = Int.MAX_VALUE + ) + + assertTrue(response.hasNext) + } + + @Test + @DisplayName("관리자 공통 mutation 응답과 댓글 수정 요청 DTO 계약을 유지한다") + fun shouldKeepAdminMutationDtoContract() { + val mutationResponse = AdminMutationResponse(id = 1L, isActive = true) + val commentUpdateRequest = AdminCommentUpdateRequest(content = "updated") + + assertEquals(1L, mutationResponse.id) + assertTrue(mutationResponse.isActive) + assertEquals("updated", commentUpdateRequest.content) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLoggerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLoggerTest.kt new file mode 100644 index 00000000..f7d44de9 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLoggerTest.kt @@ -0,0 +1,199 @@ +package kr.co.vividnext.sodalive.v2.admin.aicharacter.application + +import org.junit.jupiter.api.Assertions.assertDoesNotThrow +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.boot.test.system.CapturedOutput +import org.springframework.boot.test.system.OutputCaptureExtension + +@ExtendWith(OutputCaptureExtension::class) +class AiCharacterAdminAuditLoggerTest { + private val auditLogger = AiCharacterAdminAuditLogger() + + @Test + @DisplayName("global 원작 mutation audit은 character field null을 허용한다") + fun shouldAllowGlobalOriginalWorkAuditContext(output: CapturedOutput) { + auditLogger.logSuccess( + context = AiCharacterAdminAuditContext.globalOriginalWork( + adminMemberId = 1L, + action = AiCharacterAdminAuditAction.CREATE, + resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK, + resourceId = 10L + ) + ) + + assertTrue(output.out.contains("aiCharacterAdminAudit result=SUCCESS adminMemberId=1")) + assertTrue(output.out.contains("characterId=null")) + assertTrue(output.out.contains("creatorMemberId=null")) + assertTrue(output.out.contains("action=CREATE")) + assertTrue(output.out.contains("resourceType=ORIGINAL_WORK")) + assertTrue(output.out.contains("resourceId=10")) + } + + @Test + @DisplayName("character-scoped mutation audit은 character와 creator field를 명시한다") + fun shouldAllowCharacterScopedAuditContext(output: CapturedOutput) { + auditLogger.logFailure( + context = AiCharacterAdminAuditContext.characterScoped( + adminMemberId = 1L, + characterId = 2L, + creatorMemberId = 3L, + action = AiCharacterAdminAuditAction.UPDATE, + resourceType = AiCharacterAdminAuditResourceType.CHARACTER, + resourceId = 2L + ), + exception = IllegalStateException("failed-sensitive-body") + ) + + assertTrue(output.out.contains("result=FAILURE")) + assertTrue(output.out.contains("characterId=2")) + assertTrue(output.out.contains("creatorMemberId=3")) + assertTrue(output.out.contains("action=UPDATE")) + assertTrue(output.out.contains("resourceType=CHARACTER")) + assertTrue(output.out.contains("resourceId=2")) + assertTrue(output.out.contains("error=IllegalStateException")) + assertFalse(output.out.contains("failed-sensitive-body")) + } + + @Test + @DisplayName("원작 배정 audit은 global context로 만들 수 없다") + fun shouldRejectAssignmentAuditInGlobalOriginalWorkContext() { + assertThrows(IllegalArgumentException::class.java) { + AiCharacterAdminAuditContext.globalOriginalWork( + adminMemberId = 1L, + action = AiCharacterAdminAuditAction.ASSIGN, + resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK_CHARACTER, + resourceId = 10L + ) + } + } + + @Test + @DisplayName("global 원작 audit은 create/update/delete action만 허용한다") + fun shouldRejectNonMutationActionInGlobalOriginalWorkContext() { + assertThrows(IllegalArgumentException::class.java) { + AiCharacterAdminAuditContext.globalOriginalWork( + adminMemberId = 1L, + action = AiCharacterAdminAuditAction.READ, + resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK, + resourceId = 10L + ) + } + } + + @Test + @DisplayName("원작 배정 audit은 캐릭터별 ORIGINAL_WORK_CHARACTER context만 허용한다") + fun shouldRejectAssignmentAuditWithoutOriginalWorkCharacterResourceType() { + assertThrows(IllegalArgumentException::class.java) { + AiCharacterAdminAuditContext.characterScoped( + adminMemberId = 1L, + characterId = 2L, + creatorMemberId = 3L, + action = AiCharacterAdminAuditAction.ASSIGN, + resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK, + resourceId = 10L + ) + } + } + + @Test + @DisplayName("ORIGINAL_WORK_CHARACTER resource는 assign/unassign action만 허용한다") + fun shouldRejectOriginalWorkCharacterResourceForNonAssignmentAction() { + assertThrows(IllegalArgumentException::class.java) { + AiCharacterAdminAuditContext.characterScoped( + adminMemberId = 1L, + characterId = 2L, + creatorMemberId = 3L, + action = AiCharacterAdminAuditAction.UPDATE, + resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK_CHARACTER, + resourceId = 10L + ) + } + } + + @Test + @DisplayName("원작 배정 audit은 creator member id를 필수로 요구한다") + fun shouldRejectAssignmentWithoutCreatorMemberId() { + assertThrows(IllegalArgumentException::class.java) { + AiCharacterAdminAuditContext.characterScoped( + adminMemberId = 1L, + characterId = 2L, + creatorMemberId = null, + action = AiCharacterAdminAuditAction.ASSIGN, + resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK_CHARACTER, + resourceId = 10L + ) + } + } + + @Test + @DisplayName("global 원작 resource는 character-scoped context로 만들 수 없다") + fun shouldRejectGlobalOriginalWorkResourceInCharacterScopedContext() { + assertThrows(IllegalArgumentException::class.java) { + AiCharacterAdminAuditContext.characterScoped( + adminMemberId = 1L, + characterId = 2L, + creatorMemberId = 3L, + action = AiCharacterAdminAuditAction.UPDATE, + resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK, + resourceId = 10L + ) + } + } + + @Test + @DisplayName("원작 해제가 아닌 character-scoped audit은 creator member id가 필요하다") + fun shouldRejectMissingCreatorMemberIdOutsideUnassignment() { + assertThrows(IllegalArgumentException::class.java) { + AiCharacterAdminAuditContext.characterScoped( + adminMemberId = 1L, + characterId = 2L, + creatorMemberId = null, + action = AiCharacterAdminAuditAction.UPDATE, + resourceType = AiCharacterAdminAuditResourceType.CHARACTER, + resourceId = 2L + ) + } + } + + @Test + @DisplayName("원작 해제 audit은 creator member id null을 허용한다") + fun shouldAllowMissingCreatorMemberIdForUnassignment() { + assertDoesNotThrow { + AiCharacterAdminAuditContext.characterScoped( + adminMemberId = 1L, + characterId = 2L, + creatorMemberId = null, + action = AiCharacterAdminAuditAction.UNASSIGN, + resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK_CHARACTER, + resourceId = 10L + ) + } + } + + @Test + @DisplayName("audit context는 factory 검증을 우회하는 public copy를 노출하지 않는다") + fun shouldNotExposePublicCopyMethod() { + assertFalse(AiCharacterAdminAuditContext::class.java.methods.any { method -> method.name == "copy" }) + } + + @Test + @DisplayName("audit resource type은 원작 배정과 댓글/카테고리/공지/FanTalk 답글을 표현한다") + fun shouldExposeRequiredAuditResourceTypes() { + assertTrue(AiCharacterAdminAuditResourceType.values().contains(AiCharacterAdminAuditResourceType.ORIGINAL_WORK_CHARACTER)) + assertTrue(AiCharacterAdminAuditResourceType.values().contains(AiCharacterAdminAuditResourceType.CONTENT_COMMENT)) + assertTrue(AiCharacterAdminAuditResourceType.values().contains(AiCharacterAdminAuditResourceType.CONTENT_CATEGORY)) + assertTrue(AiCharacterAdminAuditResourceType.values().contains(AiCharacterAdminAuditResourceType.FAN_TALK_REPLY)) + assertTrue(AiCharacterAdminAuditResourceType.values().contains(AiCharacterAdminAuditResourceType.CHANNEL_NOTICE)) + } + + @Test + @DisplayName("audit action은 콘텐츠 고정까지 표현한다") + fun shouldExposePinAuditAction() { + assertTrue(AiCharacterAdminAuditAction.values().contains(AiCharacterAdminAuditAction.PIN)) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapterTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapterTest.kt new file mode 100644 index 00000000..ddbf55a9 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapterTest.kt @@ -0,0 +1,121 @@ +package kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.persistence + +import kr.co.vividnext.sodalive.chat.character.CharacterType +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberKind +import kr.co.vividnext.sodalive.member.MemberRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager +import org.springframework.context.annotation.Import +import javax.persistence.EntityManager +import javax.persistence.TypedQuery + +@DataJpaTest(properties = ["spring.cache.type=none"]) +@Import(QueryDslConfig::class) +class DefaultAiCharacterPersistenceAdapterTest @Autowired constructor( + private val entityManager: TestEntityManager, + jpaEntityManager: EntityManager +) { + private val adapter = DefaultAiCharacterPersistenceAdapter(jpaEntityManager) + + @Test + @DisplayName("AI 캐릭터 관리자 대상은 ChatCharacter와 AI creator Member를 함께 반환한다") + fun shouldFindAiCharacterAdminTarget() { + val creator = persistMember(role = MemberRole.CREATOR, memberKind = MemberKind.AI_CHARACTER, isActive = true) + val character = persistCharacter(creator = creator, isActive = true) + + val target = adapter.findAdminTarget(character.id!!) + + assertEquals(character.id, target?.characterId) + assertEquals(creator.id, target?.creatorMemberId) + assertEquals(true, target?.characterIsActive) + assertEquals(true, target?.creatorMemberIsActive) + assertEquals(MemberRole.CREATOR, target?.creatorRole) + assertEquals(MemberKind.AI_CHARACTER, target?.memberKind) + } + + @Test + @DisplayName("존재하지 않는 캐릭터는 관리자 대상이 아니다") + fun shouldReturnNullForMissingCharacter() { + assertNull(adapter.findAdminTarget(-1L)) + } + + @Test + @DisplayName("creator Member가 없으면 관리자 대상이 아니다") + fun shouldReturnNullWhenCreatorMemberIsMissing() { + val entityManager = Mockito.mock(EntityManager::class.java) + val query = Mockito.mock(TypedQuery::class.java) as TypedQuery + Mockito.`when`(entityManager.createQuery(Mockito.anyString(), Mockito.eq(ChatCharacter::class.java))) + .thenReturn(query) + Mockito.`when`(query.setParameter("characterId", 1L)).thenReturn(query) + Mockito.`when`(query.singleResult).thenReturn(characterWithoutCreator()) + + assertNull(DefaultAiCharacterPersistenceAdapter(entityManager).findAdminTarget(1L)) + } + + @Test + @DisplayName("creator Member가 AI 캐릭터가 아니면 관리자 대상이 아니다") + fun shouldIgnoreHumanCreatorMember() { + val creator = persistMember(role = MemberRole.CREATOR, memberKind = MemberKind.HUMAN, isActive = true) + val character = persistCharacter(creator = creator, isActive = true) + + assertNull(adapter.findAdminTarget(character.id!!)) + } + + @Test + @DisplayName("비활성 캐릭터와 creator는 조회 대상에 포함하되 상태를 반환한다") + fun shouldReturnInactiveStateForExistingTarget() { + val creator = persistMember(role = MemberRole.CREATOR, memberKind = MemberKind.AI_CHARACTER, isActive = false) + val character = persistCharacter(creator = creator, isActive = false) + + val target = adapter.findAdminTarget(character.id!!) + + assertEquals(false, target?.characterIsActive) + assertEquals(false, target?.creatorMemberIsActive) + } + + private fun persistMember(role: MemberRole, memberKind: MemberKind, isActive: Boolean): Member { + return entityManager.persistAndFlush( + Member( + email = null, + password = "", + nickname = "creator-${System.nanoTime()}", + role = role, + memberKind = memberKind, + isActive = isActive + ) + ) + } + + private fun persistCharacter(creator: Member?, isActive: Boolean): ChatCharacter { + val character = ChatCharacter( + characterUUID = "uuid-${System.nanoTime()}", + name = "Soda", + description = "desc", + systemPrompt = "prompt", + characterType = CharacterType.Character, + isActive = isActive + ) + character.creatorMember = creator + return entityManager.persistAndFlush(character) + } + + private fun characterWithoutCreator(): ChatCharacter { + return ChatCharacter( + characterUUID = "uuid-${System.nanoTime()}", + name = "Soda", + description = "desc", + systemPrompt = "prompt", + characterType = CharacterType.Character, + isActive = true + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterAdminTargetResolverTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterAdminTargetResolverTest.kt new file mode 100644 index 00000000..b6df348e --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterAdminTargetResolverTest.kt @@ -0,0 +1,124 @@ +package kr.co.vividnext.sodalive.v2.aicharacter.application + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.MemberKind +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.aicharacter.domain.AiCharacterAdminTarget +import kr.co.vividnext.sodalive.v2.aicharacter.port.out.AiCharacterPersistencePort +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.http.HttpStatus + +class AiCharacterAdminTargetResolverTest { + @Test + @DisplayName("존재하지 않는 관리자 대상 캐릭터는 404로 거부한다") + fun shouldRejectMissingTarget() { + val resolver = AiCharacterAdminTargetResolver(FakeAiCharacterPersistencePort(target = null)) + + val exception = assertThrows(SodaException::class.java) { + resolver.resolveActiveTarget(characterId = 1L) + } + + assertAll( + { assertEquals("characterId", exception.errorProperty) }, + { assertEquals(HttpStatus.NOT_FOUND, exception.httpStatus) } + ) + } + + @Test + @DisplayName("AI 캐릭터 creator가 아니면 404로 거부한다") + fun shouldRejectNonAiCreatorTargetAsMissing() { + listOf( + target(creatorRole = MemberRole.USER), + target(memberKind = MemberKind.HUMAN) + ).forEach { target -> + val resolver = AiCharacterAdminTargetResolver(FakeAiCharacterPersistencePort(target = target)) + + val exception = assertThrows(SodaException::class.java) { + resolver.resolveActiveTarget(characterId = 1L) + } + + assertAll( + { assertEquals("characterId", exception.errorProperty) }, + { assertEquals(HttpStatus.NOT_FOUND, exception.httpStatus) } + ) + } + } + + @Test + @DisplayName("생성/수정 대상 해석은 비활성 캐릭터 또는 creator를 409로 거부한다") + fun shouldRejectInactiveTargetForMutation() { + listOf( + target(isCharacterActive = false, isCreatorActive = true), + target(isCharacterActive = true, isCreatorActive = false) + ).forEach { target -> + val resolver = AiCharacterAdminTargetResolver(FakeAiCharacterPersistencePort(target = target)) + + val exception = assertThrows(SodaException::class.java) { + resolver.resolveActiveTarget(characterId = 1L) + } + + assertAll( + { assertEquals("characterId", exception.errorProperty) }, + { assertEquals(HttpStatus.CONFLICT, exception.httpStatus) } + ) + } + } + + @Test + @DisplayName("삭제와 상태 조회는 비활성 관리자 대상을 반환한다") + fun shouldReturnInactiveTargetForStatusOrDelete() { + val target = target(isCharacterActive = false, isCreatorActive = false) + val resolver = AiCharacterAdminTargetResolver(FakeAiCharacterPersistencePort(target = target)) + + val resolved = resolver.resolveExistingTarget(characterId = 1L) + + assertEquals(target, resolved) + } + + @Test + @DisplayName("대상 해석은 요청 characterId를 persistence port에 그대로 전달한다") + fun shouldPassRequestedCharacterIdToPersistencePort() { + val port = FakeAiCharacterPersistencePort(target = target(characterId = 99L)) + val resolver = AiCharacterAdminTargetResolver(port) + + val resolved = resolver.resolveActiveTarget(characterId = 99L) + + assertAll( + { assertEquals(99L, port.requestedCharacterId) }, + { assertEquals(99L, resolved.characterId) } + ) + } + + private fun target( + characterId: Long = 1L, + isCharacterActive: Boolean = true, + isCreatorActive: Boolean = true, + creatorRole: MemberRole = MemberRole.CREATOR, + memberKind: MemberKind = MemberKind.AI_CHARACTER + ): AiCharacterAdminTarget { + return AiCharacterAdminTarget( + characterId = characterId, + creatorMemberId = 10L, + characterIsActive = isCharacterActive, + creatorMemberIsActive = isCreatorActive, + creatorRole = creatorRole, + memberKind = memberKind + ) + } + + private class FakeAiCharacterPersistencePort( + private val target: AiCharacterAdminTarget? + ) : AiCharacterPersistencePort { + var requestedCharacterId: Long? = null + private set + + override fun findAdminTarget(characterId: Long): AiCharacterAdminTarget? { + requestedCharacterId = characterId + return target + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt index ce9c67b4..12f21b92 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt @@ -1,8 +1,12 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.`in`.web import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.configs.SecurityConfig import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler +import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint +import kr.co.vividnext.sodalive.jwt.TokenProvider import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberRole @@ -19,25 +23,18 @@ import org.junit.jupiter.api.Test import org.mockito.Mockito import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest -import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.test.mock.mockito.MockBean -import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Import -import org.springframework.http.HttpStatus -import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user -import org.springframework.security.web.SecurityFilterChain -import org.springframework.security.web.authentication.HttpStatusEntryPoint import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import java.time.LocalDateTime -import javax.servlet.http.HttpServletResponse @WebMvcTest(CreatorChannelCommunityController::class) -@Import(CreatorChannelCommunityControllerTest.TestSecurityConfig::class) +@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class) class CreatorChannelCommunityControllerTest @Autowired constructor( private val mockMvc: MockMvc ) { @@ -53,22 +50,8 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( @MockBean private lateinit var sodaMessageSource: SodaMessageSource - @TestConfiguration - class TestSecurityConfig { - @Bean - fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { - return http - .csrf().disable() - .authorizeRequests() - .anyRequest().authenticated() - .and() - .exceptionHandling() - .authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) - .accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) } - .and() - .build() - } - } + @MockBean + private lateinit var tokenProvider: TokenProvider @Test @DisplayName("크리에이터 채널 커뮤니티 탭 조회는 비회원 요청을 거부한다") diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt index ba73eca9..ff5880be 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt @@ -1,8 +1,12 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.`in`.web import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.configs.SecurityConfig import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler +import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint +import kr.co.vividnext.sodalive.jwt.TokenProvider import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberRole @@ -15,25 +19,18 @@ import org.junit.jupiter.api.Test import org.mockito.Mockito import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest -import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.test.mock.mockito.MockBean -import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Import -import org.springframework.http.HttpStatus -import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user -import org.springframework.security.web.SecurityFilterChain -import org.springframework.security.web.authentication.HttpStatusEntryPoint import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import java.time.LocalDateTime -import javax.servlet.http.HttpServletResponse @WebMvcTest(CreatorChannelFanTalkController::class) -@Import(CreatorChannelFanTalkControllerTest.TestSecurityConfig::class) +@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class) class CreatorChannelFanTalkControllerTest @Autowired constructor( private val mockMvc: MockMvc ) { @@ -49,22 +46,8 @@ class CreatorChannelFanTalkControllerTest @Autowired constructor( @MockBean private lateinit var sodaMessageSource: SodaMessageSource - @TestConfiguration - class TestSecurityConfig { - @Bean - fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { - return http - .csrf().disable() - .authorizeRequests() - .anyRequest().authenticated() - .and() - .exceptionHandling() - .authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) - .accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) } - .and() - .build() - } - } + @MockBean + private lateinit var tokenProvider: TokenProvider @Test @DisplayName("크리에이터 채널 FanTalk 탭 조회는 비회원 요청을 거부한다") diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt index 7ad5dc80..7aedabed 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt @@ -1,8 +1,12 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.`in`.web import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.configs.SecurityConfig import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler +import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint +import kr.co.vividnext.sodalive.jwt.TokenProvider import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberRole @@ -15,25 +19,18 @@ import org.junit.jupiter.api.Test import org.mockito.Mockito import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest -import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.test.mock.mockito.MockBean -import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Import -import org.springframework.http.HttpStatus -import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user -import org.springframework.security.web.SecurityFilterChain -import org.springframework.security.web.authentication.HttpStatusEntryPoint import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import java.time.LocalDateTime -import javax.servlet.http.HttpServletResponse @WebMvcTest(CreatorChannelSeriesController::class) -@Import(CreatorChannelSeriesControllerTest.TestSecurityConfig::class) +@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class) class CreatorChannelSeriesControllerTest @Autowired constructor( private val mockMvc: MockMvc ) { @@ -49,22 +46,8 @@ class CreatorChannelSeriesControllerTest @Autowired constructor( @MockBean private lateinit var sodaMessageSource: SodaMessageSource - @TestConfiguration - class TestSecurityConfig { - @Bean - fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { - return http - .csrf().disable() - .authorizeRequests() - .anyRequest().authenticated() - .and() - .exceptionHandling() - .authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) - .accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) } - .and() - .build() - } - } + @MockBean + private lateinit var tokenProvider: TokenProvider @Test @DisplayName("크리에이터 채널 시리즈 탭 조회는 비회원 요청을 거부한다") diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitEventBoundaryIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitEventBoundaryIntegrationTest.kt new file mode 100644 index 00000000..cbf60118 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitEventBoundaryIntegrationTest.kt @@ -0,0 +1,333 @@ +package kr.co.vividnext.sodalive.v2.common.application + +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository +import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectListener +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType +import kr.co.vividnext.sodalive.content.LanguageDetectionCacheService +import kr.co.vividnext.sodalive.content.category.CategoryRepository +import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository +import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository +import kr.co.vividnext.sodalive.fcm.FcmEvent +import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.fcm.FcmSendListener +import kr.co.vividnext.sodalive.fcm.FcmService +import kr.co.vividnext.sodalive.fcm.PushTokenInfo +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationService +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationListener +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType +import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler +import kr.co.vividnext.sodalive.member.MemberRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.mockito.Mockito.verify +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.ApplicationEventPublisher +import org.springframework.context.annotation.Import +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener +import org.springframework.transaction.support.TransactionTemplate + +@DataJpaTest( + properties = [ + "spring.cache.type=none", + "cloud.naver.papago-client-id=test-client-id", + "cloud.naver.papago-client-secret=test-client-secret" + ] +) +@Import( + AfterCommitExecutor::class, + QueryDslConfig::class, + AfterCommitEventBoundaryIntegrationTest.TestEventListener::class, + FcmSendListener::class, + LanguageDetectListener::class, + LanguageTranslationListener::class +) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class AfterCommitEventBoundaryIntegrationTest @Autowired constructor( + private val executor: AfterCommitExecutor, + private val eventPublisher: ApplicationEventPublisher, + private val testEventListener: TestEventListener, + transactionManager: PlatformTransactionManager +) { + private val transactionTemplate = TransactionTemplate(transactionManager) + + @MockBean + private lateinit var fcmService: FcmService + + @MockBean + private lateinit var memberRepository: MemberRepository + + @MockBean + private lateinit var contentCommentRepository: AudioContentCommentRepository + + @MockBean + private lateinit var sodaMessageSource: SodaMessageSource + + @MockBean + private lateinit var pushNotificationService: PushNotificationService + + @MockBean + private lateinit var audioContentRepository: AudioContentRepository + + @MockBean + private lateinit var chatCharacterRepository: ChatCharacterRepository + + @MockBean + private lateinit var characterCommentRepository: kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository + + @MockBean + private lateinit var creatorCheersRepository: CreatorCheersRepository + + @MockBean + private lateinit var seriesRepository: ContentSeriesRepository + + @MockBean + private lateinit var originalWorkRepository: OriginalWorkRepository + + @MockBean + private lateinit var categoryRepository: CategoryRepository + + @MockBean + private lateinit var languageDetectionCacheService: LanguageDetectionCacheService + + @MockBean + private lateinit var resourceTranslationJobScheduler: ResourceTranslationJobScheduler + + @Test + @DisplayName("direct callback은 실제 transaction commit 후 1회 실행된다") + fun shouldRunCallbackAfterRealTransactionCommit() { + var count = 0 + + transactionTemplate.executeWithoutResult { + executor.executeAfterCommit { count += 1 } + assertEquals(0, count) + } + + assertEquals(1, count) + } + + @Test + @DisplayName("direct callback은 실제 transaction rollback 후 실행되지 않는다") + fun shouldNotRunCallbackAfterRealTransactionRollback() { + var count = 0 + + try { + transactionTemplate.executeWithoutResult { + executor.executeAfterCommit { count += 1 } + throw IllegalStateException("rollback") + } + } catch (e: IllegalStateException) { + assertEquals("rollback", e.message) + } + + assertEquals(0, count) + } + + @Test + @DisplayName("첫 attempt rollback 후 동일 command 재시도 commit 시 callback은 총 1회 실행된다") + fun shouldRunCallbackOnceWhenSameCommandCommitsOnRetryAfterRollback() { + var attemptCount = 0 + var callbackCount = 0 + val command = { + transactionTemplate.executeWithoutResult { + attemptCount += 1 + executor.executeAfterCommit { callbackCount += 1 } + if (attemptCount == 1) { + throw IllegalStateException("rollback") + } + } + } + + assertThrows(IllegalStateException::class.java) { command() } + assertEquals(0, callbackCount) + + command() + + assertEquals(2, attemptCount) + assertEquals(1, callbackCount) + } + + @Test + @DisplayName("기존 FCM과 언어 event listener는 AFTER_COMMIT 경계를 유지한다") + fun shouldKeepExistingEventListenersAfterCommit() { + assertEquals( + TransactionPhase.AFTER_COMMIT, + transactionalEventListener(FcmSendListener::class.java, "send", FcmEvent::class.java).phase + ) + assertEquals( + TransactionPhase.AFTER_COMMIT, + transactionalEventListener( + LanguageDetectListener::class.java, + "detectLanguage", + LanguageDetectEvent::class.java + ).phase + ) + assertEquals( + TransactionPhase.AFTER_COMMIT, + transactionalEventListener( + LanguageTranslationListener::class.java, + "translationAfterCommit", + LanguageTranslationEvent::class.java + ).phase + ) + } + + @Test + @DisplayName("일반 event publish는 rollback되면 transactional listener를 실행하지 않는다") + fun shouldNotRunTransactionalEventListenerAfterRollback() { + testEventListener.messages.clear() + + try { + transactionTemplate.executeWithoutResult { + eventPublisher.publishEvent(TestEvent("rollback")) + throw IllegalStateException("rollback") + } + } catch (e: IllegalStateException) { + assertEquals("rollback", e.message) + } + + assertEquals(emptyList(), testEventListener.messages) + } + + @Test + @DisplayName("일반 event publish는 commit 이후 transactional listener를 실행한다") + fun shouldRunTransactionalEventListenerAfterCommit() { + testEventListener.messages.clear() + + transactionTemplate.executeWithoutResult { + eventPublisher.publishEvent(TestEvent("commit")) + assertEquals(emptyList(), testEventListener.messages) + } + + assertEquals(listOf("commit"), testEventListener.messages) + } + + @Test + @DisplayName("실제 FCM event listener는 transaction commit 이후 실행된다") + fun shouldRunRealFcmEventListenerAfterCommit() { + val event = FcmEvent( + type = FcmEventType.CANCEL_LIVE, + title = "title", + message = "message", + pushTokens = listOf(PushTokenInfo(token = "token", deviceType = "ios", languageCode = "ko")) + ) + + transactionTemplate.executeWithoutResult { + eventPublisher.publishEvent(event) + Mockito.verifyNoInteractions(fcmService) + } + + Mockito.verify(fcmService, Mockito.timeout(1000)).send( + tokens = listOf("token"), + title = "title", + message = "message", + container = "ios", + roomId = null, + messageId = null, + contentId = null, + creatorId = null, + auditionId = null, + deepLinkValue = null, + deepLinkId = null, + deepLinkCommentPostId = null, + chatType = null + ) + } + + @Test + @DisplayName("실제 언어 감지 event listener는 rollback되면 실행되지 않는다") + fun shouldNotRunRealLanguageDetectListenerAfterRollback() { + try { + transactionTemplate.executeWithoutResult { + eventPublisher.publishEvent( + LanguageDetectEvent(id = 9L, query = "hello", targetType = LanguageDetectTargetType.CHARACTER) + ) + throw IllegalStateException("rollback") + } + } catch (e: IllegalStateException) { + assertEquals("rollback", e.message) + } + + Mockito.verifyNoInteractions(chatCharacterRepository) + } + + @Test + @DisplayName("실제 언어 감지 event listener는 commit 이후 실행된다") + fun shouldRunRealLanguageDetectListenerAfterCommit() { + Mockito.doReturn("ko").`when`(languageDetectionCacheService) + .detectWithCache(eqValue("hello"), eqValue("papago"), anyValue()) + + transactionTemplate.executeWithoutResult { + eventPublisher.publishEvent( + LanguageDetectEvent( + id = 9L, + query = "hello", + targetType = LanguageDetectTargetType.CHARACTER + ) + ) + Mockito.verifyNoInteractions(chatCharacterRepository) + } + + verify(chatCharacterRepository, Mockito.timeout(1000)).findById(eqValue(9L)) + } + + @Test + @DisplayName("waitTransactionCommit=true 언어 번역 event는 commit 이후 scheduler를 호출한다") + fun shouldRunRealLanguageTranslationListenerAfterCommitWhenWaitingTransactionCommit() { + transactionTemplate.executeWithoutResult { + eventPublisher.publishEvent( + LanguageTranslationEvent( + id = 11L, + targetType = LanguageTranslationTargetType.CHARACTER, + waitTransactionCommit = true + ) + ) + Mockito.verifyNoInteractions(resourceTranslationJobScheduler) + } + + Mockito.verify(resourceTranslationJobScheduler, Mockito.timeout(1000)) + .scheduleResourceTranslations(LanguageTranslationTargetType.CHARACTER, 11L) + } + + private fun transactionalEventListener( + type: Class<*>, + methodName: String, + eventType: Class<*> + ): TransactionalEventListener { + return type.getDeclaredMethod(methodName, eventType).getAnnotation(TransactionalEventListener::class.java) + } + + private fun eqValue(value: T): T { + return Mockito.eq(value) ?: value + } + + private fun anyValue(): T { + return Mockito.any() + } + + data class TestEvent(val message: String) + + class TestEventListener { + val messages = mutableListOf() + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun handle(event: TestEvent) { + messages += event.message + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutorTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutorTest.kt new file mode 100644 index 00000000..f386b8a3 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutorTest.kt @@ -0,0 +1,88 @@ +package kr.co.vividnext.sodalive.v2.common.application + +import org.junit.jupiter.api.Assertions.assertDoesNotThrow +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.transaction.support.TransactionSynchronizationManager + +class AfterCommitExecutorTest { + @Test + @DisplayName("트랜잭션이 없으면 direct callback을 즉시 1회 실행한다") + fun shouldRunCallbackImmediatelyWithoutTransaction() { + val executor = AfterCommitExecutor() + var count = 0 + + executor.executeAfterCommit { count += 1 } + + assertEquals(1, count) + } + + @Test + @DisplayName("트랜잭션이 없으면 direct callback 예외를 호출자에게 전파하지 않는다") + fun shouldNotPropagateCallbackExceptionWithoutTransaction() { + val executor = AfterCommitExecutor() + + assertDoesNotThrow { + executor.executeAfterCommit { throw IllegalStateException("sensitive-body") } + } + } + + @Test + @DisplayName("트랜잭션 commit 이후 direct callback을 1회 실행한다") + fun shouldRunCallbackOnceAfterCommit() { + val executor = AfterCommitExecutor() + var count = 0 + TransactionSynchronizationManager.initSynchronization() + + try { + executor.executeAfterCommit { count += 1 } + TransactionSynchronizationManager.getSynchronizations().forEach { synchronization -> + synchronization.afterCommit() + } + } finally { + TransactionSynchronizationManager.clearSynchronization() + } + + assertEquals(1, count) + } + + @Test + @DisplayName("afterCommit callback 예외는 격리되어 이후 callback 실행을 막지 않는다") + fun shouldIsolateCallbackExceptionAfterCommit() { + val executor = AfterCommitExecutor() + var count = 0 + TransactionSynchronizationManager.initSynchronization() + + try { + executor.executeAfterCommit { throw IllegalStateException("sensitive-body") } + executor.executeAfterCommit { count += 1 } + + assertDoesNotThrow { + TransactionSynchronizationManager.getSynchronizations().forEach { synchronization -> + synchronization.afterCommit() + } + } + } finally { + TransactionSynchronizationManager.clearSynchronization() + } + + assertEquals(1, count) + } + + @Test + @DisplayName("트랜잭션 rollback이면 direct callback을 실행하지 않는다") + fun shouldNotRunCallbackBeforeCommit() { + val executor = AfterCommitExecutor() + var count = 0 + TransactionSynchronizationManager.initSynchronization() + + try { + executor.executeAfterCommit { count += 1 } + } finally { + TransactionSynchronizationManager.clearSynchronization() + } + + assertEquals(0, count) + } +} From 075ca88f01a88a36389c10d055a1dbc4ae1be817 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 22 Jul 2026 21:25:04 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix(home):=20=EC=A2=85=EB=A3=8C=EB=90=9C=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EB=8D=B0=EB=B7=94=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20=EB=B3=B4=EA=B0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260529_메인_홈_추천_API/plan-task.md | 4 ++- docs/20260529_메인_홈_추천_API/prd.md | 1 + ...efaultHomeRecommendationQueryRepository.kt | 3 +-- ...ltHomeRecommendationQueryRepositoryTest.kt | 26 +++++++++++++++++++ 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/docs/20260529_메인_홈_추천_API/plan-task.md b/docs/20260529_메인_홈_추천_API/plan-task.md index d0bfa4cb..07412812 100644 --- a/docs/20260529_메인_홈_추천_API/plan-task.md +++ b/docs/20260529_메인_홈_추천_API/plan-task.md @@ -259,9 +259,11 @@ - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt` - RED: 데뷔 후 30일 이내 추천 점수순, 최근 데뷔 크리에이터 노출 정보의 프로필 이미지/닉네임, 첫 오디오 콘텐츠 3번째 이내 활성 콘텐츠만 인정, 최신성 점수 구간별 정렬, 예약 공개 콘텐츠 제외 테스트를 작성한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest` - - GREEN: 데뷔일 계산, 최근 7일/30일 집계, `release_date` 기준 최신성 점수, 동점 랜덤 정렬을 구현한다. + - GREEN: 데뷔일 계산, 최근 7일/30일 집계, `release_date` 기준 최신성 점수, 동점 랜덤 정렬을 구현한다. 최근 데뷔 크리에이터의 라이브 기준 데뷔 판정은 종료된 라이브도 유지되는 `live_room.channel_name` 존재 여부를 기준으로 하며, 종료 시 `false`가 되는 `live_room.is_active`는 조건으로 사용하지 않는다. - REFACTOR: 데뷔일 계산은 `CreatorDebutPolicy`, 산식은 `RecommendationScorePolicy`만 호출하도록 중복 제거한다. - 기대 결과: 앞선 비활성 콘텐츠가 3개 이상이면 이후 활성 콘텐츠가 제외된다. + - 검증 기록: + - 2026-07-22: 종료된 라이브도 `channel_name`이 있으면 최근 데뷔 크리에이터의 라이브 데뷔로 인정하도록 `DefaultHomeRecommendationQueryRepositoryTest.shouldIncludeEndedLiveWithChannelNameInRecentDebutCreators`를 추가했다. RED에서 기존 SQL의 `lr.is_active = true` 조건 때문에 실패했고, GREEN에서 `findRecentDebutCreators`의 live 데뷔 branch가 `channel_name` 기준만 사용하도록 수정해 focused test가 `BUILD SUCCESSFUL`로 통과했다. - [x] **Task 3.3: AI 캐릭터/응원/인기 커뮤니티 스냅샷 조회 구현** - Files: diff --git a/docs/20260529_메인_홈_추천_API/prd.md b/docs/20260529_메인_홈_추천_API/prd.md index a578bc53..6ea62a31 100644 --- a/docs/20260529_메인_홈_추천_API/prd.md +++ b/docs/20260529_메인_홈_추천_API/prd.md @@ -132,6 +132,7 @@ - 전체 리스트 API는 페이징으로 조회할 수 있어야 한다. - 데뷔일은 콘텐츠를 처음 공개한 날과 라이브를 한 날 중 빠른 날짜로 계산한다. - 데뷔일 계산 로직은 기존 `ExplorerService.getCreatorDetail`의 `debutDateTime` 계산 방식과 동일하게 맞춘다. +- 라이브 기준 데뷔 판정은 `live_room.channel_name`이 존재하고 빈 값이 아닌 라이브를 사용한다. 종료된 라이브는 `live_room.is_active = false`가 되므로 최근 데뷔 판정에서 `is_active`는 조건으로 사용하지 않는다. - 데뷔 후 30일 이내 크리에이터만 대상으로 한다. - 추천 점수는 `((팔로우 증가량 * 0.35) + (콘텐츠 활동 점수 * 0.3) + (소통 점수 * 0.2)) * 신규 부스트`로 계산한다. - 팔로우 증가량은 최근 7일간 신규 팔로우한 유저 수로 계산한다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt index 942dc8ce..0b5bdf43 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt @@ -228,8 +228,7 @@ class DefaultHomeRecommendationQueryRepository( union all select lr.member_id as creator_id, lr.begin_date_time as debut_at from live_room lr - where lr.is_active = true - and lr.channel_name is not null + where lr.channel_name is not null and lr.channel_name <> '' and lr.begin_date_time <= :now and (:includeAdultContents = true or lr.is_adult = false) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt index ea202b9b..5ea19c98 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt @@ -1245,6 +1245,32 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( assertEquals(listOf(newHighScoreCreator.id, newLowScoreCreator.id), creators.map { it.creatorId }) } + @Test + @DisplayName("최근 데뷔 크리에이터는 종료된 라이브도 채널명이 있으면 라이브 데뷔로 인정한다") + fun shouldIncludeEndedLiveWithChannelNameInRecentDebutCreators() { + val now = LocalDateTime.of(2026, 5, 31, 10, 0) + val endedLiveCreator = saveMember("ended-live-debut", MemberRole.CREATOR) + val blankChannelCreator = saveMember("blank-ended-live-debut", MemberRole.CREATOR) + + saveLiveRoom( + endedLiveCreator, + now.minusDays(5), + channelName = "ended-live-channel", + isActive = false + ) + saveLiveRoom( + blankChannelCreator, + now.minusDays(4), + channelName = "", + isActive = false + ) + flushAndClear() + + val creators = repository.findRecentDebutCreators(now, limit = 10) + + assertEquals(listOf(endedLiveCreator.id), creators.map { it.creatorId }) + } + @Test @DisplayName("최근 데뷔 크리에이터는 인기 커뮤니티 전용 부스트가 아니라 기존 신규 부스트를 유지한다") fun shouldKeepOriginalNewBoostForRecentDebutCreators() { From a0bf71be47cfeddabb35599d734c008ee85bbff2 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 22 Jul 2026 22:14:26 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(home):=20AI=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=EB=B6=80=EC=A1=B1=EB=B6=84=EC=9D=84=20=EB=9E=9C?= =?UTF-8?q?=EB=8D=A4=20=EB=B3=B4=EC=B6=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260529_메인_홈_추천_API/plan-task.md | 24 +++++ docs/20260529_메인_홈_추천_API/prd.md | 3 + .../plan-task.md | 21 +++- .../prd.md | 4 + .../application/HomeRecommendationFacade.kt | 5 +- ...efaultHomeRecommendationQueryRepository.kt | 27 +++++ .../HomeRecommendationQueryService.kt | 14 ++- .../port/out/HomeRecommendationQueryPort.kt | 5 + ...ltHomeRecommendationQueryRepositoryTest.kt | 29 +++++ .../HomeRecommendationQueryServiceTest.kt | 102 ++++++++++++++++++ 10 files changed, 230 insertions(+), 4 deletions(-) diff --git a/docs/20260529_메인_홈_추천_API/plan-task.md b/docs/20260529_메인_홈_추천_API/plan-task.md index 07412812..b3a0d57e 100644 --- a/docs/20260529_메인_홈_추천_API/plan-task.md +++ b/docs/20260529_메인_홈_추천_API/plan-task.md @@ -295,6 +295,30 @@ - 2026-07-09: GREEN에서 `HomeRecommendationFacade.HOME_AI_CHARACTER_LIMIT`와 `HomeRecommendationQueryService.DEFAULT_AI_CHARACTER_LIMIT`를 20으로 변경하고, 홈 통합 응답이 AI 캐릭터를 최대 20개 반환하는 controller 테스트를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest` 실행 결과 `BUILD SUCCESSFUL`로 통과했다. - 2026-07-09: 회귀 검증으로 `./gradlew ktlintCheck`와 `./gradlew tasks --all`을 실행해 모두 `BUILD SUCCESSFUL`로 통과했다. 두 명령은 최초 sandbox 실행에서 Gradle wrapper의 `~/.gradle` lock 파일 접근 권한 문제로 실패했고, 권한 상승 재실행으로 통과했다. `./gradlew :app:ktlintCheck`는 단일 루트 프로젝트에 `app` 프로젝트가 없어 실패했으며, 저장소 기준 명령인 `./gradlew ktlintCheck`로 대체 검증했다. +- [x] **Task 9.2: 홈 첫 화면 AI 캐릭터 부족분 랜덤 보충** + - Files: + - Modify: `docs/20260529_메인_홈_추천_API/prd.md` + - Modify: `docs/20260529_메인_홈_추천_API/plan-task.md` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt` + - RED: 스냅샷 상세 조립 결과가 요청 limit 미만이면 스냅샷 target id를 제외하고 랜덤 AI 캐릭터 id를 부족분만큼 조회한 뒤 기존 상세 조회를 재사용하는 service 테스트와, 랜덤 id 조회가 활성 `ChatCharacter` 및 활성 CREATOR/AI_CHARACTER `creatorMember`만 반환하고 제외 id를 빼는 repository 테스트를 작성한다. + - 실패 확인: + ```bash + ./gradlew test \ + --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest \ + --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest + ``` + - GREEN: 홈 통합 조회에서 `backfillRandom = true`로 호출하고, `HomeRecommendationQueryService.findAiCharacterRecommendations`에서 보충이 명시적으로 켜져 있으며 상세 결과가 `limit`보다 적을 때만 `HomeRecommendationQueryPort.findRandomAiCharacterRecommendationIds(excludeCharacterIds, limit)`로 부족분 id를 먼저 뽑은 뒤 기존 `findAiCharacterRecommendationDetails(...)`로 상세와 채팅 수를 조회한다. `excludeCharacterIds`는 최신 스냅샷 target id 전체를 사용해 stale 스냅샷 후보를 다시 뽑지 않는다. + - REFACTOR: 전체보기 paging 조회는 랜덤 보충을 적용하지 않고, AI 캐릭터 추천 점수 산식/스냅샷 생성/공개 API schema는 변경하지 않는다. + - 기대 결과: `GET /api/v2/home/recommendations`의 `aiCharacters`는 가능한 경우 20개까지 채워지고, 랜덤 후보가 부족하면 중복 없이 가능한 AI 캐릭터만 반환한다. + - 검증 기록: + - 2026-07-22: RED에서 `HomeRecommendationQueryPort.findRandomAiCharacterRecommendationIds(...)` 계약을 추가하고 service/repository 테스트를 작성했으며, 기존 구현은 `DefaultHomeRecommendationQueryRepository`가 신규 port 메서드를 구현하지 않아 `compileKotlin`에서 실패했다. + - 2026-07-22: GREEN에서 홈 통합 조회에만 `backfillRandom = true`를 전달하고, AI 캐릭터 스냅샷 상세 결과가 limit보다 적을 때 랜덤 AI 캐릭터 id를 먼저 조회한 뒤 기존 상세 조회로 상세와 채팅 수를 재사용하도록 구현했다. 빈 스냅샷 refresh 완료 marker와 전체보기 paging 조회는 랜덤 보충하지 않도록 기존 계약을 유지했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` 실행 결과 `BUILD SUCCESSFUL`로 통과했다. + - 2026-07-22: 리뷰 지적에 따라 전체보기 첫 페이지가 `offset == 0`으로 랜덤 보충되는 문제를 `backfillRandom` 명시 플래그로 보정했다. 재검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `git diff --check`, `./gradlew test`를 실행해 모두 `BUILD SUCCESSFUL` 또는 무출력 통과를 확인했다. + ### Phase 4: 콘텐츠 조회 이력 기록 - [x] **Task 4.1: 콘텐츠 조회 이력 엔티티/서비스 작성** diff --git a/docs/20260529_메인_홈_추천_API/prd.md b/docs/20260529_메인_홈_추천_API/prd.md index 6ea62a31..b084cb83 100644 --- a/docs/20260529_메인_홈_추천_API/prd.md +++ b/docs/20260529_메인_홈_추천_API/prd.md @@ -164,6 +164,7 @@ #### Requirements - AI 캐릭터 리스트를 조회한다. - 홈 첫 화면은 20개를 조회한다. +- 홈 첫 화면은 최신 스냅샷 상세 조회 결과가 20개 미만이면 이미 조회한 스냅샷 캐릭터를 제외한 활성 AI 캐릭터 랜덤 조회로 부족분을 채운다. - 전체 리스트 API는 페이징으로 조회할 수 있어야 한다. - 노출 정보는 캐릭터 id, AI 캐릭터에 대응하는 creator id, 캐릭터 이름, 캐릭터 소개, 프로필 이미지, 작품명, 사용자들이 친 전체 채팅 수를 포함한다. - AI 캐릭터에 대응하는 creator id는 `ChatCharacter.creatorMember.id`이며, 해당 Member는 `role = CREATOR`, `memberKind = AI_CHARACTER`인 내부 크리에이터 Member다. @@ -182,6 +183,8 @@ #### Edge Cases - 비활성 또는 노출 제한 캐릭터는 제외한다. - 활성 `ChatCharacter`에 `creatorMember`가 없거나 연결된 Member가 비활성/비 CREATOR/비 AI_CHARACTER이면 해당 AI 캐릭터는 홈 추천 응답에서 제외한다. +- 랜덤 보충 후보가 부족하면 중복 없이 조회 가능한 AI 캐릭터만 내려준다. +- 전체 리스트 API의 paging 조회는 페이지별 랜덤 보충을 적용하지 않는다. ### Feature H. 장르의 크리에이터 diff --git a/docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md b/docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md index e060c54d..5a50f77f 100644 --- a/docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md +++ b/docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md @@ -20,6 +20,7 @@ - 유지: `DefaultHomeRecommendationQueryRepository.findAiCharacterRecommendationDetails(...)`의 상세 조립, `characterId`, `creatorId`, 원작명, 전체 채팅 수, 활성 `creatorMember` 필터는 변경하지 않는다. - 유지: `HomeRecommendationFacade`, `HomeRecommendationController`, `HomeRecommendationResponse.HomeAiCharacterItem`의 공개 API URL과 응답 필드는 변경하지 않는다. - 유지: 최근 응원/인기 커뮤니티 스냅샷 산식, window, 저장 limit, random tie-breaker 정렬은 변경하지 않는다. +- 추가: 홈 통합 조회에서 최신 스냅샷 상세 결과가 20개 미만이면 스냅샷 캐릭터 id를 제외한 활성 AI 캐릭터 랜덤 조회로 부족분을 채운다. - 변경: `findAiCharacterSnapshots(...)`의 입력 window, AI 캐릭터 점수 산식, 신규 부스트 값, 팔로우 증가 수 포함, 후보 제외 조건, top 20 동점 정렬 기준만 변경한다. - 변경: `RecommendationSnapshotRefreshService`에는 AI 캐릭터 단일 refresh 경로를 추가하되, 기존 전체 일 refresh가 최근 응원/인기 커뮤니티까지 함께 갱신하는 동작은 유지한다. - 추가: 최신 AI 캐릭터 스냅샷이 없을 때만 fallback refresh orchestration을 추가한다. @@ -217,6 +218,21 @@ ### Phase 7: 최종 검증과 문서 갱신 +- [x] **Task 6.3: 홈 통합 AI 캐릭터 부족분 랜덤 보충** + - 파일 경로: + - Modify: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md` + - Modify: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt` + - RED: 새 `findRandomAiCharacterRecommendationIds(...)` port 계약을 추가하고, 스냅샷 상세 결과가 limit 미만이면 부족분만큼 랜덤 id를 조회한 뒤 기존 상세 조회를 재사용하는 service 테스트와 제외 id/활성 AI creator member 조건을 검증하는 repository 테스트를 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` + - GREEN: 홈 통합 조회에서 `backfillRandom = true`로 호출하고, 보충이 명시적으로 켜져 있으며 스냅샷이 존재하고 상세 결과가 limit 미만인 경우에만 랜덤 id를 먼저 조회한 뒤 기존 상세 조회로 상세와 채팅 수를 재사용한다. + - REFACTOR: 빈 스냅샷 refresh 완료 marker와 전체보기 paging 조회는 기존 빈 결과/paging 계약을 유지한다. + - 기대 결과: 홈 통합 AI 캐릭터 섹션은 가능한 경우 20개까지 채워지고, 공개 API 응답 필드는 변경되지 않는다. + - [x] **Task 7.1: focused regression 실행** - 파일 경로: - Modify: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md` @@ -246,7 +262,7 @@ - Feature A: Task 2.1, Task 3.1에서 AI 채팅 수 45%, 활성 사용자 수 35%, 팔로우 증가 수 20% 산식을 검증한다. - Feature B: Task 2.3, Task 4.1에서 KST 전날 하루를 UTC 조회 범위로 변환하는 정책을 검증한다. - Feature C: Task 2.2, Task 3.2에서 `ChatCharacter.createdAt` 기준 AI 전용 신규 부스트와 null/future 제외를 검증한다. -- Feature D: Task 3.3, Task 5.3, Task 6.1에서 top 20, 동점 생성일 최신순, 기존 응답 스키마 유지를 검증한다. +- Feature D: Task 3.3, Task 5.3, Task 6.1, Task 6.3에서 top 20, 동점 생성일 최신순, 홈 통합 부족분 랜덤 보충, 기존 응답 스키마 유지를 검증한다. - Feature E: Task 5.1, Task 5.2, Task 5.3에서 fallback refresh 재사용, double-check, 300ms lock 대기, 1,500ms 홈 API 대기, timeout 후 background 완료, 중복 refresh 방지를 검증한다. - Non-Goals: Task 6.1과 Task 7.2에서 공개 API URL/응답 필드 변경 없음, AI 캐릭터 팔로우 생성/취소 동작 변경 없음, 관리자/ML/A-B 제외를 확인한다. @@ -258,6 +274,9 @@ - 2026-07-10: 구현 검증으로 산식/윈도우 focused 테스트 `RecommendationScorePolicyTest`, `RecommendationSnapshotWindowPolicyTest`를 실행해 `BUILD SUCCESSFUL`을 확인했다. - 2026-07-10: AI 캐릭터 스냅샷 집계 focused 테스트 `DefaultHomeRecommendationQueryRepositoryTest`를 실행해 전날 UTC window, 팔로우 증가량, 후보 제외, top 20/동점 정렬이 `BUILD SUCCESSFUL`임을 확인했다. - 2026-07-10: refresh/fallback/query focused 테스트 `RecommendationSnapshotRefreshServiceTest`, `RecommendationSnapshotFallbackServiceTest`, `HomeRecommendationQueryServiceTest`를 실행해 section lock, 300ms lock wait, 1,500ms home wait, timeout 후 background 완료, double-check, fallback 연결이 `BUILD SUCCESSFUL`임을 확인했다. +- 2026-07-22: 사용자 피드백에 따라 홈 통합 AI 캐릭터 섹션의 스냅샷 상세 결과가 20개 미만이면 랜덤 활성 AI 캐릭터로 부족분을 보충하도록 PRD와 plan-task를 보강했다. RED에서 신규 port 계약 미구현으로 `DefaultHomeRecommendationQueryRepository` 컴파일이 실패했고, GREEN에서 홈 통합 조회에만 `backfillRandom = true`를 전달해 랜덤 id 조회와 기존 상세 조회 재사용 조건을 추가했다. focused 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`를 실행해 `BUILD SUCCESSFUL`을 확인했다. +- 2026-07-22: 리뷰 지적에 따라 랜덤 보충 쿼리가 전체 AI 캐릭터의 채팅 메시지를 집계한 뒤 `RAND()`/`LIMIT`하지 않도록 2단계 조회로 보정했다. 랜덤 쿼리는 활성 AI 캐릭터 id만 limit만큼 선택하고, 상세/전체 채팅 수는 기존 `findAiCharacterRecommendationDetails(...)` 경로를 재사용한다. RED에서 기존 production 구현의 `findRandomAiCharacterRecommendationDetails(...)` 참조로 `compileKotlin`이 실패했고, GREEN 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`와 `./gradlew ktlintCheck`가 `BUILD SUCCESSFUL`로 통과했다. +- 2026-07-22: 리뷰 게이트에서 전체보기 첫 페이지가 `offset == 0` 조건으로 랜덤 보충되는 blocker를 확인해 `backfillRandom` 명시 플래그로 홈 통합 조회와 전체보기 조회를 분리했다. 재검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `git diff --check`, `./gradlew test`를 실행해 모두 `BUILD SUCCESSFUL` 또는 무출력 통과를 확인했고, 재리뷰에서 blocker 없이 승인받았다. - 2026-07-10: API 회귀 focused 테스트 `HomeRecommendationControllerTest`, `HomeRecommendationResponseTest`를 실행해 공개 응답 스키마 유지가 `BUILD SUCCESSFUL`임을 확인했다. - 2026-07-10: 전체 검증으로 `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`, `git diff --check`를 실행했다. `./gradlew test`는 120초 제한에서 1회 timeout되어 600초 제한으로 재실행했고, 모든 명령이 최종 `BUILD SUCCESSFUL` 또는 무출력 통과했다. - 2026-07-10: 리뷰 게이트에서 발견된 fallback 조건/단일 실행/section lock 해제 시점 이슈를 수정했다. 최신 스냅샷 존재 여부는 요청 page가 아니라 `offset=0, limit=1` 존재 확인으로 분리했고, fallback refresh는 단일 in-flight future로 제한했으며, scheduler AI section lock은 트랜잭션 완료 후 해제되도록 보강했다. diff --git a/docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md b/docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md index c2907123..213d136b 100644 --- a/docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md +++ b/docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md @@ -21,6 +21,7 @@ - 팔로우 증가 수는 전날 발생한 AI 캐릭터 신규 팔로우 수로 계산한다. - 신규 부스트는 `ChatCharacter.createdAt` 기준으로 10일 이내 1.15, 20일 이내 1.10, 30일 이내 1.05, 그 외 1.0을 적용한다. - 홈 통합 조회와 AI 캐릭터 전체보기 조회는 최신 AI 캐릭터 스냅샷의 점수순 결과를 사용한다. +- 홈 통합 조회는 최신 스냅샷 상세 조회 결과가 20개 미만이면 스냅샷 캐릭터를 제외한 활성 AI 캐릭터 랜덤 조회로 부족분을 채운다. --- @@ -117,11 +118,14 @@ - 사용자들이 친 전체 채팅 수 - 스냅샷 정렬은 점수 내림차순, 동일 점수면 더 늦게 생성된 캐릭터를 우선한다. - 조회 시점에도 비활성 또는 노출 제한 캐릭터는 제외한다. +- 홈 통합 조회에서 스냅샷 상세 조회 결과가 20개 미만이면 이미 조회한 스냅샷 캐릭터 id를 제외하고 활성 AI 캐릭터를 랜덤으로 조회해 부족분을 뒤에 붙인다. #### Edge Cases - 스냅샷에는 존재하지만 조회 시점에 캐릭터 또는 `creatorMember`가 비활성화된 경우 응답에서 제외한다. - fallback refresh 후에도 최신 스냅샷이 없으면 기존 홈 추천 API 동작과 동일하게 빈 배열을 반환한다. - 상세 조회 결과가 스냅샷 저장 개수보다 적어도 홈 조회 전체는 성공 처리한다. +- 랜덤 보충 후보가 부족하면 중복 없이 조회 가능한 AI 캐릭터만 반환한다. +- 빈 스냅샷 refresh 완료 marker가 있거나 전체보기 paging 조회이면 랜덤 보충을 적용하지 않는다. ### Feature E. 스냅샷 없음 fallback refresh diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt index 3b7617c5..a462f09b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt @@ -75,7 +75,10 @@ class HomeRecommendationFacade( includeAdultContents = includeAdult ) .map { it.toItem() }, - aiCharacters = queryService.findAiCharacterRecommendations(limit = HOME_AI_CHARACTER_LIMIT).map { it.toItem() }, + aiCharacters = queryService.findAiCharacterRecommendations( + limit = HOME_AI_CHARACTER_LIMIT, + backfillRandom = true + ).map { it.toItem() }, genreCreators = queryService.findGenreCreatorRecommendations( memberId = member?.id, includeAdultGenres = includeAdult, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt index 0b5bdf43..829560ef 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt @@ -775,6 +775,29 @@ class DefaultHomeRecommendationQueryRepository( .fetch() } + override fun findRandomAiCharacterRecommendationIds( + excludeCharacterIds: List, + limit: Int + ): List { + val creatorMember = QMember("creatorMember") + val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')") + + return queryFactory + .select(chatCharacter.id) + .from(chatCharacter) + .join(chatCharacter.creatorMember, creatorMember) + .where( + chatCharacter.isActive.isTrue, + excludeAiCharacterCondition(excludeCharacterIds), + creatorMember.isActive.isTrue, + creatorMember.role.eq(MemberRole.CREATOR), + creatorMember.memberKind.eq(MemberKind.AI_CHARACTER) + ) + .orderBy(randomTieBreaker.asc()) + .limit(limit.toLong()) + .fetch() + } + override fun findCheerCreatorRecommendationDetails( creatorIds: List, memberId: Long? @@ -1235,6 +1258,10 @@ class DefaultHomeRecommendationQueryRepository( return if (includeAdultLives) null else liveRoom.isAdult.isFalse } + private fun excludeAiCharacterCondition(excludeCharacterIds: List): BooleanExpression? { + return if (excludeCharacterIds.isEmpty()) null else chatCharacter.id.notIn(excludeCharacterIds) + } + private fun notBlockedCreatorCondition(memberId: Long?, creatorIdPath: Expression): BooleanExpression? { if (memberId == null) return null val blockMember = QBlockMember("recommendationBlockMember") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt index d57eb3fc..9c877a3d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt @@ -78,13 +78,23 @@ class HomeRecommendationQueryService( fun findAiCharacterRecommendations( offset: Long = 0, - limit: Int = DEFAULT_AI_CHARACTER_LIMIT + limit: Int = DEFAULT_AI_CHARACTER_LIMIT, + backfillRandom: Boolean = false ): List { val snapshots = findAiCharacterSnapshotsWithFallback(offset, limit) val detailsById = queryPort.findAiCharacterRecommendationDetails(snapshots.map { it.targetId }) .associateBy { it.characterId } + val snapshotDetails = snapshots.mapNotNull { detailsById[it.targetId] } - return snapshots.mapNotNull { detailsById[it.targetId] } + if (!backfillRandom || snapshots.isEmpty() || snapshotDetails.size >= limit) return snapshotDetails + + val randomIds = queryPort.findRandomAiCharacterRecommendationIds( + excludeCharacterIds = snapshots.map { it.targetId }, + limit = limit - snapshotDetails.size + ) + val randomDetailsById = queryPort.findAiCharacterRecommendationDetails(randomIds).associateBy { it.characterId } + val randomDetails = randomIds.mapNotNull { randomDetailsById[it] } + return (snapshotDetails + randomDetails).distinctBy { it.characterId }.take(limit) } private fun findAiCharacterSnapshotsWithFallback( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt index eaedcb4e..30e0300e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt @@ -58,6 +58,11 @@ interface HomeRecommendationQueryPort { fun findAiCharacterRecommendationDetails(characterIds: List): List + fun findRandomAiCharacterRecommendationIds( + excludeCharacterIds: List, + limit: Int + ): List + fun findCheerCreatorRecommendationDetails( creatorIds: List, memberId: Long? = null diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt index 5ea19c98..ef606ecd 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt @@ -1619,6 +1619,35 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( ) } + @Test + @DisplayName("랜덤 AI 캐릭터 id는 제외 id를 빼고 활성 AI 캐릭터 크리에이터 회원만 조회한다") + fun shouldFindRandomAiCharacterRecommendationIdsExcludingExistingCharacters() { + val excludedCharacter = saveCharacter("ai-random-excluded", isActive = true) + val visibleCharacter = saveCharacter("ai-random-visible", isActive = true) + val inactiveCharacter = saveCharacter("ai-random-inactive", isActive = false) + val inactiveCreatorCharacter = saveCharacter("ai-random-inactive-creator", isActive = true).apply { + creatorMember!!.isActive = false + } + val userCreatorCharacter = saveCharacter("ai-random-user-creator", isActive = true).apply { + creatorMember!!.role = MemberRole.USER + } + val humanCreatorCharacter = saveCharacter("ai-random-human-creator", isActive = true).apply { + creatorMember!!.memberKind = MemberKind.HUMAN + } + flushAndClear() + + val characterIds = repository.findRandomAiCharacterRecommendationIds( + excludeCharacterIds = listOf(excludedCharacter.id!!), + limit = 10 + ) + + assertEquals(listOf(visibleCharacter.id), characterIds) + assertEquals(false, characterIds.contains(inactiveCharacter.id)) + assertEquals(false, characterIds.contains(inactiveCreatorCharacter.id)) + assertEquals(false, characterIds.contains(userCreatorCharacter.id)) + assertEquals(false, characterIds.contains(humanCreatorCharacter.id)) + } + @Test @DisplayName("최근 응원 크리에이터 상세는 활성 크리에이터의 닉네임과 프로필만 조회한다") fun shouldFindCheerCreatorRecommendationDetailsForActiveCreatorsOnly() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt index e2d4d5a8..a6efc425 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt @@ -196,6 +196,53 @@ class HomeRecommendationQueryServiceTest { assertEquals(null, characters.last().originalWorkTitle) } + @Test + @DisplayName("홈 AI 캐릭터 추천은 스냅샷 상세가 부족하면 랜덤 AI 캐릭터로 limit까지 채운다") + fun shouldBackfillAiCharactersWithRandomDetailsWhenSnapshotDetailsAreUnderLimit() { + val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) + snapshotPort.replaceSnapshots( + RecommendedSectionType.AI_CHARACTER, + snapshotAt, + (1L..3L).map { targetId -> snapshot(RecommendedSectionType.AI_CHARACTER, targetId, 100.0 - targetId, snapshotAt) } + ) + port.aiCharacterDetailsByRequest = mapOf( + (1L..3L).toList() to listOf(aiCharacterRecord(1L, 101L)), + listOf(4L, 5L) to listOf(aiCharacterRecord(4L, 104L), aiCharacterRecord(5L, 105L)) + ) + port.randomAiCharacterIds = listOf(4L, 5L) + + val characters = service.findAiCharacterRecommendations(limit = 3, backfillRandom = true) + + assertEquals(listOf(4L, 5L), port.aiCharacterDetailIds) + assertEquals((1L..3L).toList(), port.randomAiCharacterExcludeIds) + assertEquals(2, port.randomAiCharacterLimit) + assertEquals(listOf((1L..3L).toList(), listOf(4L, 5L)), port.aiCharacterDetailIdRequests) + assertEquals(listOf(1L, 4L, 5L), characters.map { it.characterId }) + } + + @Test + @DisplayName("홈 AI 캐릭터 추천은 랜덤 후보가 부족하면 가능한 캐릭터만 중복 없이 반환한다") + fun shouldReturnAvailableAiCharactersWhenRandomBackfillIsUnderLimit() { + val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) + snapshotPort.replaceSnapshots( + RecommendedSectionType.AI_CHARACTER, + snapshotAt, + listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 1L, 100.0, snapshotAt)) + ) + port.aiCharacterDetailsByRequest = mapOf( + listOf(1L) to listOf(aiCharacterRecord(1L, 101L)), + listOf(2L) to listOf(aiCharacterRecord(2L, 102L)) + ) + port.randomAiCharacterIds = listOf(2L) + + val characters = service.findAiCharacterRecommendations(limit = 3, backfillRandom = true) + + assertEquals(listOf(1L), port.randomAiCharacterExcludeIds) + assertEquals(2, port.randomAiCharacterLimit) + assertEquals(listOf(listOf(1L), listOf(2L)), port.aiCharacterDetailIdRequests) + assertEquals(listOf(1L, 2L), characters.map { it.characterId }) + } + @Test @DisplayName("AI 캐릭터 추천은 최신 스냅샷이 없으면 fallback refresh 후 스냅샷 상세를 조립한다") fun shouldFindAiCharactersAfterFallbackWhenLatestSnapshotsDoNotExist() { @@ -254,10 +301,39 @@ class HomeRecommendationQueryServiceTest { assertEquals(null, fallback.offset) assertEquals(null, fallback.limit) + assertEquals(null, port.randomAiCharacterExcludeIds) + assertEquals(null, port.randomAiCharacterLimit) assertEquals(emptyList(), port.aiCharacterDetailIds) assertEquals(emptyList(), characters) } + @Test + @DisplayName("AI 캐릭터 전체보기는 첫 페이지여도 랜덤 보충을 적용하지 않는다") + fun shouldNotBackfillAiCharacterPageWithRandomDetails() { + val fallback = FakeAiCharacterSnapshotFallbackPort( + listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59))) + ) + val service = HomeRecommendationQueryService(port, snapshotPort, fallback) + val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) + snapshotPort.replaceSnapshots( + RecommendedSectionType.AI_CHARACTER, + snapshotAt, + (1L..20L).map { targetId -> snapshot(RecommendedSectionType.AI_CHARACTER, targetId, 100.0 - targetId, snapshotAt) } + ) + + port.aiCharacterDetails = listOf(aiCharacterRecord(1L, 101L)) + port.randomAiCharacterIds = listOf(21L) + + val characters = service.findAiCharacterRecommendations(offset = 0, limit = 20) + + assertEquals(null, fallback.offset) + assertEquals(null, fallback.limit) + assertEquals(null, port.randomAiCharacterExcludeIds) + assertEquals(null, port.randomAiCharacterLimit) + assertEquals((1L..20L).toList(), port.aiCharacterDetailIds) + assertEquals(listOf(1L), characters.map { it.characterId }) + } + @Test @DisplayName("AI 캐릭터 추천은 최신 스냅샷이 있으면 요청 페이지가 비어도 fallback refresh를 호출하지 않는다") fun shouldNotFallbackWhenLatestAiSnapshotsExistButRequestedPageIsEmpty() { @@ -902,6 +978,9 @@ class HomeRecommendationQueryServiceTest { var firstAudioMemberId: Long? = null var firstAudioIncludeAdultContents: Boolean? = null var aiCharacterDetailIds: List = emptyList() + val aiCharacterDetailIdRequests = mutableListOf>() + var randomAiCharacterExcludeIds: List? = null + var randomAiCharacterLimit: Int? = null var cheerCreatorDetailIds: List = emptyList() var popularCommunityDetailIds: List = emptyList() var popularCommunityIncludeAdultCommunities: Boolean? = null @@ -962,6 +1041,8 @@ class HomeRecommendationQueryServiceTest { ) ) var aiCharacterDetails: List = emptyList() + var aiCharacterDetailsByRequest: Map, List> = emptyMap() + var randomAiCharacterIds: List = emptyList() var cheerCreatorDetails: List = emptyList() var cheerCreatorMemberId: Long? = null var popularCommunityDetails: List = emptyList() @@ -1048,9 +1129,20 @@ class HomeRecommendationQueryServiceTest { override fun findAiCharacterRecommendationDetails(characterIds: List): List { aiCharacterDetailIds = characterIds + aiCharacterDetailIdRequests.add(characterIds) + aiCharacterDetailsByRequest[characterIds]?.let { return it } return aiCharacterDetails } + override fun findRandomAiCharacterRecommendationIds( + excludeCharacterIds: List, + limit: Int + ): List { + randomAiCharacterExcludeIds = excludeCharacterIds + randomAiCharacterLimit = limit + return randomAiCharacterIds.take(limit) + } + override fun findCheerCreatorRecommendationDetails( creatorIds: List, memberId: Long? @@ -1154,6 +1246,16 @@ private fun fixedClock(): Clock { return Clock.fixed(Instant.parse("2026-07-09T21:00:00Z"), ZoneOffset.UTC) } +private fun aiCharacterRecord(characterId: Long, creatorId: Long) = HomeAiCharacterRecommendationRecord( + characterId = characterId, + creatorId = creatorId, + name = "character-$characterId", + description = "description-$characterId", + profileImage = null, + totalChatCount = 0L, + originalWorkTitle = null +) + private class FakeCheerCreatorSnapshotFallbackPort( private val snapshots: List ) : CheerCreatorSnapshotFallbackPort {