Compare commits

...

16 Commits

Author SHA1 Message Date
5a90fbf98e docs(ai-character): 관리자 API 계약과 검증 기록을 갱신한다 2026-07-30 00:35:34 +09:00
d86a9fb059 test(ai-character): 관리자 API 인가 검증을 보강한다 2026-07-30 00:34:53 +09:00
1bfb387e78 feat(ai-character): 팬톡 관리자 API를 추가한다 2026-07-30 00:33:46 +09:00
c45ad98db8 feat(ai-character): 커뮤니티 게시글 관리자 API를 추가한다 2026-07-30 00:32:49 +09:00
79b4436812 feat(ai-character): 시리즈 관리자 API를 추가한다 2026-07-30 00:31:52 +09:00
7b652400b7 feat(ai-character): 오디오 콘텐츠 관리자 계약을 보강한다 2026-07-30 00:30:53 +09:00
4479c192c9 feat(ai-character): 캐릭터 관리자 레거시 계약을 보강한다 2026-07-30 00:30:26 +09:00
2f93e2c9c3 docs(ai-character): 관리자 API 계약 문서를 고정한다 2026-07-28 02:19:26 +09:00
dc1816aea7 chore(openapi): 생성기 버전을 고정한다 2026-07-28 01:33:39 +09:00
ec4e4a5768 feat(ai-character): 오디오 콘텐츠 관리자 API를 추가한다 2026-07-28 01:33:05 +09:00
90c0182ae1 feat(ai-character): 캐릭터 관리자 API를 추가한다 2026-07-28 01:32:53 +09:00
54bbfc75cf docs(ai-character): 관리자 API 검증 기록을 보강한다 2026-07-28 01:32:40 +09:00
8197f90b24 docs(ai-character): 관리자 API 계획을 보강한다 2026-07-27 17:42:23 +09:00
f713313315 docs(agent): 문서 작업 절차를 보강한다 2026-07-27 17:42:11 +09:00
0d2742756f feat(ai-character): 관리자 API Phase 1 기반을 추가한다 2026-07-26 05:01:34 +09:00
d3564f8c0d revert(ai-character): 기존 관리자 기능 기반을 제거한다 2026-07-23 16:47:00 +09:00
138 changed files with 35290 additions and 11762 deletions

View File

@@ -1,395 +0,0 @@
# 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와 이유를 명시한다.
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,422 @@
# PRD: AI 캐릭터 관리자 API
## 1. Overview
운영자가 AI 캐릭터용 Member로 직접 로그인하지 않고, `ADMIN` 권한으로 선택한 AI 캐릭터의 크리에이터 채널 자산과 팬 상호작용을
대리 관리하는 신규 v2 관리자 API를 제공한다.
---
## 2. Problem
- AI 캐릭터용 `Member(memberKind = AI_CHARACTER)`는 직접 로그인할 수 없어야 하지만, 운영자는 캐릭터의 콘텐츠, 시리즈,
커뮤니티, 각 자산의 댓글과 FanTalk를 관리해야 한다.
- 기존 기능은 `creatorMember.id` 기반으로 흩어져 있으며, 관리자 frontend가 레거시 endpoint를 조합하면 권한, 소유권, soft delete 의미가 일관되지 않을 수 있다.
- 기존 creator/admin service 일부에는 소유권 검증이 약한 경로가 있어, 단순 위임만으로는 다른 캐릭터나 HUMAN creator 자원을 변경할 위험이 있다.
- 캐릭터 등록에 필요한 원작 검색과 시리즈 등록에 필요한 장르 목록은 범용 관리자 API에만 있어 캐릭터 관리자 배포 Origin에서
호출할 수 없다.
- 기존 legacy/public API 계약은 유지해야 하므로 신규 관리자 표면은 별도 v2 경계로 제공되어야 한다.
---
## 3. Goals
- 신규 prefix `/api/v2/admin/ai-characters/**`는 JWT `auth` claim의 `ROLE_ADMIN`과 JWT subject로 조회한 현재 DB
`Member.role == ADMIN`을 모두 만족하는 요청만 허용한다.
- 모든 신규 target endpoint는 외부 대상 식별자로 `characterId`를 받고, 서버가 `ChatCharacter.creatorMember`를 내부 행위자로
해석한다. 단, 캐릭터 목록/검색·생성, 원작 검색과 시리즈 장르 목록은 선택된 target이 필요하지 않아 `characterId`를 받지
않는다.
- target 해석 시 `ChatCharacter` 존재, `creatorMember` 존재, `creatorMember.role == CREATOR`, `creatorMember.memberKind == AI_CHARACTER`를 모두 검증한다.
- 검증 실패 시 4xx로 거부하고 DB, S3, 외부 캐릭터 API, 이벤트 발행 등 후속 부작용을 만들지 않는다.
- 캐릭터, 오디오 콘텐츠와 댓글, 시리즈, 커뮤니티 게시글과 댓글, FanTalk 목록·답변 작성·수정·팬 원글 삭제, 오디오
signed URL을 신규 관리자 API에서 관리한다.
- 기존 legacy/public endpoint의 URI, 성공·오류 HTTP status, response body, message/i18n을 포함한 외부 계약은 변경하지 않는다.
단, 캐릭터 관리자 frontend가 기존 관리자 인증을 재사용할 수 있도록 `/admin/member/login`, `/member/logout`의 CORS 허용
Origin만 path-specific으로 확장한다.
- 내부 구현은 신규 v2 controller/facade/application 경계를 두고, 기존 entity/repository/S3/CloudFront/event 컴포넌트는 테스트로 고정한 뒤 선택적으로 재사용한다.
---
## 4. Non-Goals
- 이번 PRD는 관리자 API backend 요구사항과 구현 계획만 포함하며, 관리자 UI/frontend 구현은 포함하지 않는다.
- AI 캐릭터용 Member의 access token, refresh token, 임시 세션, impersonation 로그인은 만들지 않는다.
- `creatorMemberId`를 관리자 frontend의 필수 입력으로 노출하지 않는다.
- HUMAN creator를 이 API로 대리 관리하지 않는다.
- 위 두 공유 인증 경로의 CORS 허용 Origin 확장 외 기존 legacy/public endpoint 변경, 폐기, deprecation, schema 변경은 포함하지
않는다.
- 기존 external character API business contract 변경은 포함하지 않으며, 변경이 필요하면 재확인한다.
- 기존 soft delete 의미 변경은 포함하지 않으며, 변경이 필요하면 재확인한다.
- 물리 삭제와 연관 데이터 cascade 삭제는 포함하지 않는다.
- 신규 DB schema/DDL 또는 `ChatCharacter`-`Member` 관계 모델 변경은 포함하지 않는다.
- 라이브, DM, 후원, 정산, 알림 설정, 랭킹 관리, 콘텐츠 구매·좋아요와 커뮤니티 구매·좋아요 관리는 포함하지 않는다.
- 사용하지 않는 레거시 캐릭터 직접 댓글 `/api/chat/character/{characterId}/comments`의 v2 전환·조회·삭제는 포함하지 않는다.
- FanTalk 원글 작성, creator reply 전용 삭제 endpoint·hard delete, 일반 사용자 대리 작성, nested reply 작성과 FanTalk
원글 삭제 시 reply cascade 변경은 포함하지 않는다.
- `AudioContentCloudFront` 복사/이동, signed URL 신규 dependency 추가는 포함하지 않는다.
---
## 5. Target Users
- 운영자: AI 캐릭터를 대신해 캐릭터 프로필, 콘텐츠, 시리즈, 커뮤니티 게시글, 각 자산의 댓글과 FanTalk를 관리하는 관리자
- 관리자 frontend: 신규 v2 AI 캐릭터 관리자 API만으로 In-Scope 작업을 수행해야 하는 클라이언트
- 서버 개발자: 기존 creator 기능을 회귀시키지 않으면서 AI 캐릭터 대리 관리 경계를 유지해야 하는 개발자
---
## 6. User Stories
- 운영자는 AI 캐릭터 목록을 검색하고 상세 정보를 확인한 뒤 생성, 수정, 비활성화하고 싶다.
- 운영자는 캐릭터 등록 시 soft delete되지 않은 원작을 검색해 `originalWorkId`를 선택하고 싶다.
- 운영자는 선택한 AI 캐릭터 소유 오디오 콘텐츠를 조회, 생성, 수정, soft delete하고 관리자 화면에서 재생 가능한 signed URL을 받고 싶다.
- 운영자는 선택한 AI 캐릭터의 오디오 콘텐츠에 달린 댓글·답글을 조회하고, 캐릭터 명의로 작성·수정하며, 부적절한 댓글·답글을
soft delete하고 싶다.
- 운영자는 선택한 AI 캐릭터 소유 시리즈를 조회, 생성, 수정, soft delete하고 콘텐츠 연결/해제/순서를 관리하고 싶다.
- 운영자는 시리즈 등록 시 활성 장르 목록에서 `genreId`를 선택하고 싶다.
- 운영자는 선택한 AI 캐릭터 소유 커뮤니티 게시글을 작성, 수정, 고정/해제, soft delete하고 싶다.
- 운영자는 선택한 AI 캐릭터의 커뮤니티 게시글에 달린 댓글·답글을 조회하고, 캐릭터 명의로 작성·수정하며, 부적절한 댓글·답글을
soft delete하고 싶다.
- 운영자는 선택한 AI 캐릭터의 FanTalk 목록과 기존 creator reply를 확인한 뒤 활성 root FanTalk에 답변하고, 기존 답변의
내용·활성 상태를 수정하거나 팬이 작성한 원글을 soft delete하고 싶다.
- 서버는 다른 AI 캐릭터나 HUMAN creator의 resource ID가 전달되면 변경 없이 4xx로 거부해야 한다.
---
## 7. Core Features
### Feature A. 공통 인증, 인가, target 해석
#### Requirements
- 모든 신규 prefix endpoint는 JWT `ROLE_ADMIN`과 현재 DB `Member.role == ADMIN`을 독립적으로 모두 검증한다.
- JWT가 없거나 잘못됐거나 만료·폐기된 경우는 401, JWT role과 현재 DB role 중 하나라도 ADMIN이 아닌 경우는 403으로
처리한다.
- JWT에는 `ROLE_ADMIN`이 남아 있지만 현재 DB role이 강등된 stale claim도 403으로 거부한다.
- 선택한 캐릭터 자원을 다루는 domain write/read는 `characterId``ChatCharacter`를 조회한 뒤 연결된 `creatorMember`
사용한다. 원작 검색과 시리즈 장르 목록은 target 없는 reference endpoint이므로 `characterId`를 받거나 target을 해석하지
않는다.
- `creatorMember`는 도메인 소유권/작성자 판단에만 사용하고 Spring Security principal로 교체하지 않는다.
- `creatorMember` 누락, role 불일치, memberKind 불일치 요청은 4xx로 거부한다.
- 요청 중 누락 Member 생성, role/memberKind 자동 보정 같은 lazy repair는 하지 않는다.
#### Edge Cases
- stale claim을 포함한 인증·인가 실패는 target resolver와 domain use-case 실행 전에 종료되어야 한다.
- 유효하지 않은 `characterId` 요청은 DB write, S3 upload/delete, 외부 캐릭터 API 호출, 이벤트 발행 없이 실패해야 한다.
- 다른 AI 캐릭터 또는 HUMAN creator 소유 resource ID는 조회/수정/삭제/연결/답변 모두 거부해야 한다.
### Feature B. AI 캐릭터 관리
#### Requirements
- 목록 조회, 검색, 상세 조회, 생성, 수정, 삭제 의미의 비활성화(`isActive=false`)를 제공한다.
- 캐릭터 등록 화면에서 사용할 soft delete되지 않은 원작 검색을 `characterId` 없는 관리자 전용 endpoint로 제공한다.
- 원작 검색은 레거시 `AdminOriginalWorkController.search`처럼 필수 `searchTerm`으로 제목·콘텐츠 타입·카테고리를 부분
검색하고 soft delete된 원작을 제외하며, 페이징 없는 `OriginalWorkResponse` 목록을 반환한다.
- 레거시 플랫폼 관리자와 중복 이름 검증, 외부 캐릭터 API 연동, 대표 이미지 저장, 원작 연결, 언어 감지/번역 이벤트, AI 캐릭터용 `creatorMember` 생성 및 표시 정보 동기화 동작 parity를 유지한다.
- 삭제는 soft delete이며 row, 연결 Member, 콘텐츠를 물리 삭제하지 않는다.
#### Edge Cases
- 중복 이름, 외부 캐릭터 API 실패, 이미지 저장 실패는 기존 관리자 동작을 특성화 테스트로 고정한 뒤 유지한다.
- 비활성화 실패 시 일부 관계만 변경된 상태로 남기지 않는다.
- 원작 검색은 캐릭터 target을 해석하지 않지만 신규 prefix의 ADMIN 이중 인가·오류·CORS 계약을 동일하게 적용한다.
### Feature C. 오디오 콘텐츠 관리 및 signed URL
#### Requirements
- 캐릭터 소유 콘텐츠 목록/검색/상세 조회, 생성, 수정, 기존 삭제 동작에 따른 soft delete를 제공한다.
- 콘텐츠 생성 화면에서 사용할 활성 콘텐츠 테마(카테고리) 목록 조회를 제공한다. 기존 크리에이터 관리자 콘텐츠 등록
화면의 `GetAudioContentThemeResponse`와 같은 `id`, `theme`, `image` 필드명을 유지한다.
- 기존 크리에이터 콘텐츠 관리의 검증, 파일 처리, content upload/processing pipeline, 가격, 공개/예약, 번역/알림 등 business behavior parity를 유지한다.
- 관리자 화면 재생용 signed URL을 콘텐츠 목록/상세 응답에 제공한다.
- signed URL은 공통 `AudioContentCloudFront`를 재사용하고 기존 크리에이터 관리자와 같은 만료 정책을 따른다.
- 기존 signed URL 구현을 재사용하기 전에 creator admin 만료 계산식과 만료 계산·path 처리에서 실제로 관찰되는 edge case를 통과하는 특성화 테스트로 고정한다.
- 응답에 private object path나 서명 키 정보를 노출하지 않는다.
- 신규 관리자 오디오 생성 request는 `timezone`을 받지 않고 nullable `releaseDate`를 클라이언트가 변환한 ISO-8601
UTC(`Z`)로 받는다. 로컬 시각과 timezone을 함께 받는 레거시 생성 형식은 병행 지원하지 않는다.
- 오디오 상세의 nullable `releaseDate`는 기존 필드명과 null/노출 조건을 유지하고, 값이 있으면 ISO-8601 UTC(`Z`)로
반환한다. 상세 조회는 `timezone` query를 받지 않는다.
- target 소유의 활성 오디오 콘텐츠에 대해 댓글 목록, 답글 목록, 댓글·답글 작성, 캐릭터가 작성한 댓글·답글 수정과 모든
댓글·답글 soft delete를 제공한다.
- 댓글·답글 작성자는 관리자 principal이 아니라 target `creatorMember`이며, optional `parentId`가 없으면 원댓글, 있으면
답글로 저장한다.
- 댓글·답글 내용 수정은 작성자가 target `creatorMember`인 활성 row에만 허용한다.
- 댓글·답글 삭제는 작성자와 관계없이 target 소유 콘텐츠에 연결된 row의 `isActive=false`만 반영하고 자식 답글을 cascade
변경하거나 물리 삭제하지 않는다.
- 댓글·답글 조회는 `timezone` 없이 `page`, `size`를 받고 레거시 응답 필드를 유지하되, 각 `date`를 ISO-8601
UTC(`Z`)로 반환한다.
#### Edge Cases
- 콘텐츠 소유자가 target `creatorMember`와 다르면 조회/수정/삭제 모두 거부한다.
- 댓글 또는 답글이 target 소유 콘텐츠에 연결되지 않았거나, 답글 작성의 `parentId`가 같은 콘텐츠의 활성 원댓글이 아니면
mutation 전에 400으로 거부한다.
- 댓글 수정은 팬이 작성한 row를 target 캐릭터 명의로 변경하지 않으며, 이미 비활성인 row도 수정하지 않는다.
- 이미 비활성인 댓글·답글의 삭제는 성공 no-op으로 처리하고 추가 상태 변경이나 이벤트를 만들지 않는다.
- 커뮤니티 오디오의 기존 30분 signed URL 정책은 이 콘텐츠 재생 정책과 임의 통합하지 않는다.
### Feature D. 시리즈 관리
#### Requirements
- 목록/상세 조회, 생성, 수정, `isActive=false` soft delete를 제공한다.
- 시리즈 상세 응답 `data`는 목록 wrapper가 아니라 목록 `items`의 단일 항목과 동일한 schema를 사용한다. 필드는
`seriesId`, `title`, `introduction`, `coverImageUrl`, `publishedDaysOfWeek`, `genreId`, `isAdult`, `state`, `isActive`,
`writer`, `studio`이며 기존 상세 전용 `genre`, `keywords`는 반환하지 않는다.
- 콘텐츠 연결/해제, 시리즈 콘텐츠 조회/검색, 순서 관리를 제공한다.
- 기존 creator series 관리의 생성/수정/soft delete, 콘텐츠 연결/해제, 조회/검색, 순서 관리 behavior를 먼저 통과하는 특성화 테스트로 고정하고 신규 v2 경로에서 parity를 유지한다.
- 시리즈와 연결 콘텐츠는 모두 동일한 `creatorMember` 소유여야 한다.
- 기존 `updateSeriesOrders(ids)`처럼 소유권 없는 ID-only 갱신은 신규 v2 경로에서 허용하지 않는다.
- 시리즈 콘텐츠 조회는 관리자 연결 작업을 위해 검색어 기반 필터를 제공한다.
- 시리즈 등록 화면에서 사용할 활성 장르 목록을 `characterId` 없는 관리자 전용 endpoint로 제공한다.
- 장르 목록은 레거시 범용 관리자 API처럼 활성 장르 전체를 `orders` 오름차순으로 반환하며 각 항목에 `id`, `genre`,
`isAdult`를 포함한다.
#### Edge Cases
- 순서 변경 요청의 모든 series/content ID는 target character 소유 검증을 통과해야 한다.
- inactive series는 일반 활성 조회에서 제외한다.
- 장르 목록은 캐릭터 target을 해석하지 않지만 신규 prefix의 ADMIN 이중 인가·오류·CORS 계약을 동일하게 적용한다.
### Feature E. 커뮤니티 게시글 관리
#### Requirements
- 등록, 수정, 공지 고정/해제(`isFixed`), 수정 요청의 `isActive=false` soft delete를 제공한다.
- soft delete 시 현재 동작처럼 `isFixed=false`, `fixedAt=null`을 적용한다.
- 기존 최대 고정 게시글 수 3개, 이미지/오디오/유료 게시글 검증, 알림/최근 소식 side effect를 유지한다.
- 관리자 UI에 필요한 조회는 기존 v2 커뮤니티 조회 로직을 무비판적으로 복제하지 않고 신규 관리자 facade/endpoint에서 안전하게 재사용하거나 최소 query adapter를 둔다.
- 관리자 커뮤니티 목록은 `timezone` query를 받지 않고 `page`, `size`만 받는다.
- 목록 응답 `data``totalCount`, `page`, `size`, `hasNext`, `items`를 포함해 관리자 UI가 전체 개수와 다음 페이지
추가 로딩 필요 여부를 판단할 수 있어야 한다.
- target 소유의 활성 커뮤니티 게시글에 대해 댓글 목록, 답글 목록, 댓글·답글 작성, 캐릭터가 작성한 댓글·답글 수정과 모든
댓글·답글 soft delete를 제공한다.
- 댓글·답글 작성자는 관리자 principal이 아니라 target `creatorMember`이며, optional `parentId`가 없으면 원댓글, 있으면
답글로 저장한다.
- 댓글·답글 내용 수정은 작성자가 target `creatorMember`인 활성 row에만 허용한다.
- 댓글·답글 삭제는 작성자와 관계없이 target 소유 게시글에 연결된 row의 `isActive=false`만 반영하고 자식 답글을 cascade
변경하거나 물리 삭제하지 않는다.
- 댓글·답글 조회는 `timezone` 없이 `page`, `size`를 받고 레거시 응답 필드를 유지하되, 각 `date`를 ISO-8601
UTC(`Z`)로 반환한다.
#### Edge Cases
- 고정 게시글이 이미 3개인 상태에서 추가 고정은 기존 정책대로 실패한다.
- soft delete된 고정 게시글은 고정 상태와 시간이 반드시 제거되어야 한다.
- 댓글 또는 답글이 target 소유 게시글에 연결되지 않았거나, 답글 작성의 `parentId`가 같은 게시글의 활성 원댓글이 아니면
mutation 전에 400으로 거부한다.
- 댓글 수정은 팬이 작성한 row를 target 캐릭터 명의로 변경하지 않으며, 이미 비활성인 row도 수정하지 않는다.
- 이미 비활성인 댓글·답글의 삭제는 성공 no-op으로 처리하고 추가 상태 변경이나 이벤트를 만들지 않는다.
### Feature F. FanTalk 관리
#### Requirements
- 선택한 AI 캐릭터의 FanTalk 목록과 각 root 글의 creator reply 목록을 관리자 전용 endpoint로 조회한다.
- 목록 응답 필드와 page 정책은 공개 v2 `CreatorChannelFanTalkTabResponse`를 유지하되, 공개 v2 endpoint를 직접 재사용하지
않고 `characterId` target 해석과 관리자 ownership 정책을 적용한다.
- 선택한 AI 캐릭터가 자신의 활성 root FanTalk에 creator reply를 작성한다.
- 요청은 `characterId`와 대상 root `fanTalkId`를 포함한다.
- 대상 FanTalk가 존재하고 활성 상태이며, 대상 creator가 해석된 `creatorMember`와 일치하는 root 글인지 검증한다.
- 언어 감지와 기존 응답 DTO 의미 등 검증 가능한 business behavior를 유지하고, 저장된 답변의 writer/creator는 해석된 `creatorMember`와 일관되어야 한다.
- 선택한 AI 캐릭터가 작성한 기존 direct reply의 내용과 활성 상태를 수정하는 관리자 전용 endpoint를 제공한다.
- 답변 수정 request는 레거시 `PutWriteCheersRequest`에서 path로 이동한 `cheersId`를 제외한 optional/nullable `content`,
`isActive`를 그대로 받는다. 두 필드는 함께 입력할 수 있고 모두 생략하거나 `null`이면 성공 no-op이다.
- 답변 수정 대상은 target `creatorMember`가 writer이자 creator인 direct reply여야 하며, `fanTalkId`로 지정한 target
채널의 활성 root에 직접 연결되어야 한다. 비활성 reply는 조회 대상에 포함해 `isActive=true`로 재활성화할 수 있다.
- 답변 수정은 레거시와 같이 non-null field만 반영하며 저장된 `languageCode`를 변경하거나 언어 감지·이벤트를 발생시키지
않는다.
- 답변 수정 성공 `data`는 레거시 `CreatorChannelFanTalkResponse` 필드 형태를 유지한다. 응답의 `fanTalkId`는 수정한
reply row ID이고 `creatorReplies`는 빈 배열이다.
- 팬이 작성한 root FanTalk를 `isActive=false`로 soft delete하는 관리자 전용 endpoint를 제공한다.
- FanTalk 삭제는 root row만 비활성화하고 연결된 creator reply row는 변경하지 않는다. 비활성 root가 목록에서 제외되므로
연결된 reply도 함께 노출되지 않는다.
#### Edge Cases
- 다른 캐릭터의 FanTalk, reply에 대한 nested reply, 비활성 FanTalk, 미존재 FanTalk에는 답변하지 않는다.
- 실패 시 reply 저장과 이벤트 발행이 없어야 한다.
- 답변 수정 시 root·reply가 다른 target에 속하거나, reply가 지정한 root의 direct child가 아니거나, root가
비활성·미존재이거나, target AI가 작성하지 않은 팬 root/reply이면 400으로 거부하고 변경하지 않는다.
- 답변 수정 대상 reply 자체의 비활성 상태는 거부 조건이 아니며, `isActive=true` 재활성화를 허용한다.
- FanTalk 삭제 대상은 target `creatorMember` 채널에 연결된 `parent=null`의 fan 작성 root여야 한다. 같은 target의 이미
비활성인 fan root는 성공 no-op이고, creator가 작성한 root, reply, 다른 creator의 root, 미존재 root는 변경 없이
400으로 거부한다.
- FanTalk 원글 삭제는 reply 삭제·수정이나 별도 이벤트를 발생시키지 않는다.
---
## 8. API Expectations
- 신규 endpoint prefix는 기존 공개 `/api/v2/creator-channels/*`와 legacy `/admin/*`, `/creator-admin/*`를 변경하지 않기 위해 `/api/v2/admin/ai-characters`를 기본안으로 한다.
- 성공 응답은 `ApiResponse.ok(...)`, API application/controller/security filter 오류는 오류 의미에 맞는 HTTP status와
`ApiResponse.error(...)`를 사용한다.
- 이 API 오류 응답은 `success=false`와 현지화된 `message`를 포함하며 2xx로 normalize하지 않는다.
- `Accept-Language: ko|en|ja`에 따라 KO/EN/JA 메시지를 반환하고, 없거나 지원하지 않는 언어는 KO로 fallback한다.
- security filter 단계의 오류도 MVC interceptor에 의존하지 않고 `Accept-Language`를 직접 해석해 동일한 응답 계약을 따른다.
- 신규 prefix는 캐릭터 관리자 frontend Origin `http://localhost:8888`,
`https://test-character-admin.sodalive.net`, `https://character-admin.sodalive.net`만 허용한다.
- 기존 범용 관리자 frontend와 creator frontend Origin을 캐릭터 관리자 Origin 대신 허용하지 않는다.
- 공유 인증 경로 `/admin/member/login`, `/member/logout`는 기존 전역 Origin과 위 캐릭터 관리자 Origin의 합집합만 허용한다.
이 path-specific 확장은 다른 legacy/public 경로의 CORS 허용 범위를 변경하지 않는다.
- 위 관리자 Origin의 신규 prefix 오류와 preflight는 404 fallback 및 실제 mapped endpoint의 405/406/415 경로를 포함해 기존
전역 CORS 응답 계약을 유지하며, 두 공유 인증 경로에서도 허용·거부 Origin을 검증한다.
- 허용되지 않은 Origin, method 또는 header를 Spring CORS 계층에서 정책 거부하는 경우는 handler 진입 전 403으로 종료되는
브라우저 보안 경계다. 이 403의 body, content type, 현지화 및 `ApiResponse.error` envelope는 신규 API 오류 계약의 예외로
두고 외부 계약으로 고정하지 않는다.
- 표준 HTTP method가 MVC까지 도달했지만 해당 mapping이 없으면 기존 Spring MVC의 405와 `Allow` header를 유지한다.
- `StrictHttpFirewall`이 신규 prefix에서 비표준 HTTP method 또는 위험 URL을 `RequestRejectedException`으로 거부하면,
캐릭터 관리자 허용 Origin에는 CORS header를 포함한 400 `common.error.invalid_request`와 현지화된 `ApiResponse.error`
반환한다. 허용되지 않은 Origin은 기존 Spring CORS 정책과 같이 body 계약 없는 403으로 종료한다.
- `SecurityConfig`는 기존 `AiCharacterAdminSecurityErrorHandler`를 global `RequestRejectedHandler`로 등록하되 신규 prefix만 위
400/CORS 계약으로 처리하고, legacy/public은 `DefaultRequestRejectedHandler`에 위임해 기존 `RequestRejectedException` 동작을
유지한다. Spring 5.3의 비표준 method enum 한계 때문에 CORS 검사 request만 `GET` wrapper를 사용하며 실제 firewall method
허용 범위는 확장하지 않고 `setUnsafeAllowAnyHttpMethod(true)`도 사용하지 않는다.
- Phase 1 공통 오류는 인증 정보 없음·잘못됨·만료·폐기 401 `common.error.bad_credentials`, JWT 또는 현재 DB role의
ADMIN 불충족 403 `common.error.access_denied`, request/target 미존재·불변식 위반 400, 신규 prefix 미매핑 경로 404, 지원하지
않는 HTTP method 405, 응답 media type 406, 요청 media type 415를 `common.error.invalid_request`로 고정한다. 405는 표준
`Allow` header를, 415는 표준 `Accept` header를 유지한다. controller mapping의 필수 path variable 선언이 누락된
`MissingPathVariableException`과 예상하지 못한 controller/JWT filter 오류는 500 `common.error.unknown`으로 고정한다.
- malformed JSON의 `HttpMessageNotReadableException`, handler에 전달된 `MethodArgumentNotValidException`, multipart 필수 part
누락의 `MissingServletRequestPartException`은 각각 400 `common.error.invalid_request`와 KO/EN/JA `ApiResponse.error`를 반환한다.
- 이후 phase의 domain/client/server 오류는 각 task에서 정확한 HTTP status와 KO/EN/JA message key를 먼저 정의하고 같은
envelope를 적용한다.
- 신규 prefix 전용 오류 처리는 legacy/public endpoint의 기존 성공·오류 응답에 적용하지 않는다.
- request/response의 기계 검증 가능한 단일 기준은 같은 디렉터리의 `api-contract.openapi.json`이다. 설명과 레거시 근거는
`api-contract.md`에 기록한다.
- 신규 endpoint는 레거시 API의 request/response 필드명, 타입, optional/nullable, 기본값과 성공 `data` 형태를 그대로
이관한다. `characterId`, `contentId`, `seriesId`, `postId`, `fanTalkId`, `replyId`처럼 신규 path로 이동한 ID만
request body에서 중복 제거한다.
- 캐릭터 등록용 원작 검색은 `GET /api/v2/admin/ai-characters/original-works/search?searchTerm=...`, 시리즈 장르 목록은
`GET /api/v2/admin/ai-characters/series-genres`로 제공하며 두 endpoint 모두 `characterId`를 받지 않는다.
- 오디오 콘텐츠 댓글은
`/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments`, 커뮤니티 댓글은
`/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments` 하위에서 각각 댓글 목록, 답글 목록, 작성,
수정, 삭제 5개 operation으로 제공한다. 답글 목록은 `/{commentId}/replies`, 수정·삭제는 `/{commentId}` 하위 path를
사용한다.
- 오디오 댓글 작성 request는 `comment`, optional `parentId`, `isSecret`, `languageCode`, 커뮤니티 댓글 작성 request는
`comment`, optional `parentId`, `isSecret`을 받는다. 수정 request는 두 domain 모두 `comment`만 받으며, 삭제는 request
body를 받지 않는다.
- 두 댓글 domain의 목록과 답글 목록은 `GetAudioContentCommentListResponse` 또는
`GetCommunityPostCommentListResponse`에 해당하는 `totalCount`, `items` 형태를 유지한다. 두 목록은 `timezone`
query를 받지 않고 각 댓글 `date`를 ISO-8601 UTC(`Z`)로 반환한다.
- FanTalk 팬 원글 삭제는
`DELETE /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}`로 제공한다.
- FanTalk 답변 수정은
`PUT /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}`로 제공한다.
- 위 14개 신규 operation을 기존 23개에 추가해 전체 관리자 계약은 37개 operation으로 관리한다.
- `GET /api/v2/admin/ai-characters/{characterId}/series/{seriesId}`의 성공 `data`
`GET /api/v2/admin/ai-characters/{characterId}/series``items` 단일 항목과 동일한 schema를 참조한다.
- 캐릭터 수정은 레거시 `ChatCharacterUpdateRequest`처럼 `isActive=false`와 다른 optional field의 동시 입력을 허용하며,
이 경우 레거시 service 의미대로 비활성화만 반영한다.
- 목록 endpoint의 query와 page 동작은 각 레거시 API를 따른다. FanTalk 관리자 목록만 공개 v2 탭의 `page` 기본값 0,
`size` 기본값 20, 최소 20, 최대 50 보정을 따른다.
- 오디오 콘텐츠 생성 multipart의 `request``timezone`을 포함하지 않는다. nullable `releaseDate`는 클라이언트가
UTC로 변환한 ISO-8601 `date-time`(`Z`)이며 기존 로컬 시각+timezone 형식은 신규 endpoint에서 받지 않는다.
- 오디오 콘텐츠 상세, 오디오 댓글·답글 목록, 커뮤니티 댓글·답글 목록은 `timezone` query를 받지 않는다. 상세
`releaseDate`와 댓글 `date`는 기존 필드명 및 nullable/노출 조건을 유지한 ISO-8601 UTC(`Z`)다.
- 2026-07-29 사용자 확정에 따라 커뮤니티 관리자 목록은 위 레거시 이관 원칙의 예외로 둔다. 사용하지 않는
`timezone` query를 제거하고 `data``totalCount`, `page`, `size`, `hasNext`, `items` pagination wrapper로 반환한다.
- multipart 생성/수정은 기존 admin/creator-admin 관례대로 파일 part와 `request` JSON string part를 사용한다.
- 시리즈 연결 콘텐츠 목록과 미연결 콘텐츠 검색은 응답 형태가 다르므로 각각
`GET .../series/{seriesId}/contents``GET .../series/{seriesId}/contents/search?search_word=...`로 분리한다.
- 레거시 mutation이 `ApiResponse.ok(null)`을 반환하면 신규 endpoint도 `data: null`을 반환한다. 오디오 콘텐츠 생성은
레거시 `CreateAudioContentResponse(contentId)`를 유지한다. FanTalk 답변 작성만 신규 계획의 축약 응답
`fanTalkId`, `replyId`, `creatorMemberId`, `content`, `createdAtUtc`를 사용한다. FanTalk 답변 수정은 레거시
`CreatorChannelFanTalkResponse` 필드 형태를 유지한다.
- 신규 댓글 작성·수정·삭제와 FanTalk 원글 삭제의 성공 응답은 모두 `ApiResponse.ok(null)`을 사용한다.
- request/response 구현 DTO는 신규 v2 AI character admin API 전용으로 둘 수 있지만, JSON 외부 계약은
`api-contract.openapi.json`의 레거시 필드명과 형태를 유지한다.
---
## 9. Technical Constraints
- Kotlin, Java 17, Spring Boot 2.7.14, Gradle Wrapper를 유지한다.
- 신규 dependency를 추가하지 않는다.
- 신규 DB schema/DDL을 만들지 않는다.
- 기존 v2 API 조립 계층과 domain/application 의존 방향을 따른다.
- controller 내부 호출, 서버 내부 legacy HTTP 호출, 기존 controller 역참조는 하지 않는다.
- 신규 v2 application/domain 계층은 기존 controller와 v2 API response DTO를 역참조하지 않는다.
- 기존 business method를 재사용하기 전 특성화/회귀 테스트를 작성한다.
- 특성화/회귀 테스트는 신규 v2 use-case의 미구현 RED 테스트와 분리하고, 기존 legacy/creator-admin 구현을 대상으로 먼저 통과해야 한다.
- 단순 복사-붙여넣기 대신 필요한 최소 추출 또는 v2 use-case 재개발을 선택한다.
---
## 10. Metrics
- 신규 endpoint별 또는 controller slice별 JWT role × 현재 DB role 인가 매트릭스와 stale ADMIN claim 403 테스트 존재 여부
- 신규 prefix의 각 API 오류 분기에 정확한 HTTP status, `ApiResponse.error`, KO/EN/JA와 405 `Allow`/415 `Accept` header 테스트
존재 여부
- 신규 prefix 실제 mapped endpoint 및 공유 인증 경로의 허용·거부 Origin/preflight 테스트 존재 여부
- 신규 prefix의 표준 method 미매핑 405 `Allow` 유지와 `RequestRejectedException` 400/i18n/`ApiResponse.error`/허용 Origin CORS
header, 미허용 Origin body 계약 없는 403 테스트 존재 여부
- legacy/public firewall 동작 불변 및 `setUnsafeAllowAnyHttpMethod(true)` 미사용 확인 여부
- core controller security/error 계약의 production `@SpringBootTest` full-context 실행과 Redis token fixture cleanup 확인 여부
- target resolver 조회 직후 `creatorMember` 초기화와 fetch join 제거 시 실패하는 non-vacuous 회귀 테스트 존재 여부
- `HttpMessageNotReadableException`, `MethodArgumentNotValidException`, `MissingServletRequestPartException`의 exact exception
type과 KO/EN/JA 400 envelope 직접 검증 여부
- target/ownership 실패 시 no-side-effect 테스트 존재 여부
- character/original-work/content/content-comment/series/series-genre/community/community-comment/FanTalk slice별 targeted test
통과 여부
- 오디오 생성 request와 오디오 상세·댓글·답글 및 커뮤니티 댓글·답글 조회에서 `timezone`이 제거되고,
`releaseDate`/`date`가 ISO-8601 UTC(`Z`)로 검증되는지 여부
- 댓글 작성·수정의 target 작성자 제한, 소유 자산 댓글 삭제, parent 소유권·root 검증과 soft-delete no-cascade 테스트 존재 여부
- FanTalk 팬 root 삭제, 비활성 팬 root no-op, creator reply row 보존·비노출 테스트 존재 여부
- FanTalk 답변 수정의 optional/nullable `content`·`isActive`, 빈 객체 no-op, 비활성 reply 재활성화,
target/root/direct-reply ownership과 레거시 성공 응답 테스트 존재 여부
- signed URL TTL 계산식·edge case parity 및 private path 비노출 테스트 통과 여부
- 기존 legacy/public endpoint의 성공·오류 status/body/message 회귀 테스트 통과 여부
---
## 11. Acceptance Criteria
- JWT `ROLE_ADMIN`과 현재 DB `Member.role == ADMIN`을 모두 만족하는 요청만 유효한 AI character 대상으로 신규 endpoint를
호출할 수 있다.
- 비로그인 또는 잘못된 JWT 요청은 401이고, JWT 비ADMIN + DB ADMIN과 JWT ADMIN + DB 비ADMIN stale claim은 모두 403이다.
- 신규 prefix의 API application/controller/security filter 오류는 정확한 비2xx status, `ApiResponse.error`,
`Accept-Language`에 따른 KO/EN/JA message를 반환한다. Spring CORS 계층의 정책 거부 403 body는 이 envelope 계약의 예외다.
- 지원하지 않는 HTTP method는 405와 `Allow` header, 응답 media type은 406, 요청 media type은 415와 `Accept` header를
반환하고, `MissingPathVariableException`은 500 `common.error.unknown`을 반환한다.
- 표준 HTTP method가 MVC에 도달한 뒤 mapping이 없을 때는 기존 405와 `Allow` header를 유지한다. 신규 prefix의 비표준 HTTP
method 또는 위험 URL이 `StrictHttpFirewall`에서 `RequestRejectedException`으로 거부되면 허용된 캐릭터 관리자 Origin에는
CORS header와 현지화된 400 `common.error.invalid_request` `ApiResponse.error`를, 미허용 Origin에는 body 계약 없는 403을
반환한다. legacy/public firewall 동작은 변하지 않고 `setUnsafeAllowAnyHttpMethod(true)`는 사용하지 않는다.
- 신규 prefix는 캐릭터 관리자 Origin만 허용하고, `/admin/member/login`, `/member/logout`는 기존 전역 Origin과 캐릭터 관리자
Origin의 합집합을 허용한다. 실제 mapped endpoint와 공유 인증 경로의 CORS 허용·거부가 테스트로 고정된다.
- core controller security/error 계약은 production `@SpringBootTest` full context에서 검증하고 Redis token fixture를 테스트 후
정리해 다음 테스트에 남기지 않는다.
- target resolver의 repository 조회 결과는 반환 직후 `creatorMember`가 초기화되어 있어야 하며, fetch join 제거 시 실패하는
회귀 테스트로 고정한다.
- malformed JSON, handler에 전달된 `MethodArgumentNotValidException`, multipart 필수 part 누락은 각각 정확한 MVC exception
type과 현지화된 400 `ApiResponse.error` 계약을 만족한다.
- character 미존재, creatorMember 미존재, role 불일치, memberKind 불일치 요청은 4xx이며 아무 side effect도 남기지 않는다.
- 다른 character 소유 resource ID를 사용한 조회/수정/삭제/연결/답변은 4xx로 거부된다.
- 캐릭터 생성/수정/비활성화는 레거시 관리자 behavior parity를 유지한다.
- 캐릭터 등록용 원작 검색은 soft delete된 원작을 제외하고 제목·콘텐츠 타입·카테고리 부분 검색 결과를 반환한다.
- 콘텐츠 생성/수정/soft delete와 signed URL 응답은 기존 creator/admin behavior parity를 유지한다.
- 신규 관리자 오디오 생성은 `timezone` 없이 nullable `releaseDate`를 ISO-8601 UTC(`Z`)로 받고, 상세 조회도
`timezone` 없이 기존 nullable `releaseDate`를 ISO-8601 UTC(`Z`)로 반환한다.
- 오디오 콘텐츠 댓글은 target 소유 활성 콘텐츠 범위에서 조회되고, target 명의 작성·작성자 한정 수정·소유자 moderation
soft delete가 적용된다. 댓글·답글 목록은 `timezone` 없이 각 `date`를 ISO-8601 UTC(`Z`)로 반환한다.
- 시리즈 CRUD, 콘텐츠 연결/해제/순서 변경은 target character 소유 범위를 벗어나지 않는다.
- 시리즈 상세 `data`는 목록 `items` 한 건과 동일한 필드·타입을 반환하고 상세 전용 `genre`, `keywords`를 반환하지 않는다.
- 시리즈 장르 목록은 활성 장르의 `id`, `genre`, `isAdult``orders` 순으로 반환한다.
- 커뮤니티 생성/수정/고정/해제/soft delete는 target creatorMember 소유 게시글에만 적용된다.
- 커뮤니티 목록은 `timezone` 없이 조회되고 active owner 게시글의 전체 개수, 요청 page/size, 다음 페이지 여부와
기존 목록 item 필드를 반환한다.
- 커뮤니티 댓글은 target 소유 활성 게시글 범위에서 조회되고, target 명의 작성·작성자 한정 수정·소유자 moderation soft
delete가 적용된다. 댓글·답글 목록은 `timezone` 없이 각 `date`를 ISO-8601 UTC(`Z`)로 반환한다.
- FanTalk 관리자 목록은 target AI character의 root 글과 creator reply를 공개 v2 응답 필드명으로 반환하며, 공개 v2의
viewer/block 필터나 creator 관리자 CORS 경계에 의존하지 않는다.
- FanTalk 답변은 target AI character 자신의 활성 root FanTalk에만 저장된다.
- FanTalk 답변 수정은 target AI character가 작성하고 해당 target의 활성 root에 직접 연결된 reply에만 적용된다.
optional/nullable `content``isActive`의 레거시 상태 전이 및 빈 객체 no-op을 유지하며, 비활성 reply를 재활성화할 수
있고 성공 `data`는 레거시 `CreatorChannelFanTalkResponse` 필드 형태다.
- FanTalk 원글 삭제는 target 채널의 팬 작성 root만 비활성화하고 이미 비활성이면 성공 no-op이며 연결 creator reply row를 변경하지 않는다.
- 레거시 캐릭터 직접 댓글 API는 신규 v2 endpoint나 구현 계획에 포함되지 않는다.
- 기존 legacy/public endpoint 테스트가 통과하고, 두 공유 인증 경로의 CORS 허용 Origin 확장 외 성공·오류
status/body/message를 포함한 request/response contract가 변경되지 않는다.
- 신규 dependency, 신규 DDL, 관련 없는 리팩터링이 없다.
---
## 12. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 범위 |
|---|---|---|---|---|---|
| 2026-07-29 | `DEC-COMMENT-001` | 확정 | 오디오 콘텐츠·커뮤니티 댓글은 관리자 principal이 아닌 target AI 캐릭터 명의로 작성하고, target 작성 댓글만 내용을 수정하며, target 소유 자산의 댓글은 작성자와 관계없이 soft delete한다. | 사용자 승인과 기존 콘텐츠·게시글 소유자의 댓글 비활성화 동작 | Feature C, Feature E, API Expectations |
| 2026-07-29 | `DEC-CHAR-COMMENT-001` | 제외 | 사용하지 않는 레거시 캐릭터 직접 댓글 API는 v2로 전환하거나 관리자 삭제 기능을 추가하지 않는다. | 사용자 확인 결과 v2 전환 후 미사용 | Non-Goals, Acceptance Criteria |
| 2026-07-29 | `DEC-FANTALK-DELETE-001` | 확정 | 팬 작성 FanTalk root 삭제는 원글만 soft delete하고 연결 creator reply row는 변경하지 않는다. | 사용자 승인과 기존 `CreatorCheers.isActive` 동작 유지 | Feature F, API Expectations |
| 2026-07-29 | `DEC-FANTALK-REPLY-UPDATE-001` | 확정 | FanTalk 답변 수정은 레거시 `PUT /explorer/profile/cheers`의 optional/nullable `content`, `isActive`, 빈 객체 no-op, 비활성 reply 재활성화와 `CreatorChannelFanTalkResponse` 성공 `data`를 유지한다. 신규 path의 `characterId`, root `fanTalkId`, `replyId`로 target AI 소유 direct reply를 한정한다. | 사용자 요청과 “기존 계약과 동일” 확정, 레거시 `ExplorerService.modifyCheers` 동작 | Feature F, API Expectations |
| 2026-07-29 | `DEC-REGISTRATION-REFERENCE-001` | 확정 | 캐릭터 등록용 원작 검색과 시리즈 등록용 장르 목록을 target 없는 신규 v2 관리자 endpoint로 제공한다. | 레거시 API는 캐릭터 관리자 배포 Origin에서 호출할 수 없고 신규 frontend는 v2 경계를 사용해야 함 | Feature B, Feature D, API Expectations |
| 2026-07-29 | `DEC-SERIES-DETAIL-001` | 확정 | 시리즈 상세 `data`를 목록 `items`의 단일 항목과 동일한 schema로 변경하고 기존 상세 전용 `genre`, `keywords`를 제거한다. | 사용자 확정과 관리자 목록·상세 DTO 일관성 | Feature D, API Expectations |
| 2026-07-29 | `DEC-UTC-DATE-001` | 확정 | 신규 관리자 오디오 생성의 `timezone` body와 오디오 상세·오디오 댓글/답글·커뮤니티 댓글/답글 조회의 `timezone` query를 제거한다. 생성 `releaseDate`는 클라이언트가 UTC로 변환해 보내고, 상세 `releaseDate`와 댓글 `date`는 기존 필드명을 유지한 ISO-8601 UTC(`Z`)로 반환한다. | 클라이언트별 timezone 표시 변환을 제거하고 단일 절대 시각 계약을 유지한다는 사용자 승인 | Feature C, Feature E, API Expectations |
---
## 13. Open Questions
- 없음.

View File

@@ -0,0 +1,310 @@
# Phase 1 공통 경계·보안 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 1 / 공통 target resolver, 보안, 오류 경계 |
| 기준 commit 또는 working tree | `2f93e2c9` + 현재 Phase 2~7 working tree |
| 리뷰 일자 | 2026-07-28 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md`, `plan-task.md`, `api-contract.openapi.json` |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
### 목적
- PRD Feature A와 공통 API Expectations가 현재 resolver/security/error 구현에 유지되는지 확인한다.
- Phase 2~6의 모든 신규 controller가 같은 prefix 경계와 target 불변식을 공유하는지 정적으로 추적한다.
### 포함 범위
- `AiCharacterAdminTargetResolver`, `SecurityConfig`, 신규 prefix 오류 handler/writer
- `AiCharacterAdminAuthorizationTest`, `AiCharacterAdminErrorContractTest`, resolver 관련 테스트
- OpenAPI 공통 오류·security 정의와 plan Phase 1 완료 기록
### 제외 범위
- 테스트 재실행, Phase 2~6 domain 세부 동작, legacy/public API 변경
## 3. 판정 기준
| 심각도 | 기준 |
|---|---|
| Blocker | 인증·인가 우회, cross-owner write, 데이터 손실 위험 |
| High | PRD/OpenAPI 공통 보안·오류 계약 위반 |
| Medium | 제한된 경로의 오류·현지화·부작용 계약 누락 |
| Low | 유지보수성 또는 문서 정합성 문제 |
## 4. 검토한 근거
### 문서와 코드
- 요구사항: PRD Feature A, API Expectations, Acceptance Criteria
- 계획: Phase 1 `Task 1.1`~`Task 1.7`과 완료 증거
- 코드: `AiCharacterAdminTargetResolver.kt:17`~`35`
- 코드: `SecurityConfig.kt:118`~`130`, `:202`~`:207`
- 코드: `AiCharacterAdminExceptionHandler.kt:28`~`97`
- 테스트: resolver unit/integration, authorization, error contract 테스트
### 실행한 검증
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|---|---|---|
| 코드·문서 정적 추적 | 성공 | creator role/kind 불변식, JWT authority + 현재 DB ADMIN 이중 인가, prefix 전용 오류 경계 확인 |
| Gradle/컴파일/테스트 | 미실행 | 사용자가 기존 통과 사실을 제공하고 직접 실행하지 말 것을 요청함 |
## 5. 발견 사항 요약
확정 발견 사항 없음.
## 6. 주요 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| target 해석 | 충족 | character와 creatorMember를 함께 조회하고 `CREATOR + AI_CHARACTER`를 검증 |
| ADMIN 이중 인가 | 충족 | `ROLE_ADMIN`, `MemberAdapter`, 현재 DB `Member.role == ADMIN`을 모두 요구 |
| prefix 오류 경계 | 충족 | security/MVC/fallback 오류가 신규 prefix 전용 handler로 연결됨 |
| Phase별 재사용 | 충족 | Character/Content/Series/Community/FanTalk facade가 공통 resolver를 사용 |
| 후속 Task 필요성 | 없음 | 정적 근거에서 신규 확정 finding이 발견되지 않음 |
## 7. plan·goal 전환
확정 finding이 없어 Phase 1 신규 Task나 Goal을 추가하지 않았다. 기존 Phase 1 완료 이력은 유지한다.
## 8. 리뷰 종료 판정
**최종 결론:** Phase 1 추가 수정 없음
**검증 제한:** 이번 판정은 정적 리뷰 결과다. 사용자 요청에 따라 테스트·컴파일을 재실행하지 않았으며 기존 plan의 통과
기록을 실행 증거로 재사용하지 않고 참고만 했다.
## 9. 2차 정적 리뷰 및 판정 — 2026-07-28
### 리뷰 정보와 범위
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
- 기준 문서: PRD Feature A·API Expectations, plan Phase 1, OpenAPI 공통 security/error
- 검토 범위: target resolver, SecurityConfig/WebConfig, JWT와 prefix 전용 security/MVC 오류 handler,
Phase 2~6 facade의 resolver 사용
- 검증 방식: 코드·문서·테스트 정적 추적. 사용자 요청에 따라 컴파일과 테스트는 실행하지 않았다.
### 발견 사항과 판정
확정 발견 사항 없음.
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| target 불변식 | 충족 | `CREATOR + AI_CHARACTER`, creatorMember 연결을 공통 resolver에서 검증 |
| ADMIN 이중 인가 | 충족 | JWT authority와 현재 DB role을 모두 확인 |
| 오류/CORS 경계 | 충족 | 신규 prefix 전용 handler와 허용 Origin 분리 유지 |
| Phase별 적용 | 충족 | Character/AudioContent/Series/Community/FanTalk facade가 resolver 사용 |
| plan 전환 | 해당 없음 | Phase 1 신규 확정 finding 없음 |
**최종 결론:** Phase 1 추가 수정 없음
**남은 항목:** 없음. `REV-030`~`REV-033`의 소유 Phase 보완 뒤 `P7-R2` 통합 재판정에 참여한다.
## 10. 3차 정적 리뷰 및 판정 — 2026-07-28
### 리뷰 범위와 방식
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
- 기준 문서: PRD Feature A·공통 오류, plan Phase 1, OpenAPI security/error
- 검토 범위: target resolver, ADMIN 이중 인가, 신규 prefix 오류·CORS, Phase 2~6 resolver 적용
- 검증 방식: 코드·문서·테스트 정적 추적. 컴파일과 테스트는 실행하지 않았다.
### 발견 사항과 판정
확정 발견 사항 없음.
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| target 불변식 | 충족 | creatorMember fetch와 `CREATOR + AI_CHARACTER` 검증 유지 |
| ADMIN 인가 | 충족 | JWT authority와 현재 DB role 이중 확인 유지 |
| 오류/CORS | 충족 | 신규 prefix 전용 handler와 전용 Origin 정책 유지 |
| Phase 적용 | 충족 | 각 domain facade가 공통 resolver를 통해 target을 해석 |
| plan 전환 | 해당 없음 | Phase 1 신규 Task 불필요 |
**최종 결론:** Phase 1 추가 수정 없음
**남은 항목:** `P7-R3`에서 공통 인가·오류 회귀를 통합 재검증한다.
## 11. 4차 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
- 기준 문서: PRD Feature A·공통 오류, plan Phase 1, OpenAPI 공통 security/error
- 검토 범위: target resolver, ADMIN 이중 인가, 오류·CORS·firewall, 각 domain의 resolver 적용
- 검증 방식: 코드·문서·테스트 정적 추적. 컴파일과 테스트는 실행하지 않았다.
### 발견 사항과 판정
확정 발견 사항 없음.
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| target 불변식 | 충족 | fetch join과 `CREATOR + AI_CHARACTER` 검증 유지 |
| ADMIN 인가 | 충족 | JWT authority와 현재 DB role을 독립 확인 |
| 오류/CORS/firewall | 충족 | prefix 전용 handler와 legacy fallback 유지 |
| Phase 적용 | 충족 | Character~FanTalk facade가 공통 resolver 사용 |
| plan 전환 | 해당 없음 | Phase 1 신규 Task 불필요 |
**최종 결론:** Phase 1 추가 수정 없음
**남은 항목:** 없음.
## 12. 5차 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위
- PRD·OpenAPI 공통 ADMIN 인가, target resolver, 오류·CORS·firewall 계약
- Phase 2~6 facade의 공통 resolver 사용과 JSON mapping 오류 변환 경계
- primitive nullability 보완을 전역 설정이 아닌 각 v2 request 경계에 둘 수 있는지
### 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| target 불변식 | 충족 | `CREATOR + AI_CHARACTER` 검증과 owner 전달 경로 유지 |
| ADMIN 인가 | 충족 | JWT authority와 현재 DB role의 이중 확인 유지 |
| 오류/CORS/firewall | 충족 | prefix 전용 error writer/handler와 허용 origin 정책 유지 |
| primitive finding 소유 | Phase 2~5 | 공통 mapper가 아니라 domain별 수동 `ObjectMapper` reader와 DTO에서 발생 |
| plan 전환 | 해당 없음 | 전역 Jackson·공통 계층 변경 없이 각 Phase Task로 분리 |
사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않고 코드·계약만 정적으로 대조했다.
**최종 결론:** Phase 1 신규 수정 없음
**남은 항목:** 없음.
## 13. 6차 통합 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 문서: PRD 공통 보안·오류·CORS 요구사항, OpenAPI 공통 response/security 계약
- 검토 범위: 신규 prefix의 security matcher, JWT authority와 DB role 이중 인가, target resolver,
공통 exception handler와 CORS 설정
- 기준 상태: 현재 working tree
- 검증 방식: 문서·코드·관련 테스트 정적 대조. 사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다.
### 확인 결과
| 항목 | 판정 | 근거 |
|---|---|---|
| ADMIN 인가 | 충족 | 신규 prefix는 JWT `ROLE_ADMIN`과 현재 principal Member의 DB `ADMIN` role을 모두 확인 |
| target 불변식 | 충족 | `characterId`가 가리키는 creatorMember의 `CREATOR + AI_CHARACTER`를 공통 resolver에서 검증 |
| 오류/CORS | 충족 | prefix 전용 handler/writer와 승인된 Origin 범위 유지 |
| 6차 finding 영향 | 없음 | `REV-052`~`REV-058`은 domain controller의 query/media type 경계에 한정 |
### finding 및 plan 전환
- 신규 Phase 1 finding 없음.
- Phase 1 신규 Task/Gate 없음.
**최종 결론:** Phase 1 공통 보안·resolver·오류 경계 유지
**남은 항목:** 없음.
## 14. 7차 통합 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 문서: PRD 공통 보안·오류·CORS 요구사항, OpenAPI 공통 security/error 계약
- 검토 범위: security matcher, JWT authority/현재 DB role 이중 인가, target resolver, 공통 exception/CORS 경계
- 검증 방식: 현재 working tree의 문서·코드·관련 테스트를 정적으로 대조했다. 사용자 요청에 따라 컴파일과 테스트는
실행하지 않았다.
### 판정
| 항목 | 결과 | 근거 |
|---|---|---|
| ADMIN 이중 인가 | 충족 | JWT `ROLE_ADMIN`과 현재 principal Member의 DB `ADMIN` role을 독립 확인 |
| target 불변식 | 충족 | `characterId` 대상의 `CREATOR + AI_CHARACTER` 검증과 owner 전달 경로 유지 |
| 공통 오류/CORS | 충족 | 신규 prefix 전용 handler와 승인 Origin 정책 유지 |
| 7차 finding 소유 | Phase 2~5·7 | multipart 이름 검증은 domain controller, 구현 상태 문서는 통합 Phase 소유 |
### finding 및 plan 전환
- 신규 Phase 1 finding 없음.
- Phase 1 신규 Task/Gate 없음.
**최종 결론:** Phase 1 추가 수정 없음
**남은 항목:** `P7-R9` 통합 재판정에 공통 오류·인가 회귀 근거로 참여한다.
## 15. 8차 통합 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 문서: PRD 공통 보안·오류·CORS 요구사항, OpenAPI 공통 security/error 계약
- 검토 범위: security matcher, JWT authority/현재 DB role 이중 인가, target resolver, 공통 exception/CORS 경계
- 기준 상태: 현재 working tree
- 리뷰어/상태: Codex / 판정 완료
- 검증 방식: 문서·코드·관련 테스트 소스 정적 대조. 사용자 지시에 따라 컴파일과 테스트는 실행하지 않았다.
### 판정
| 항목 | 결과 | 근거 |
|---|---|---|
| ADMIN 이중 인가 | 충족 | JWT `ROLE_ADMIN`과 현재 DB `Member.role == ADMIN`을 독립 확인하는 경계 유지 |
| target 불변식 | 충족 | `CREATOR + AI_CHARACTER` 검증과 owner 전달 경로 유지 |
| 공통 오류/CORS | 충족 | 신규 prefix 전용 오류 envelope/i18n과 승인 Origin 정책 유지 |
| 8차 finding 소유 | Phase 2~7 | multipart 전체 part, Series 장르 ID, FanTalk 설명, 문서 상태 문제로 공통 경계 변경 불필요 |
### finding 및 plan 전환
- 신규 Phase 1 finding 없음.
- Phase 1 신규 Task/Gate 없음.
**최종 결론:** Phase 1은 요구사항과 일치하며 추가 수정이 없다.
**남은 항목:** Phase 2~7 후속 Goal 완료 뒤 `P7-R10` 통합 재판정에 공통 오류·인가 근거로 참여한다.
## 16. 9차 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 문서: PRD 공통 인가·target resolver·오류·CORS 요구사항, OpenAPI 공통 security
- 검토 범위: security matcher, JWT/현재 DB role 이중 인가, target resolver, 신규 prefix 오류·CORS 경계
- 검증 방식: 현재 working tree의 문서·production·test 소스를 정적으로 대조했다. 사용자 지시에 따라 컴파일과
테스트는 실행하지 않았다.
### 판정
| 항목 | 결과 | 근거 |
|---|---|---|
| ADMIN 이중 인가 | 충족 | JWT `ROLE_ADMIN`과 현재 DB `Member.role == ADMIN` 검증 경계 유지 |
| target 불변식 | 충족 | `CREATOR + AI_CHARACTER` 검증과 creator member 해석 경로 유지 |
| 오류·CORS | 충족 | 신규 prefix 전용 오류 envelope/i18n과 승인 Origin 경계 유지 |
| 신규 finding | 없음 | Phase 3 preview 검증 회귀는 공통 security/target 경계 변경 없이 소유 Phase에서 수정 가능 |
### plan 전환
- 신규 Phase 1 Task/Gate 없음.
**최종 결론:** Phase 1 추가 수정 없음.
**남은 항목:** Phase 3 보완 뒤 `P7-R11` 통합 재판정.
## 17. 10차 정적 리뷰 및 판정 — 2026-07-30
### 리뷰 범위와 방식
- 기준 문서: PRD Feature A, OpenAPI 공통 security/error 계약
- 검토 범위: 신규 prefix security matcher, JWT authority/현재 DB role 이중 인가, target resolver, 오류·CORS 경계
- 검증 방식: 현재 working tree의 문서·production·test 소스를 정적으로 대조했다. 사용자 지시에 따라 컴파일과
테스트는 실행하지 않았다.
### 판정
- JWT `ROLE_ADMIN`과 현재 DB `Member.role == ADMIN`의 이중 인가가 신규 prefix보다 먼저 적용된다.
- target resolver는 `ChatCharacter.creatorMember``CREATOR + AI_CHARACTER` 불변식을 유지한다.
- prefix 전용 오류 envelope/i18n, 405 `Allow`, 415 `Accept`, 승인 Origin과 공유 로그인·로그아웃 CORS 경계가 유지된다.
- 신규 확정 finding이 없어 Phase 1 회귀 수정 Task/Gate를 추가하지 않는다.
**최종 결론:** Phase 1 요구사항 충족, 추가 수정 없음.
**남은 항목:** 없음.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,605 @@
# Phase 4 시리즈 관리 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 4 / 시리즈 9개 operation |
| 기준 commit 또는 working tree | `2f93e2c9` + 현재 Phase 4~7 working tree |
| 리뷰 일자 | 2026-07-28 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md`, `plan-task.md`, `api-contract.openapi.json` |
| 리뷰 상태 | 후속 수정 및 Gate 완료 |
## 2. 리뷰 목적과 범위
### 목적
- PRD Feature D와 OpenAPI Series 9개 operation을 controller/facade/repository/test에 직접 대조한다.
- 공개 route 수, soft delete 방식, owner 검증과 JSON request schema 경계를 점검한다.
### 포함 범위
- `src/main/kotlin/.../aicharacter/series/*`
- `src/test/kotlin/.../aicharacter/series/*`
- OpenAPI Series path/schema와 plan Phase 4
### 제외 범위
- production 수정, legacy series API 변경, 테스트 실행
## 3. 판정 기준
| 심각도 | 기준 |
|---|---|
| Blocker | 보안·소유권 우회 또는 데이터 손실 |
| High | OpenAPI operation/path 위반 또는 주요 사용자 흐름 회귀 |
| Medium | request schema·오류 계약의 제한된 위반 |
| Low | 유지보수성 또는 문서 정합성 문제 |
## 4. 검토한 근거
| 근거 | 판정 |
|---|---|
| OpenAPI `:247`~`:389` | Series는 9개 operation이며 `/series/{seriesId}`에는 GET/PUT만 존재 |
| `AiCharacterAdminSeriesController.kt:23`~`:112` | 10개 route를 노출하며 마지막 DELETE가 계약에 없음 |
| `AiCharacterAdminSeriesFacade.kt:166`~`:187` | 별도 DELETE facade가 PUT과 같은 `isActive=false`를 구성 |
| `AiCharacterAdminSeriesMutationTest.kt:201` 이후 | 계약 밖 DELETE 성공·거부 동작을 테스트가 고정 |
| OpenAPI `:1099`~`:1109` | 콘텐츠 추가와 순서 변경 schema는 `additionalProperties: false` |
| controller `:55`~`:82` | 두 request를 기본 `@RequestBody` DTO binding으로 수신 |
| strict reader 검색 | multipart create/update에만 `FAIL_ON_UNKNOWN_PROPERTIES`가 있고 두 JSON body에는 없음 |
### 실행한 검증
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|---|---|---|
| route/schema/호출 정적 대조 | 성공 | OpenAPI 9개 대비 controller 10개, strict JSON 경계 누락 확인 |
| Gradle/컴파일/테스트 | 미실행 | 사용자 요청에 따라 실행하지 않음 |
## 5. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-023` | High | 처리 완료 | OpenAPI에 없는 시리즈 DELETE route 노출 | `Task 4.7` | `P4-R1` |
| `REV-024` | Medium | 처리 완료 | 콘텐츠 추가·순서 변경이 미지 JSON 필드를 허용 | `Task 4.7` | `P4-R1` |
## 6. 발견 사항 상세
### REV-023 — OpenAPI에 없는 시리즈 DELETE route
- **심각도:** High
- **상태:** 처리 완료
- **관련 요구사항:** PRD Feature D, 공개 API schema 임의 변경 금지
- **관련 계약:** `/series/{seriesId}`는 GET과 PUT 수정/soft delete만 제공
- **소유 Task:** `Task 4.7`, `P4-R1`
**관찰 내용**
controller는 `DELETE /series/{seriesId}`를 추가로 노출한다. 같은 soft delete는 계약에 있는 PUT의 `isActive=false`로 이미
표현되므로 별도 route는 중복이자 공개 API 표면 확장이다.
**영향**
서버와 OpenAPI 기반 client가 서로 다른 operation 집합을 사용한다. 현재 테스트도 계약 밖 route를 정상 동작으로 고정해
향후 차이를 지속시킨다.
**권장 조치**
DELETE controller/facade를 제거하고 기존 DELETE 성공 기대를 405 계약으로 교정하며 PUT `isActive=false` actual endpoint
테스트를 보강한다.
**처리 결과**
`DELETE /series/{seriesId}` controller/facade 경로를 제거했고, actual endpoint 테스트를 405와 `PUT isActive=false` soft delete
계약으로 교정했다.
### REV-024 — 두 JSON body의 `additionalProperties: false` 미적용
- **심각도:** Medium
- **상태:** 처리 완료
- **관련 요구사항:** OpenAPI request schema 준수
- **관련 계약:** `SeriesContentAddRequest`, `SeriesOrderUpdateRequest`
- **소유 Task:** `Task 4.7`, `P4-R1`
**관찰 내용**
multipart create/update는 facade strict reader를 사용하지만 콘텐츠 추가와 순서 변경은 기본 `@RequestBody` binding을
사용한다. repository 전역 설정에는 unknown property 실패 설정이 없어 두 schema의 계약 밖 필드를 무시하고 요청을 처리한다.
현재 malformed JSON 테스트는 문법 오류만 확인하고 미지 필드를 확인하지 않는다.
**영향**
잘못된 client field가 성공으로 처리되어 계약 오류를 조기에 발견할 수 없고 mutation side effect까지 진행될 수 있다.
**권장 조치**
두 body만 strict parse하고 미지 필드 요청의 400 `common.error.invalid_request`와 DB/event 0회를 actual endpoint로
고정한다. 전역 ObjectMapper 설정은 legacy/public 영향이 있으므로 변경하지 않는다.
**처리 결과**
콘텐츠 추가와 순서 변경 body를 facade strict reader로 역직렬화하도록 변경했고, 미지 필드 요청의 400/no-side-effect 테스트를
추가했다.
## 7. plan·goal 전환
`plan-task.md` Phase 4의 `Task 4.7` / `P4-R1``P4-R1-GATE`를 완료 처리했다. 기존 `P4-GATE` 완료 이력은 유지한다.
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| OpenAPI operation 대조 | 충족 | Series controller mapping 9개, 계약 밖 `DELETE /series/{seriesId}` 제거 |
| JSON schema 대조 | 충족 | content add/order body strict parse와 unknown-field 400 회귀 통과 |
| ownership/soft delete 핵심 추적 | 충족 | facade owner 검증과 PUT soft delete 경로 존재 |
| 실행 검증 | 충족 | focused 테스트, series/common 회귀, `ktlintCheck`, `git diff --check` 통과 |
**최종 결론:** 후속 수정 및 Gate 완료
**남은 항목:** 없음. 다음 Goal은 `P5-R1`이다.
## 9. 2차 정적 리뷰 및 판정 — 2026-07-28
### 리뷰 정보와 검증 범위
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
- 기준 문서: PRD Feature D·API Expectations, plan Phase 4, OpenAPI Series 9개 operation
- 검토 범위: 시리즈 생성 multipart binding, 콘텐츠 연결/해제 facade·repository·legacy service와 관련 테스트
- 검증 방식: controller → facade → repository/legacy service 상태 전이를 정적으로 추적했다.
사용자 요청에 따라 컴파일과 테스트는 실행하지 않았다.
### 추가 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-031` | Medium | 처리 완료 | soft-delete된 linked content를 시리즈에서 해제할 수 없음 | `Task 4.8` | `P4-R2` |
| `REV-032` | Medium | 처리 완료 | 생성 필수 `image`가 nullable binding으로 공통 오류 계약 우회 | `Task 4.8` | `P4-R2` |
### REV-031 — soft-delete 콘텐츠의 기존 link 해제 차단
- **심각도:** Medium
- **상태:** 처리 완료
- **관련 요구사항:** PRD Feature D의 콘텐츠 연결/해제와 동일 owner 검증
- **관련 계약:** 존재하는 owner link 해제 성공, 없는/cross-owner link만 400
- **소유 Task:** `Task 4.8`, `P4-R2`
**관찰 내용**
해제 facade가 실제 link 존재 여부뿐 아니라 연결 추가/미연결 검색용 적격성 method를 호출한다. 이 method는
`duration != null && (isActive || releaseDate != null)`을 요구하므로, 정상 연결 후 콘텐츠가 soft delete되어
`isActive=false`, `releaseDate=null`이 되면 link가 존재해도 해제를 400으로 거부한다.
**근거**
- 코드: `AiCharacterAdminSeriesFacade.kt:109`~`:120`은 해제 전에 `findEligibleContentByIdAndCreatorMemberId`를 요구한다.
- 코드: `AiCharacterAdminSeriesRepository.kt:28`~`:30`은 inactive/unreleased 콘텐츠를 제외한다.
- 코드: legacy `CreatorAdminContentSeriesService.kt:297`~`:303`은 owner series의 link를 content 상태와 무관하게 제거한다.
- 테스트: `AiCharacterAdminSeriesContentTest`는 정상 active link 해제와 inactive 콘텐츠 연결 거부를 분리 검증하지만,
이미 연결된 콘텐츠를 soft delete한 뒤 해제하는 상태 전이는 없다.
**정적 재현 절차**
1. owner의 active content를 active series에 연결한다.
2. content를 soft delete해 `isActive=false`, `releaseDate=null`로 만든다.
3. 같은 owner/series/content로 DELETE unlink를 요청한다.
4. 실제 link는 남아 있지만 적격성 guard가 null을 반환해 400이 된다.
**영향**
관리자는 삭제된 콘텐츠의 시리즈 연결을 정리할 수 없고 orphan link가 남는다. 콘텐츠 연결 추가 적격성과 기존 link 해제
조건이 불필요하게 결합된 문제다.
**권장 조치**
해제는 series에 존재하는 link와 link 콘텐츠의 owner만 검증한다. `findEligibleContentByIdAndCreatorMemberId`는 연결 추가에만
유지하고 soft-delete link 해제 회귀를 추가한다.
**처리 결과**
해제 경로에서 연결 추가·검색용 active/release/duration 적격성 guard를 제거하고, 실제 series link와 link 콘텐츠의 owner만
검증하도록 변경했다. soft-delete된 owner content의 기존 link 해제 성공 회귀를 추가했다.
### REV-032 — 시리즈 생성 필수 image의 nullable binding
- **심각도:** Medium
- **상태:** 처리 완료
- **관련 요구사항:** PRD API Expectations의 필수 multipart 누락 오류
- **관련 계약:** OpenAPI `SeriesCreateMultipart.required = ["image", "request"]`
- **소유 Task:** `Task 4.8`, `P4-R2`
**관찰 내용**
OpenAPI는 생성 `image`를 필수로 선언하지만 controller와 facade는 nullable로 받아 누락 요청을 legacy service까지
전달한다. 결과는 400이지만 exact `MissingServletRequestPartException``common.error.invalid_request` 대신
legacy `creator.admin.series.cover_image_required`가 된다. plan의 Phase 4 오류 표가 이 legacy 결과를 기록해 PRD 공통 계약과
충돌한다.
**근거**
- 계약: OpenAPI `SeriesCreateMultipart`의 required `image`.
- 요구사항: PRD `:183`~`:184`는 필수 part 누락을 exact `MissingServletRequestPartException`,
400 `common.error.invalid_request`로 고정한다.
- 코드: `AiCharacterAdminSeriesController.kt:83`~`:90``required=false`, nullable image를 사용한다.
- 코드: facade create와 legacy service는 null을 받아 domain key로 변환한다.
- 테스트: `AiCharacterAdminSeriesMutationTest`는 missing image가 facade/legacy까지 도달하는 동작을 기대한다.
**영향**
클라이언트는 같은 신규 prefix의 다른 필수 multipart 누락과 다른 message/exception 계약을 받는다. 상태는 400으로 같고
mutation은 시작되지 않으므로 영향은 오류 표면에 제한된다.
**권장 조치**
PRD/OpenAPI를 우선해 생성 image만 non-null binding으로 변경하고 세 locale의 exact exception/envelope과
facade/DB/S3/event 0회를 검증한다. update image의 optional 계약은 유지한다.
**처리 결과**
시리즈 생성 `image` part를 non-null `MultipartFile` binding으로 변경해 누락 요청을 `MissingServletRequestPartException`,
400 `common.error.invalid_request`로 통일했다. KO/EN/JA envelope과 no-side-effect를 actual endpoint로 고정했고 update의 optional
`image` 계약은 유지했다.
**판정 기록**
- 2026-07-28 — `REV-031`, `REV-032` 모두 코드·문서·테스트 정적 추적으로 확정. 테스트는 미실행.
### plan·goal 전환
`plan-task.md` Phase 4에 `Task 4.8` / `P4-R2``P4-R2-GATE`를 추가했다.
`DEC-P4-R2-001`로 생성 image 누락의 canonical 오류를 확정했으며 기존 완료 이력은 유지한다.
`P4-R2``P4-R2-GATE`를 완료 처리했다.
### 2차 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| operation/path 대조 | 충족 | Series 9개 mapping 유지 |
| 상태 전이 대조 | 충족 | soft-delete linked content unlink 성공 회귀 통과 |
| multipart binding | 충족 | required image 누락이 `MissingServletRequestPartException` 400으로 처리됨 |
| plan 반영 | 충족 | `Task 4.8`, `P4-R2`, `P4-R2-GATE` |
| 실행 검증 | 충족 | focused 테스트, series/common 회귀, `ktlintCheck`, `git diff --check` 통과 |
**최종 결론:** 후속 수정 및 Gate 완료
**남은 항목:** 없음. 다음 Goal은 `P5-R2`다.
## 10. 3차 정적 리뷰 및 판정 — 2026-07-28
### 리뷰 정보와 범위
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
- 기준 문서: PRD Feature D, plan Phase 4, OpenAPI Series 9개 operation
- 검토 범위: 생성·수정 multipart facade, legacy series service의 S3 upload, mutation 테스트
- 검증 방식: 코드·문서·테스트 정적 추적. 컴파일과 테스트는 실행하지 않았다.
### 발견 사항
#### `REV-037` — Medium — 시리즈 빈 image의 0-byte upload
- 생성 controller의 non-null binding은 part 누락만 차단하며 `MultipartFile.isEmpty`는 검증하지 않는다.
- facade는 생성 image를 그대로 legacy service로 넘기고, legacy service는 size 0 metadata와 input stream을 S3에
업로드한 뒤 cover를 교체한다.
- 수정의 optional 빈 image도 null이 아니므로 같은 0-byte upload와 기존 cover 교체가 발생한다.
- 현재 테스트는 생성 image 누락과 정상 image 경로를 다루지만 생성·수정의 빈 part는 다루지 않는다.
**권장 조치:** 생성 빈 image는 legacy 호출 전에 400으로 거부하고, 수정 빈 image는 optional part 생략으로 정규화한다.
정상 upload, 수정 image 생략, 빈 image no-S3/no-cover-change를 actual endpoint로 고정한다.
### plan·goal 전환
`plan-task.md` Phase 4에 `Task 4.9` / `P4-R3``P4-R3-GATE`를 추가했다.
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| operation/path | 충족 | Series 9개 mapping 유지 |
| 생성 empty file | 수정 필요 | 0-byte upload와 DB/event 부작용 발생 |
| 수정 empty file | 수정 필요 | 기존 cover가 0-byte 객체로 교체됨 |
| plan 반영 | 충족 | `Task 4.9`, `P4-R3`, `P4-R3-GATE` 추가 |
| 실행 검증 | 미실행 | 사용자 요청에 따라 컴파일·테스트 미실행 |
**최종 결론:** Phase 4 후속 수정 필요
**남은 항목:** `P3-R11-GATE``P4-R3``P4-R3-GATE`.
## 11. 3차 후속 수정 및 Gate — 2026-07-29
- 무엇을: `REV-037`을 처리했다.
- 왜: 생성·수정의 빈 `image` part가 legacy service로 전달되어 0-byte S3 upload와 cover 교체를 유발했기 때문이다.
- 어떻게:
- RED: `AiCharacterAdminSeriesMutationTest`에 빈 생성 image 400/no-side-effect, 빈 수정 image cover 유지, 빈 image-only no_changes 유지 테스트를 추가했다. focused 실행에서 신규 3건이 실패했다.
- GREEN: `AiCharacterAdminSeriesFacade.create`는 empty image를 400으로 거부하고, `update`는 empty image를 null로 정규화해 legacy service에 전달했다.
- 검증: focused series mutation test, targeted aicharacter 회귀, 전체 `./gradlew test`, `ktlintCheck`, OpenAPI/mapping/diff 점검을 실행했다.
- 결과: `REV-037` 처리 완료. 생성 empty image는 부작용 전에 400이고, 수정 empty image는 생략으로 처리해 기존 cover를 유지한다.
**최종 결론:** Phase 4 3차 리뷰 종결
**남은 항목:** 없음.
## 12. 4차 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
- 기준 문서: PRD Feature D, plan Phase 4, OpenAPI Series 9개 operation
- 검토 범위: CRUD, 콘텐츠 조회·검색·연결·해제, owner-scoped 순서 변경, 최신 empty image 보완
- 검증 방식: 코드·schema·테스트 정적 대조. 컴파일과 테스트는 실행하지 않았다.
### 발견 사항과 판정
Series runtime의 신규 확정 finding은 없다. 9개 controller mapping, owner-scoped lock·연결/해제와
생성·수정 empty image 정책은 현재 OpenAPI와 일치한다.
다만 plan Phase 4 endpoint 설명은 콘텐츠 해제를 `DELETE /series/{seriesId}/contents`와 request body로 적고 있어,
OpenAPI와 실제 `DELETE /series/{seriesId}/contents/{contentId}` body 없음 route와 달랐다. 이를 `REV-039`로 확정했고,
Phase 7 `Task 7.6` / `P7-R4`에서 설명을 정정했다. `Task 4.9` 헤더의 미완료 표기도 `REV-038`로 함께 동기화했다.
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| runtime operation | 충족 | Series controller mapping 9개와 OpenAPI 일치 |
| ownership/순서 | 충족 | active owner ID 전체 검증과 ID 정렬 lock 유지 |
| 연결/해제 | 충족 | owner link 검증 후 path `contentId`로 해제 |
| 문서 계약 | 충족 | `REV-039`의 stale DELETE path/body 설명 정정 |
| plan 전환 | 충족 | `Task 7.6`, `P7-R4` 완료 |
**최종 결론:** Phase 4 기능 추가 수정 없음, Phase 7 문서 보완 완료
**남은 항목:** 없음.
## 13. 5차 정적 리뷰 및 판정 — 2026-07-29
### 확인된 문제
#### `REV-042` — 시리즈 생성 primitive의 explicit null 허용 가능성
- **심각도:** Medium
- **상태:** 처리 완료
- **계약:** OpenAPI `SeriesCreateRequest``genreId`, `isAdult`은 nullable이 아니며 각각 생략 기본값 `0`,
`false`를 가진다.
- **구현:** `CreateSeriesRequest`의 두 값은 Kotlin primitive이고 facade strict reader는 미지 필드만 거부한다.
- **근거:** Jackson Kotlin/databind 2.13.5 기본 설정에서 explicit null은 primitive 기본값으로 보정될 수 있다.
- **영향:** 특히 `isAdult: null`이 계약상 400 대신 `false`인 정상 생성으로 이어져 S3·DB·event mutation이 발생할 수 있다.
### 보완 결과
| 항목 | 판정 |
|---|---|
| 신규 Task | `Task 4.10` / `P4-R4` 처리 완료 |
| 시작 조건 | `P3-R12-GATE` 완료 후 실행 |
| Gate | `P4-R4-GATE` 완료 |
| RED | `genreId: null`, `isAdult: null` actual POST가 보완 전 400 기대 실패 |
| GREEN | v2 생성 strict reader에서 explicit null 거부, 필드 생략 기본값 유지 |
| 범위 제한 | 전역 mapper·레거시 service·OpenAPI 변경 없음 |
### 실행 검증
| 명령 또는 검증 | 결과 | 핵심 증거 |
|---|---|---|
| `AiCharacterAdminSeriesMutationTest.shouldRejectNullPrimitiveCreateFieldsBeforeSideEffects` RED | 실패 확인 | 신규 2개 invocation이 400 기대 실패 |
| 같은 focused RED/GREEN 명령 | 통과 | strict reader 보완 후 `BUILD SUCCESSFUL` |
| `AiCharacterAdminSeriesMutationTest` | 통과 | 기존 생성 기본값·정상 mutation 회귀 유지 |
| series/common 영향 범위 회귀 | 통과 | `BUILD SUCCESSFUL in 1m 12s` |
| `ktlintCheck`, `git diff --check` | 통과 | `ktlintCheck``BUILD SUCCESSFUL in 20s`, diff check 출력 없음 |
**최종 결론:** Phase 4 `REV-042` 보완 완료
**다음 Goal:** `P5-R3`.
## 14. 장르 참조 API·상세 응답 후속 검토 — 2026-07-29
### 확인 결과
- **`REV-046` / Medium / 구현 대기:** 시리즈 등록용 활성 장르 목록은 레거시 관리자 service/repository에 존재하지만
신규 v2 캐릭터 관리자 route에는 없다. 계약은 `orders` 오름차순의 `id`, `genre`, `isAdult` 직접 배열이다.
- **`REV-047` / Medium / 정합화 대기:** 현재 시리즈 상세는 레거시 상세 DTO를 반환해 목록 item과
`publishedDaysOfWeek`, `genreId`, `state`, `isActive` 필드·타입이 다르다.
- 승인된 상세 `data`는 목록 `items` 단일 객체와 동일한 11개 필드이며 기존 상세 전용 `genre`, `keywords`는 제거한다.
### plan 전환
- 장르 목록: `Task 4.11` / `P4-R5`, Gate `P4-R5-GATE`
- 상세 정합화: `Task 4.12` / `P4-R6`, Gate `P4-R6-GATE`
- 범위 밖: 장르 CRUD, 목록 wrapper 변경, legacy/public 상세 DTO 변경
사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다.
**최종 결론:** Phase 4 장르 목록 구현과 시리즈 상세 정합화 필요
**다음 Goal:** `P4-R5`.
## 15. 장르 참조 API 구현 및 Gate — 2026-07-29
- 무엇을: `REV-046`을 처리했다.
- 왜: 시리즈 등록 화면에서 사용할 활성 장르 목록이 레거시 관리자 API에는 있으나 신규 v2 캐릭터 관리자 route에는 없었기 때문이다.
- 어떻게:
- RED: `AiCharacterAdminSeriesGenreTest`에 활성/비활성 장르, `orders` 오름차순, 빈 목록, ADMIN 공통 경계 actual GET 테스트 2건을 추가했고 미구현 route로 실패했다.
- GREEN: `AiCharacterAdminSeriesReferenceController``GET /api/v2/admin/ai-characters/series-genres`를 추가하고 facade에서 기존 `AdminContentSeriesGenreService.getSeriesGenreList()`를 그대로 재사용했다.
- Gate: focused 장르 테스트, series/common 영향 범위 회귀, `ktlintCheck`, `git diff --check`를 fresh 실행했다.
- 결과: `REV-046` 처리 완료. 별도 query, DTO, pagination, 장르 CRUD 변경은 추가하지 않았다.
**최종 결론:** Phase 4 장르 목록 후속 기능 종결
**다음 Goal:** `P4-R6`.
## 16. 시리즈 상세 정합화 및 Gate — 2026-07-29
- 무엇을: `REV-047`을 처리했다.
- 왜: 시리즈 상세가 레거시 상세 DTO를 반환해 목록 item과 field/type이 달랐기 때문이다.
- 어떻게:
- RED: `AiCharacterAdminSeriesQueryTest`에서 상세 `data`의 11개 목록 item field와 `genre`, `keywords` 부재를 고정했고 레거시 상세 응답 차이로 실패했다.
- GREEN: v2 상세 response type을 `GetCreatorAdminContentSeriesListItem`으로 통일하고 facade에서 owner-scoped series를 동일 필드로 매핑했다.
- Gate: focused query/contract, series/common 영향 범위 회귀, `ktlintCheck`, `git diff --check`를 fresh 실행한다.
- 결과: `REV-047` 처리 완료. legacy/public 상세 DTO와 mapper는 변경하지 않았다.
**최종 결론:** Phase 4 시리즈 상세 정합화 종결
**다음 Goal:** `P5-R5`.
## 17. 6차 통합 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 문서: PRD Feature D, OpenAPI Series 10개 operation
- 검토 범위: Series/reference controller, facade/repository, JSON·multipart mapping과 목록·상세 DTO
- 기준 상태: 현재 working tree
- 검증 방식: 문서·코드·관련 테스트 정적 대조. 사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다.
### 확인 결과
| 항목 | 판정 | 근거 |
|---|---|---|
| route/operation | 충족 | 장르 참조를 포함한 Series 10개 OpenAPI operation과 실제 mapping 유지 |
| request 전체 media type | 충족 | 생성·수정 multipart, 순서 변경·콘텐츠 추가 JSON `consumes` 선언 일치 |
| query/default | 충족 | 목록·연결 콘텐츠 page/size 기본값과 미연결 검색 필수 query 일치 |
| response/ownership | 충족 | 상세-목록 item schema 정합화와 target owner 경계 유지 |
### `REV-057` — High — 처리 완료 — Series multipart request part의 JSON media type 미강제
- OpenAPI와 계약 설명은 생성·수정 multipart의 `request` part Content-Type을 `application/json`으로 고정한다.
- 두 controller는 `@RequestPart("request") request: String`으로 받아 part 자체의 media type을 검사하지 않는다.
- 정상 테스트는 JSON media type만 사용하며 미지원/누락 part media type의 415 `Accept` header와
S3/DB/event no-side-effect를 고정하지 않는다.
- 같은 shared converter와 signature에서 AudioContent의 `text/plain` 성공 테스트가 있어 permissive binding을
정적으로 확인할 수 있다.
### 처리 결과
- `AiCharacterAdminSeriesController`의 POST·PUT에 `MultipartHttpServletRequest`를 받고 Character·AudioContent와
같은 part header 검사와 `HttpMediaTypeNotSupportedException`을 적용했다. 기존 String strict reader와
facade/domain 로직은 변경하지 않았다.
- `AiCharacterAdminSeriesMutationTest`는 POST·PUT 각각의 `text/plain`·Content-Type 누락 request part를 KO/EN/JA
415 `ApiResponse.error`, `Accept: application/json`, S3/DB/event no-side-effect로 고정했고, 필수 `request` part
누락의 기존 400 binding 오류도 확인했다.
- RED는 신규 media type 12건이 415 기대와 달리 실패했고, GREEN focused 36건과 Series/common error 영향 범위 회귀,
`ktlintCheck`, OpenAPI encoding 정적 대조, `git diff --check`를 통과했다.
**최종 결론:** `REV-057`, `P4-R7`, `P4-R7-GATE` 처리 완료
**다음 Goal:** `P5-R7` (`P4-R7-GATE` 완료 후).
## 18. 7차 통합 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 문서: PRD Feature D, OpenAPI `SeriesCreateMultipart`·`SeriesUpdateMultipart`
- 검토 범위: Series POST·PUT controller의 multipart binding과 image/genre/owner mutation 테스트
- 검증 방식: 현재 working tree의 문서·코드·테스트를 정적으로 대조했다. 사용자 요청에 따라 컴파일과 테스트는
실행하지 않았다.
### `REV-062` — Medium — `image`, `request` 외 multipart part가 무시됨
- 두 Series multipart schema는 `additionalProperties: false`이며 허용 이름은 `image`, `request`다.
- controller의 `requireJsonRequestPart()``request` media type만 검사하고 전체 part 이름을 열거하지 않는다.
- 정상 part와 미정의 part를 함께 보낸 요청이 facade로 전달될 수 있어 OpenAPI 입력 범위보다 runtime이 넓다.
### plan 전환
| 항목 | 내용 |
|---|---|
| 신규 Task | `Task 4.14` / `P4-R8` |
| Gate | `P4-R8-GATE` |
| RED | POST·PUT 미정의 part와 S3·DB·event 결과 |
| GREEN | 실제 part 이름을 `{image, request}`와 비교해 초과 이름 400 |
| 회귀 | 필수/빈 image, request part 415, genre/owner 경계 |
**처리 결과 (2026-07-29 / P4-R8):**
- Series POST·PUT controller 경계에 multipart part allow-list를 추가해 `image`, `request` 외 file part를 400 `common.error.invalid_request`로 거부했다.
- RED에서 POST·PUT `unexpected` part KO/EN/JA 테스트 6개가 기존 mutation 경로로 실패함을 확인했고, GREEN 후 focused/영향 범위 회귀, `ktlintCheck`, `git diff --check`를 통과했다.
**Gate 결과 (2026-07-29 / P4-R8-GATE):**
- Focused multipart 회귀, Phase 4 Series 영향 범위 회귀, `ktlintCheck`, `git diff --check`를 fresh 실행했다.
- POST·PUT 미정의 part 400/no-side-effect, 정상·필수/빈 image, request part 415, genre/owner 경계가 유지됨을 확인했다.
**최종 결론:** `REV-062` resolved. Phase 4 완료.
**다음 Goal:** `P5-R9`.
## 19. 8차 통합 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 문서: PRD Feature D, OpenAPI Series create/update multipart 및 장르 계약
- 검토 범위: Series controller/facade, active genre 조회 경계, legacy genre repository와 mutation 테스트
- 기준 상태: 현재 working tree
- 리뷰어/상태: Codex / 판정 완료
- 검증 방식: 문서·코드·테스트 소스와 기존 compile output을 정적으로 대조했다. 사용자 지시에 따라 컴파일과 테스트는
실행하지 않았다.
### `REV-067` — Medium — 일반 form-field multipart part가 allow-list 우회
- `AiCharacterAdminSeriesController.kt:114-117``fileMap.keys``{image, request}`와 비교한다.
- 기존 `AiCharacterAdminSeriesMutationTest.kt:664-710`은 filename이 있는 `MockMultipartFile("unexpected", ...)`
검증하므로 filename 없는 일반 form-field part 경계는 빠져 있다.
- OpenAPI의 create/update multipart `additionalProperties: false`를 우회할 수 있어 Medium으로 확정한다.
### `REV-068` — Medium — `genreId <= 0`이 active genre 검사를 우회
- `AiCharacterAdminSeriesFacade.kt:155-168`은 create와 non-null update `genreId`에 공통 검사를 호출하지만,
`:213-215`의 구현은 양수인 경우에만 active genre 존재를 확인한다.
- OpenAPI create schema는 0이 domain validation에서 유효하지 않다고 명시한다. 그러나 0 또는 음수는 legacy service로
전달되고, `CreatorAdminContentSeriesGenreRepository.kt:19-26`의 QueryDSL `fetchFirst()` 결과를 Kotlin non-null
반환으로 취급하는 repository 경계에서 NPE가 발생할 수 있다.
- 기존 compile output의 해당 repository bytecode를 `javap`로 확인한 결과 `fetchFirst()` 뒤 Kotlin
`checkNotNullExpressionValue`가 존재했다. 공통 예상 밖 예외는 500 경로이므로 요청 오류 400 계약과 다르다.
### plan 전환
| finding | 신규 Task / Goal | Gate | 최소 보완 |
|---|---|---|---|
| `REV-067` | `Task 4.15` / `P4-R9` | `P4-R9-GATE` | servlet 전체 part 이름을 `{image, request}`와 대조 |
| `REV-068` | `Task 4.16` / `P4-R10` | `P4-R10-GATE` | 0 이하 또는 비활성·미존재 장르를 legacy 호출 전에 400으로 거부 |
**최종 결론:** Phase 4 보완 필요 — Medium 2건 확정
**다음 Goal:** `P4-R9` (`P3-R18-GATE` 완료 후), 이어서 `P4-R10`.
## 20. 8차 후속 수정 및 Gate — 2026-07-29
- 무엇을: `REV-067`의 Series POST·PUT filename 없는 multipart part 우회와 `REV-068``genreId <= 0` 사전 거부 누락을 보완했다.
- 왜: `{image, request}` 외 일반 form-field part와 0 이하 장르가 legacy service 호출 전 400으로 고정되어야 하기 때문이다.
- 어떻게: `AiCharacterAdminSeriesMutationTest`에 filename 없는 part와 `genreId=0/-1` create·update KO/EN/JA actual endpoint 회귀를 추가하고, controller part 검사와 facade active genre guard를 최소 수정했다.
- 결과: RED 묶음에서 신규 multipart/genre 36건 실패를 확인했고, 보완 후 focused GREEN 묶음은 `BUILD SUCCESSFUL in 1m 17s`였다. 영향 범위 회귀와 lint 결과는 `P7-R10-GATE`에 통합 기록한다.
**최종 결론:** `REV-067`, `REV-068` 처리 완료. Phase 4 후속 Gate 완료.
**남은 항목:** 없음.
## 21. 9차 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 문서: PRD Feature E, OpenAPI Series 10개 operation
- 검토 범위: 목록·상세·생성·수정·연결 콘텐츠·순서·장르, owner/active genre/multipart 경계
- 검증 방식: 현재 working tree의 문서·production·test 소스를 정적으로 대조했다. 사용자 지시에 따라 컴파일과
테스트는 실행하지 않았다.
### 판정 및 plan 전환
- Series 10개 operation과 controller/facade의 owner·active genre·exact multipart 경계를 대조했다.
- 기존 완료 finding 이후 신규 확정 finding은 없다.
- Phase 4 신규 Task/Gate 없음.
**최종 결론:** Phase 4 추가 수정 없음.
**남은 항목:** Phase 3 보완 뒤 `P7-R11` 통합 재판정.
## 22. 10차 정적 리뷰 및 판정 — 2026-07-30
### 리뷰 범위와 방식
- 기준 문서: PRD Feature D, OpenAPI Series 10개 operation
- 검토 범위: 장르·목록·상세·생성·수정, 연결 콘텐츠 조회·검색·추가·해제와 순서 변경
- 검증 방식: 현재 working tree의 문서·production·test 소스를 정적으로 대조했다. 사용자 지시에 따라 컴파일과
테스트는 실행하지 않았다.
### 판정
- Series 10개 operation과 controller mapping, 목록 item과 동일한 상세 schema가 일치한다.
- active target/series·genre와 owner-scoped 콘텐츠 연결/해제, 순서 변경 lock·전체 ID 선검증이 유지된다.
- 생성·수정의 필수/빈 image, strict JSON과 exact multipart part 경계가 OpenAPI와 일치한다.
- 신규 확정 finding이 없어 Phase 4 회귀 수정 Task/Gate를 추가하지 않는다.
**최종 결론:** Phase 4 요구사항 충족, 추가 수정 없음.
**남은 항목:** 없음.

View File

@@ -0,0 +1,641 @@
# Phase 5 커뮤니티 관리 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 5 / 커뮤니티 3개 operation |
| 기준 commit 또는 working tree | `2f93e2c9` + 현재 Phase 5~7 working tree |
| 리뷰 일자 | 2026-07-28 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md`, `plan-task.md`, `api-contract.openapi.json` |
| 리뷰 상태 | 후속 수정 및 Gate 완료 |
## 2. 리뷰 목적과 범위
### 목적
- PRD Feature E와 OpenAPI Community 3개 operation을 facade/legacy service/test에 대조한다.
- multipart JSON 오류, 미지 필드, pagination과 side-effect 차단 순서를 점검한다.
### 포함 범위
- `AiCharacterAdminCommunityPostController`, `Facade`, `Repository`, DTO
- 관리자 community 테스트와 재사용하는 `CreatorCommunityService`
- OpenAPI Community path/schema와 plan Phase 5
### 제외 범위
- production 수정, legacy/public community 계약 변경, 테스트 실행
## 3. 판정 기준
| 심각도 | 기준 |
|---|---|
| Blocker | 소유권 우회 또는 데이터 손실 |
| High | 잘못된 요청이 500/side effect로 이어지는 주요 계약 위반 |
| Medium | pagination 또는 제한된 request schema 위반 |
| Low | 유지보수성 또는 문서 정합성 문제 |
## 4. 검토한 근거
| 근거 | 판정 |
|---|---|
| `AiCharacterAdminCommunityPostFacade.kt:36`~`:50` | create는 raw JSON을 legacy service에 전달하고 update는 기본 ObjectMapper로 직접 parse |
| `CreatorCommunityService.kt:73`~`:87` | create JSON을 기본 ObjectMapper로 parse한 뒤 media 검증·side effect 진행 |
| `AiCharacterAdminExceptionHandler.kt:58`~`:71` | Jackson parse 예외 전용 400 변환이 없고 미분류 예외는 500 |
| OpenAPI `:1156`~`:1176` | create/update request는 필수 필드와 `additionalProperties: false`를 정의 |
| `AiCharacterAdminCommunityPostFacade.kt:68`~`:81`, `:141`~`:145` | 목록에 `size in 1..50`을 강제 |
| OpenAPI `Size` parameter `:519` | minimum 1만 있고 maximum은 없음 |
| create/update 테스트 | request part 누락은 검증하지만 malformed/missing JSON field/unknown field는 직접 검증하지 않음 |
### 실행한 검증
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|---|---|---|
| parse 흐름·예외 handler·schema 정적 추적 | 성공 | parse 예외의 500 가능성과 unknown-field 허용 경계 확인 |
| pagination 계약 대조 | 성공 | runtime 최대 50과 OpenAPI maximum 부재 확인 |
| Gradle/컴파일/테스트 | 미실행 | 사용자 요청에 따라 실행하지 않음 |
## 5. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-025` | High | 처리 완료 | multipart JSON parse 오류가 500이 될 수 있고 미지 필드를 허용 | `Task 5.7` | `P5-R1` |
| `REV-026` | Medium | 처리 완료 | OpenAPI에 없는 목록 size 50 상한 | `Task 5.7` | `P5-R1` |
## 6. 발견 사항 상세
### REV-025 — Community JSON 오류 경계 불일치
- **심각도:** High
- **상태:** 처리 완료
- **관련 요구사항:** PRD Feature E, 공통 오류/side-effect 계약
- **관련 계약:** 잘못된 request는 400 `common.error.invalid_request`, request schema는 미지 필드 금지
- **소유 Task:** `Task 5.7`, `P5-R1`
**관찰 내용**
create는 raw request 문자열을 legacy service에 넘기고 update는 facade에서 기본 ObjectMapper로 읽는다. 두 경로 모두
`FAIL_ON_UNKNOWN_PROPERTIES`를 활성화하지 않으며 `JsonProcessingException`을 관리자 API 예외로 변환하지 않는다.
**영향**
malformed JSON이나 필수 non-null 필드 누락이 공통 handler의 500 `common.error.unknown`으로 분류될 수 있다. 미지 필드는
무시되어 잘못된 요청이 mutation과 S3/event 경로까지 진행될 수 있다.
**권장 조치**
legacy 호출 전에 create/update DTO를 strict reader로 검증하고 Jackson mapping 오류를
400 `common.error.invalid_request`로 변환한다. malformed, 필수 필드 누락, 미지 필드의 DB/S3/event 0회를 actual
endpoint로 고정한다.
**처리 결과**
create/update `request` part를 legacy service 호출 전에 strict reader로 검증하고 Jackson parse/mapping 오류를
400 `common.error.invalid_request`로 변환했다. create malformed·필수 field 누락·미지 field, update malformed·미지 field의
actual endpoint no-side-effect 테스트를 추가했다. OpenAPI상 update request에는 required field가 없어 update 필수 field 누락
케이스는 계약 밖으로 제외했다.
### REV-026 — 계약에 없는 Community size 상한
- **심각도:** Medium
- **상태:** 처리 완료
- **관련 요구사항:** 레거시 목록 query/pagination 유지
- **관련 계약:** 공통 `Size` parameter는 default 20, minimum 1이며 maximum 없음
- **소유 Task:** `Task 5.7`, `P5-R1`
**관찰 내용**
facade는 `size !in 1..50`을 400으로 거부한다. OpenAPI 단일 원본에는 maximum 50이 없으므로 `size=51`은 계약상 유효하다.
**권장 조치**
OpenAPI를 임의 변경하지 않고 관리자 facade의 상한 guard만 제거한다. page 음수와 size 1 미만 검증은 유지한다.
**처리 결과**
목록의 `size <= 50` 상한 guard만 제거하고 `page < 0`, `size < 1` 검증은 유지했다. `size=51` actual endpoint 요청이
정상 pagination으로 처리되는 테스트를 추가했다.
## 7. plan·goal 전환
`plan-task.md` Phase 5의 `Task 5.7` / `P5-R1``P5-R1-GATE`를 완료 처리했다. 기존 `P5-GATE` 완료 이력은 유지한다.
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| operation/path 대조 | 충족 | Community 3개 route 존재 |
| JSON 오류/schema 대조 | 충족 | strict parse와 parse 예외 400 변환, unknown-field 거부 회귀 통과 |
| pagination 대조 | 충족 | 계약에 없는 maximum 50 제거, `size=51` 회귀 통과 |
| ownership 선검증 | 충족 | target/post owner 확인은 mutation 전 수행 |
| 실행 검증 | 충족 | focused 테스트, community/common 회귀, `ktlintCheck`, `git diff --check` 통과 |
**최종 결론:** 후속 수정 및 Gate 완료
**남은 항목:** 없음. 다음 Goal은 `P6-R1`이다.
## 9. 2차 정적 리뷰 및 판정 — 2026-07-28
### 리뷰 정보와 검증 범위
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
- 기준 문서: PRD Feature E, plan Phase 5, OpenAPI Community 3개 operation
- 검토 범위: fixed update facade/legacy service/repository, transaction·lock 경계와 concurrency test
- 검증 방식: 두 병렬 transaction의 count/read/update 순서를 코드와 테스트로 정적 추적했다.
사용자 요청에 따라 컴파일과 테스트는 실행하지 않았다.
### 추가 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-033` | High | 처리 완료 | 최대 고정 3개가 실제 병렬 요청에서 보장되지 않음 | `Task 5.8` | `P5-R2` |
### REV-033 — lock 없는 count-then-update 경쟁 조건
- **심각도:** High
- **상태:** 처리 완료
- **관련 요구사항:** PRD Feature E의 최대 고정 게시글 수 3개
- **관련 계약:** plan transaction/concurrency 고려사항과 `Task 5.5`의 동시 요청 완료 증거
- **소유 Task:** `Task 5.8`, `P5-R2`
**관찰 내용**
legacy fixed update는 활성 고정 수를 조회한 뒤 별도 게시글 entity의 `isFixed`를 변경한다. owner 또는 고정 집합을 잠그는
lock/constraint가 없으므로, 고정 2개 상태에서 서로 다른 게시글을 고정하는 두 transaction이 모두 count 2를 읽고 커밋하면
최종 고정 수는 4개가 된다.
`AiCharacterAdminCommunityPostConcurrencyTest`는 이름과 달리 두 요청을 순서대로 호출한다. plan `Task 5.5`도 실제 병렬
재현을 하지 못했다고 기록하면서 Task objective·완료 증거와 Phase acceptance를 완료 처리했다.
**근거**
- 코드: `AiCharacterAdminCommunityPostFacade.kt:62`~`:69`은 lock 없이 legacy fixed update를 호출한다.
- 코드: `CreatorCommunityService.kt:247`~`:262``countBy...` 후 서로 다른 post를 갱신한다.
- 테스트: `AiCharacterAdminCommunityPostConcurrencyTest.kt:55`~`:79`는 세 번째 요청 완료 후 네 번째 요청을 실행하는
순차 시나리오다.
- 계획: `Task 5.5` objective/완료 증거는 동시 요청을 요구하지만 `:2849`~`:2852`에서 실제 병렬 요청은 미검증이라고
명시한다.
- 기존 코드: `MemberRepository.findByIdForUpdate` owner row pessimistic lock을 재사용할 수 있다.
**정적 재현 절차**
1. 같은 owner에 active fixed post 2개와 미고정 post 2개를 준비한다.
2. 두 독립 transaction이 서로 다른 미고정 post를 `isFixed=true`로 수정한다.
3. lock이 없으므로 두 transaction 모두 count 2를 읽을 수 있다.
4. 서로 다른 row를 갱신해 둘 다 커밋하면 최종 active fixed count는 4가 된다.
**영향**
PRD의 최대 3개 데이터 불변식이 깨지고 관리자 목록 정렬·운영 정책이 비결정적이 된다. 단순 순차 회귀는 통과하므로
현재 테스트 통과만으로 문제를 탐지할 수 없다.
**권장 조치**
신규 DDL이나 dependency 없이 기존 `MemberRepository.findByIdForUpdate`로 fixed/unfixed count·update 전에 owner를 잠근다.
두 독립 transaction과 barrier/lock probe를 사용하는 결정적 병렬 테스트로 최종 3개, 한 요청 400, 실패 side effect 0건을
검증하고 sleep·반복 확률 기반 테스트는 사용하지 않는다.
**처리 결과**
fixed 변경 요청에서 legacy count/update 전에 `MemberRepository.findByIdForUpdate`로 owner row를 잠그도록 변경했다.
두 병렬 요청이 같은 count 경계에 진입하는 결정적 회귀 테스트를 추가했고, 최종 고정 수 3개와 한 요청 400을 확인했다.
**판정 기록**
- 2026-07-28 — 코드·plan 완료 증거·테스트 실행 구조로 경쟁 조건을 확정. 테스트는 사용자 요청에 따라 미실행.
### plan·goal 전환
`plan-task.md` Phase 5에 `Task 5.8` / `P5-R2``P5-R2-GATE`를 추가했다. 기존 `Task 5.5` 완료 이력은
되돌리지 않고 실제 동시성 보완을 새 Goal로 추적한다.
`P5-R2``P5-R2-GATE`를 완료 처리했다.
### 2차 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| operation/schema 대조 | 충족 | Community 3개 mapping과 JSON 경계 유지 |
| 순차 fixed 정책 | 충족 | 세 번째 성공·네 번째 거부 테스트 존재 |
| 실제 동시성 불변식 | 충족 | owner row lock과 결정적 병렬 회귀 통과 |
| plan 반영 | 충족 | `Task 5.8`, `P5-R2`, `P5-R2-GATE` |
| 실행 검증 | 충족 | focused concurrency, community/common 회귀, `ktlintCheck`, `git diff --check` 통과 |
**최종 결론:** 후속 수정 및 Gate 완료
**남은 항목:** 없음. 다음 Goal은 `P7-R2`다.
## 10. 3차 정적 리뷰 및 판정 — 2026-07-28
### 리뷰 범위와 방식
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
- 기준 문서: PRD Feature E, plan Phase 5, OpenAPI Community 3개 operation
- 검토 범위: 목록·생성·수정 facade, strict multipart JSON, owner-scoped query, fixed owner lock과 관련 테스트
- 검증 방식: 코드·문서·테스트 정적 추적. 컴파일과 테스트는 실행하지 않았다.
### 발견 사항과 판정
확정 발견 사항 없음.
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| operation/schema | 충족 | Community 3개 mapping과 OpenAPI 경계 유지 |
| ownership | 충족 | active target과 owner post 검증 유지 |
| JSON/multipart | 충족 | strict parse와 malformed/unknown-field 400 유지 |
| fixed 동시성 | 충족 | owner row lock이 count/update 앞에서 수행됨 |
| plan 전환 | 해당 없음 | Phase 5 신규 Task 불필요 |
**최종 결론:** Phase 5 추가 수정 없음
**남은 항목:** `P7-R3`에서 Community/common 회귀를 통합 재검증한다.
## 11. 4차 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
- 기준 문서: PRD Feature E, plan Phase 5, OpenAPI Community 3개 operation
- 검토 범위: 목록·생성·수정, owner query, strict JSON, 고정 수 lock과 soft delete
- 검증 방식: 코드·schema·테스트 정적 대조. 컴파일과 테스트는 실행하지 않았다.
### 발견 사항과 판정
확정 발견 사항 없음.
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| operation/schema | 충족 | Community 3개 mapping과 OpenAPI 경계 유지 |
| ownership | 충족 | active target과 owner post를 mutation 전에 확인 |
| 고정/soft delete | 충족 | owner row lock과 fixed 상태 동시 해제 유지 |
| 오류/부작용 | 충족 | strict JSON과 target/owner 실패 선검증 유지 |
| plan 전환 | 해당 없음 | Phase 5 신규 Task 불필요 |
**최종 결론:** Phase 5 추가 수정 없음
**남은 항목:** 없음.
## 12. 5차 정적 리뷰 및 판정 — 2026-07-29
### 확인된 문제
#### `REV-043` — 커뮤니티 primitive의 required·null 계약 미강제
- **심각도:** High
- **상태:** 처리 완료
- **계약:** OpenAPI create request는 `isCommentAvailable`, `isAdult`를 required non-null boolean으로 정의하고,
`price`와 update의 `isFixed`도 nullable로 선언하지 않는다.
- **구현:** create DTO의 boolean/price는 Kotlin primitive이고 update `isFixed`는 nullable이라, strict reader가
미지 필드만 거부하면 누락·explicit null을 계약대로 구분하지 못한다.
- **근거:** Jackson Kotlin/databind 2.13.5 기본 설정에서 create primitive는 false·0으로 보정될 수 있고,
update `isFixed: null`은 필드 생략과 같은 null로 처리된다.
- **영향:** 계약상 invalid 요청이 생성 mutation을 진행하거나 성공 no-op update로 처리될 수 있다.
### 보완 결과
| 항목 | 판정 |
|---|---|
| 신규 Task | `Task 5.9` / `P5-R3` 처리 완료 |
| 시작 조건 | `P4-R4-GATE` 완료 후 실행 |
| Gate | `P5-R3-GATE` 완료 |
| RED | required boolean 누락·null, `price: null`, `isFixed: null`이 400 기대 실패 |
| GREEN | v2 create/update 경계 required/non-null 검증, 생략 의미 유지 |
| 범위 제한 | 전역 mapper·레거시 service·OpenAPI 변경 없음 |
### 실행 검증
| 명령 또는 검증 | 결과 | 핵심 증거 |
|---|---|---|
| invalid primitive RED focused | 실패 확인 | 신규 6건이 400 기대 실패 |
| invalid primitive GREEN focused | 통과 | `BUILD SUCCESSFUL in 41s` |
| create/update focused | 통과 | 리뷰 보완 후 `BUILD SUCCESSFUL in 34s` |
| community/common 영향 범위 회귀 | 통과 | 리뷰 보완 후 `BUILD SUCCESSFUL in 1m 2s` |
| `ktlintCheck`, `git diff --check` | 통과 | `ktlintCheck``BUILD SUCCESSFUL in 13s`, diff check 출력 없음 |
**최종 결론:** Phase 5 `REV-043` 보완 완료
**다음 Goal:** `P5-R4`.
## 13. Community 목록 요구사항 변경 판정 — 2026-07-29
### 확정 요구사항
- **추적 ID:** `DEC-P5-LIST-001`
- GET 목록에서 실제 응답 생성에 사용하지 않는 `timezone` query를 제거한다.
- 성공 `data`는 직접 배열 대신
`AiCharacterAdminCommunityPostListResponse(totalCount, page, size, hasNext, items)`를 반환한다.
- `totalCount``hasNext`는 target creatorMember 소유 active 게시글만 기준으로 계산한다.
- `items`의 기존 18개 필드와 고정 우선 정렬, owner/inactive 격리, page/size 오류 정책은 유지한다.
### 설계 판정
| 항목 | 판정 | 근거 |
|---|---|---|
| query | `page`, `size`만 유지 | timezone은 facade에서 유효성 검사 외 사용되지 않음 |
| response | 전용 pagination wrapper 추가 | UI가 전체 개수와 추가 로딩 필요 여부를 판단해야 함 |
| count | active owner count query 1개 추가 | totalCount가 필요해 size+1 조회만으로는 충족 불가 |
| hasNext | `pageable.offset + items.size < totalCount` | 마지막·범위 밖 page를 단순하게 처리 |
| 기존 item | 변경 없음 | 요청 범위 밖 schema 변경 방지 |
| 공용 추상화 | 추가하지 않음 | 단일 endpoint 전용 DTO가 최소 변경 |
### plan 전환
- 신규 Task: `Task 5.10` / `P5-R4`
- Gate: `P5-R4-GATE`
- 시작 조건: `P5-R3-GATE` 완료
- 통합 조건: `P7-R5` 시작 전에 `P5-R4-GATE` 완료
### 처리 결과
`P5-R4`에서 controller/facade의 `timezone` query와 미사용 검증을 제거하고, repository에 active owner count query를 추가했다.
성공 `data``AiCharacterAdminCommunityPostListResponse(totalCount, page, size, hasNext, items)`로 반환한다. 기존 item 18개 필드,
고정 우선 정렬, owner/inactive 격리, `page < 0`·`size < 1` 오류 정책과 문서에 없는 size 상한 부재는 유지했다. OpenAPI
Community GET status는 `implemented`로 복구했다.
### 실행 검증
| 명령 또는 검증 | 결과 | 핵심 증거 |
|---|---|---|
| query RED focused | 실패 확인 | 신규 3건이 400/직접 배열 응답 차이로 실패 |
| query GREEN focused | 통과 | `BUILD SUCCESSFUL in 1m 28s` |
| query+contract focused | 통과 | `BUILD SUCCESSFUL in 37s` |
| community/common 영향 범위 회귀 | 통과 | `BUILD SUCCESSFUL in 1m 6s` |
| OpenAPI jq assertion | 통과 | `true` |
| `ktlintCheck`, `git diff --check` | 통과 | `ktlintCheck``BUILD SUCCESSFUL in 12s`, diff check 출력 없음 |
**최종 결론:** Phase 5 `DEC-P5-LIST-001` 목록 계약 정합화 및 Gate 완료
**다음 Goal:** `P7-R5`.
## 14. 커뮤니티 댓글 후속 검토 — 2026-07-29
### 확인 결과
- **`REV-048` / High / 구현 대기:** 신규 v2 관리자 경계에 target 소유 커뮤니티 게시글의 댓글·답글
조회/작성/수정/삭제 5개 operation이 없다.
- 조회는 필수 `timezone``page`, `size`, 레거시 `totalCount/items`를 유지한다.
- 작성자는 target `creatorMember`, 수정은 target 작성 활성 row만 허용한다. 삭제는 target 소유 게시글의 row를
작성자와 관계없이 soft delete하고 cascade하지 않으며 이미 비활성이면 성공 no-op이다.
- 답글 `parentId`는 같은 게시글의 활성 원댓글이어야 한다.
### plan 전환
- 신규 Task: `Task 5.11` / `P5-R5`
- Gate: `P5-R5-GATE`
- 범위 밖: 캐릭터 직접 댓글, hard delete·cascade, legacy/public endpoint 변경
사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다.
**최종 결론:** Phase 5 커뮤니티 댓글 CRUD 구현 필요
**다음 Goal:** `P5-R5`.
## 15. 커뮤니티 댓글 CRUD 구현 및 Gate — 2026-07-29
- 무엇을: `REV-048`을 처리했다.
- 왜: target AI 소유 커뮤니티 게시글의 댓글·답글 조회/작성/수정/삭제 5개 operation이 신규 v2 관리자 경계에 없었기 때문이다.
- 어떻게:
- RED: `AiCharacterAdminCommunityPostCommentTest`에 root/reply 목록, target AI 작성, parent 검증, target 작성자 수정 제한, row-only soft delete, 요청 오류 계약 테스트 7건을 추가했고 미구현 route로 실패했다.
- GREEN: `AiCharacterAdminCommunityPostController`에 5개 route를 추가하고, facade에서 target/owner/parent/actor를 선검증한 뒤 기존 `CreatorCommunityService` 댓글 조회·작성·수정 의미를 재사용했다.
- Gate: focused 댓글 테스트, community/common 영향 범위 회귀, `ktlintCheck`, `git diff --check`를 fresh 실행했다.
- 결과: `REV-048` 처리 완료. 캐릭터 직접 댓글, hard delete, cascade, legacy/public endpoint 변경은 추가하지 않았다.
**최종 결론:** Phase 5 커뮤니티 댓글 후속 기능 종결
**다음 Goal:** `P6-R2`.
## 16. 커뮤니티 댓글 UTC 날짜 계약 변경 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 문서: PRD Feature E, OpenAPI 2.2.0 Community 8개 operation, `DEC-UTC-DATE-001`
- 검토 범위: 커뮤니티 댓글·답글 GET의 controller/facade/repository와 관련 테스트
- 검증 방식: 문서·코드·테스트 정적 대조. 사용자 요청에 따라 컴파일과 테스트는 실행하지 않았다.
### `REV-051` — High — 처리 완료: 커뮤니티 댓글 2개 GET의 timezone/UTC 계약 불일치
- 처리 전 댓글·답글 GET은 필수 `timezone` query를 controller/facade/repository로 전달하고, 각 댓글 `date`
요청 timezone에 맞춘 표시 문자열로 반환한다.
- 승인된 최신 계약은 `timezone` query 없이 `page`, `size`만 받고 기존 `totalCount`, `items`, `date` 필드명을
유지하되 `date` 값을 ISO-8601 UTC(`Z`)로 반환한다.
- 댓글 작성·수정·삭제의 actor/owner/parent/soft delete 의미와 커뮤니티 게시글 목록의 pagination wrapper는
변경 대상이 아니다.
### 판정
| 항목 | 결과 | 근거 |
|---|---|---|
| route 수 | 유지 | Community 8개 operation 자체는 변경 없음 |
| 댓글·답글 query | 처리 완료 | v2 controller/facade에서 필수 `timezone`을 제거하고 `page`·`size`만 사용 |
| 댓글 `date` | 처리 완료 | owner-scoped 조회 결과를 `createdAt.toUtcIso()`로 재매핑해 UTC `date-time` 반환 |
| 기존 댓글 의미 | 유지 | pagination·ownership·block/secret·mutation 정책 변경 없이 영향 범위 회귀 통과 |
| legacy/public 격리 | 충족 | 기존 community 댓글 timezone service/repository 계약을 변경하지 않음 |
| OpenAPI 상태 | 처리 완료 | 영향 2개 operation을 `implemented`로 동기화 |
### plan·goal 전환
- 신규 Task: `Task 5.12` / `P5-R6`
- Gate: `P5-R6-GATE`
- 시작 조건: `P3-R14-GATE`
- 완료 조건: 댓글·답글 actual GET UTC exact JSON, 기존 pagination/ownership/block/secret 의미와
legacy/public 회귀
### `P5-R6` / `P5-R6-GATE` 처리 결과
- RED: production 변경 전 `AiCharacterAdminCommunityPostCommentTest`는 timezone 없는 root/reply GET이 400을 반환해 2건 실패했고 `BUILD FAILED in 38s`였다.
- GREEN: controller/facade의 timezone 입력·검증을 제거하고 legacy 조회 결과의 `date``createdAt.toUtcIso()`로 재매핑한 뒤 같은 focused 테스트는 `BUILD SUCCESSFUL in 43s`였다.
- Gate: community/common·legacy 영향 범위 회귀는 `BUILD SUCCESSFUL in 1m 11s`, `ktlintCheck``BUILD SUCCESSFUL in 26s`였고, OpenAPI는 36개 `implemented`와 0개 `alignment-required`, `git diff --check`는 출력 없음을 확인했다.
**최종 결론:** `REV-051` 처리 완료, Phase 5 UTC 계약 정합화 완료
**다음 Goal:** `P7-R7`.
## 17. 6차 통합 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 문서: PRD Feature E, OpenAPI Community 8개 operation
- 검토 범위: Community controller의 multipart/JSON mapping, 댓글 facade와 관련 actual endpoint 테스트
- 기준 상태: 현재 working tree
- 검증 방식: 문서·코드·테스트 정적 대조. 사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다.
### `REV-053` — High — 처리 완료
- OpenAPI는 댓글 POST와 PUT의 requestBody media type을 `application/json` 하나로 정의하고 415 response를 선언한다.
- 검토 당시 두 controller mapping에는 `consumes = [MediaType.APPLICATION_JSON_VALUE]`가 없었다.
- body를 `String`으로 받으므로 mapping 단계에서 media type을 제한하지 않으면 `text/plain` 같은 요청이
`HttpMediaTypeNotSupportedException`으로 차단되지 않고 handler/parser까지 진입할 수 있다.
- 기존 댓글 테스트는 정상·오류 JSON 요청을 모두 `application/json`으로만 보내 미지원 media type과
415 `Accept` header/no-side-effect를 고정하지 않는다.
- 외부 HTTP 요청 수용 범위와 명시된 415가 달라 High로 판정한다.
### plan 전환
- 신규 Task: `Task 5.13` / `P5-R7`
- Gate: `P5-R7-GATE`
- 최소 수정: 댓글 POST·PUT mapping에 JSON `consumes` 추가
- 완료 조건: 정상 JSON 회귀, 미지원 media type의 KO/EN/JA 415 envelope, `Accept` header,
작성 insert/event 0회와 수정 row 불변
- 범위 밖: facade/parser·댓글 actor/owner/parent 의미, OpenAPI·legacy/public API 변경
### 처리 결과
- 댓글 POST·PUT mapping에 `consumes = [MediaType.APPLICATION_JSON_VALUE]`를 추가했다.
- actual endpoint 회귀에서 KO/EN/JA `text/plain` 요청의 localized 415 `ApiResponse.error`, `Accept: application/json`,
작성 insert/event 0회와 수정 row 불변을 확인했다.
### `REV-058` — High — 처리 완료
- OpenAPI와 계약 설명은 게시글 생성·수정 multipart의 `request` part Content-Type을 `application/json`으로 고정한다.
- 두 controller는 `@RequestPart("request") request: String`으로 받아 part 자체의 media type을 검사하지 않는다.
- 정상 테스트는 JSON media type만 사용하며 미지원/누락 part media type의 415 `Accept` header와
S3/DB/event no-side-effect를 고정하지 않는다.
- 같은 shared converter와 signature에서 AudioContent의 `text/plain` 성공 테스트가 있어 permissive binding을
정적으로 확인할 수 있다.
### 추가 plan 전환
- 신규 Task: `Task 5.14` / `P5-R8`
- Gate: `P5-R8-GATE`
- 최소 수정: 기존 strict String reader는 유지하고 v2 multipart 경계에서 part-level JSON media type만 강제
- 완료 조건: POST·PUT 정상 JSON 회귀, 미지원/누락 media type의 KO/EN/JA 415 envelope, `Accept` header,
S3/DB/event no-side-effect
### `P5-R8` / `P5-R8-GATE` 처리 결과
- RED: production 변경 전 `AiCharacterAdminCommunityPostCreateTest``AiCharacterAdminCommunityPostUpdateTest`
KO/EN/JA `text/plain` 및 Content-Type 누락 `request` part 415 matrix를 추가했고, focused 명령은 12개 invocation이
415 기대 실패로 `BUILD FAILED in 56s`였다.
- GREEN: controller POST·PUT 경계에서 `request` part의 JSON 호환 media type만 확인하도록 추가했다. facade strict reader,
media/fixed/owner 의미, OpenAPI schema는 변경하지 않았다.
- Gate: 같은 focused 명령은 `BUILD SUCCESSFUL in 59s`, community/common 영향 범위와
`AiCharacterAdminErrorContractTest` 회귀는 `BUILD SUCCESSFUL in 55s`였다.
**최종 결론:** `REV-053`, `REV-058` 처리 완료, Phase 5 HTTP media type 계약 정합화 완료
**다음 Goal:** `P6-R3`.
## 18. 7차 통합 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 문서: PRD Feature E, OpenAPI `CommunityPostCreateMultipart`·`CommunityPostUpdateMultipart`
- 검토 범위: Community POST·PUT controller의 multipart binding과 media/fixed/owner mutation 테스트
- 검증 방식: 현재 working tree의 문서·코드·테스트를 정적으로 대조했다. 사용자 요청에 따라 컴파일과 테스트는
실행하지 않았다.
### `REV-063` — Medium — 생성·수정의 서로 다른 허용 part 집합을 강제하지 않음
- OpenAPI는 생성에 `audioFile`, `postImage`, `request`, 수정에 `postImage`, `request`만 허용하고 두 schema 모두
`additionalProperties: false`다.
- controller는 각 `@RequestPart``request` media type만 처리하며 전체 part 이름을 검사하지 않는다.
- 특히 수정 요청에 OpenAPI가 금지한 `audioFile`이나 임의 `unexpected` part를 추가해도 해당 part가 무시된 채 게시글
mutation이 진행될 수 있다.
### plan 전환
| 항목 | 내용 |
|---|---|
| 신규 Task | `Task 5.15` / `P5-R9` |
| Gate | `P5-R9-GATE` |
| RED | 생성·수정 미정의 part, 수정 `audioFile`, S3·DB·event 결과 |
| GREEN | 생성 `{audioFile, postImage, request}`, 수정 `{postImage, request}` exact allow-list |
| 회귀 | 정상 media/fixed/owner, request part 누락·415 |
### `P5-R9` / `P5-R9-GATE` 처리 결과
- RED: production 변경 전 Community POST `unexpected`, PUT `unexpected`·`audioFile` actual endpoint 테스트를 추가했고,
focused 명령은 3개 케이스 모두 400 기대 실패로 `BUILD FAILED in 3m 23s`였다.
- GREEN: Community controller POST는 `{audioFile, postImage, request}`, PUT은 `{postImage, request}` exact allow-list를
적용해 초과 part를 400 `common.error.invalid_request`로 거부한다. media/fixed/owner 의미와 OpenAPI schema는 변경하지 않았다.
- Gate: focused 명령은 `BUILD SUCCESSFUL in 2m 30s`, community/common 영향 범위 회귀는 `BUILD SUCCESSFUL in 1m 44s`,
`ktlintCheck``BUILD SUCCESSFUL in 55s`였다.
**최종 결론:** `REV-063` 처리 완료, Phase 5 multipart part 이름 계약 정합화 완료
**다음 Goal:** `P7-R9`.
## 19. 8차 통합 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 문서: OpenAPI Community create/update multipart schema의 operation별 허용 part와
`additionalProperties: false`
- 검토 범위: Community POST·PUT controller와 미정의 part·media/fixed/owner 회귀 테스트
- 기준 상태: 현재 working tree
- 리뷰어/상태: Codex / 판정 완료
- 검증 방식: 문서·코드·테스트 소스 정적 대조. 사용자 지시에 따라 컴파일과 테스트는 실행하지 않았다.
### `REV-069` — Medium — 일반 form-field multipart part가 allow-list 우회
- `AiCharacterAdminCommunityPostController.kt:63-69`는 operation별 allow-list를 받지만 실제 검사는
`fileMap.keys`에 한정한다.
- 기존 create/update 회귀는 `AiCharacterAdminCommunityPostCreateTest.kt:202-210`
`AiCharacterAdminCommunityPostUpdateTest.kt:256-268`에서 filename이 있는 `MockMultipartFile`만 사용한다.
- filename 없는 일반 form-field `unexpected`는 생성 `{audioFile, postImage, request}`, 수정
`{postImage, request}` 계약을 우회할 수 있어 Medium으로 확정한다.
### plan 전환
| 항목 | 내용 |
|---|---|
| 신규 Task | `Task 5.16` / `P5-R10` |
| Gate | `P5-R10-GATE` |
| RED | filename 없는 미정의 part의 POST·PUT 400과 S3·DB·event no-side-effect |
| GREEN | servlet 전체 part 이름을 operation별 allow-list와 비교 |
| 범위 제한 | media/fixed/concurrency·OpenAPI·전역 resolver·legacy/public 변경 없음 |
**최종 결론:** Phase 5 보완 필요 — `REV-069` 확정
**다음 Goal:** `P5-R10` (`P4-R10-GATE` 완료 후).
## 20. 8차 후속 수정 및 Gate — 2026-07-29
- 무엇을: `REV-069`의 Community POST·PUT filename 없는 일반 form-field multipart part 우회를 보완했다.
- 왜: 생성 `{audioFile, postImage, request}`, 수정 `{postImage, request}` 외 일반 form-field part가 기존 파일 map 검사만으로는 mutation 전 거부되지 않았기 때문이다.
- 어떻게: create/update focused test에 filename 없는 `unexpected` part KO/EN/JA actual endpoint 회귀를 추가하고, controller가 `fileMap.keys`와 servlet `parts` 이름을 모두 operation별 allow-list와 비교하게 했다.
- 결과: RED 묶음에서 신규 multipart/genre 36건 실패를 확인했고, 보완 후 focused GREEN 묶음은 `BUILD SUCCESSFUL in 1m 17s`였다. 영향 범위 회귀와 lint 결과는 `P7-R10-GATE`에 통합 기록한다.
**최종 결론:** `REV-069` 처리 완료. Phase 5 후속 Gate 완료.
**남은 항목:** 없음.
## 21. 9차 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 문서: PRD Feature F의 Community 요구사항, OpenAPI Community 8개 operation
- 검토 범위: 게시글 목록·생성·수정, 댓글 CRUD, owner/actor/parent, multipart·concurrency 경계
- 검증 방식: 현재 working tree의 문서·production·test 소스를 정적으로 대조했다. 사용자 지시에 따라 컴파일과
테스트는 실행하지 않았다.
### 판정 및 plan 전환
- Community 8개 operation과 target owner, 댓글 actor/parent, exact multipart, 고정 제한 동시성 경계를 대조했다.
- 기존 완료 finding 이후 신규 확정 finding은 없다.
- Phase 5 신규 Task/Gate 없음.
**최종 결론:** Phase 5 추가 수정 없음.
**남은 항목:** Phase 3 보완 뒤 `P7-R11` 통합 재판정.
## 22. 10차 정적 리뷰 및 판정 — 2026-07-30
### 리뷰 범위와 방식
- 기준 문서: PRD Feature E, OpenAPI Community 8개 operation
- 검토 범위: 게시글 목록·생성·수정, 고정 동시성, 댓글 CRUD, owner/actor/parent와 multipart 경계
- 검증 방식: 현재 working tree의 문서·production·test 소스를 정적으로 대조했다. 사용자 지시에 따라 컴파일과
테스트는 실행하지 않았다.
### 판정
- Community 8개 operation과 controller mapping, active owner pagination wrapper·고정 우선 정렬이 일치한다.
- 게시글 생성·수정의 strict request와 operation별 multipart part, 최대 고정 3개 owner lock·soft delete 정리가 유지된다.
- 댓글의 target AI 작성/수정, 동일 게시글 활성 원댓글, row-only soft delete와 UTC 응답 계약이 유지된다.
- 신규 확정 finding이 없어 Phase 5 회귀 수정 Task/Gate를 추가하지 않는다.
**최종 결론:** Phase 5 요구사항 충족, 추가 수정 없음.
**남은 항목:** 없음.

View File

@@ -0,0 +1,488 @@
# Phase 6 FanTalk 관리 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 6 / FanTalk 2개 operation |
| 기준 commit 또는 working tree | `2f93e2c9` + 현재 Phase 6~7 working tree |
| 리뷰 일자 | 2026-07-28 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md`, `plan-task.md`, `api-contract.openapi.json` |
| 리뷰 상태 | 후속 수정 및 Gate 완료 |
## 2. 리뷰 목적과 범위
### 목적
- PRD Feature F와 OpenAPI FanTalk 2개 operation을 관리자/public v2 query policy, controller, facade, 테스트에 대조한다.
- pagination 보정과 reply JSON/side-effect 경계를 점검한다.
### 포함 범위
- 관리자 FanTalk controller/facade/repository/DTO와 관련 테스트
- 공개 v2 `CreatorChannelFanTalkQueryPolicy`
- OpenAPI FanTalk path/parameter/schema와 plan Phase 6
### 제외 범위
- 공개 v2 정책 변경
## 3. 판정 기준
| 심각도 | 기준 |
|---|---|
| Blocker | cross-owner reply 또는 데이터 손실 |
| High | 승인된 공개 v2 parity나 주요 조회 계약 위반 |
| Medium | reply request schema·오류 계약의 제한된 위반 |
| Low | 유지보수성 또는 문서 정합성 문제 |
## 4. 검토한 근거
| 근거 | 판정 |
|---|---|
| OpenAPI `FanTalkPage`/`FanTalkSize` `:520`~`:521` | page는 0 이상, size는 20..50으로 보정 |
| `CreatorChannelFanTalkQueryPolicy.kt:8`~`:12`, `:23`~`:28` | 공개 v2가 실제로 같은 보정을 수행 |
| `AiCharacterAdminFanTalkFacade.kt:30`~`:55` | 관리자는 범위 밖 값을 400으로 거부하고 size 1도 허용 |
| `AiCharacterAdminFanTalkQueryTest.kt:120`~`:189` | size 1 성공과 -1/0/51 거부를 테스트가 반대 계약으로 고정 |
| OpenAPI `FanTalkReplyCreateRequest` `:1238`~`:1242` | `content` required, `additionalProperties: false` |
| `AiCharacterAdminFanTalkController.kt:27`~`:33` | reply를 기본 `@RequestBody` DTO binding으로 수신 |
| reply contract test `:50`~`:100` | blank/malformed/missing은 검증하지만 미지 필드는 검증하지 않음 |
### 실행한 검증
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|---|---|---|
| query policy·facade·test 정적 대조 | 성공 | pagination 구현과 테스트가 PRD/OpenAPI에 반대임을 확인 |
| reply schema/binding 정적 대조 | 성공 | unknown-field strict 경계 누락 확인 |
| Gradle/컴파일/테스트 | 실행 | `P6-R1` RED/GREEN focused test 수행 |
| `P6-R1` RED | 성공 | pagination 400과 reply unknown-field 허용으로 4건 실패 확인 |
| `P6-R1` GREEN | 성공 | focused 재실행 `BUILD SUCCESSFUL in 3m 14s` |
| `P6-R1-GATE` FanTalk/common 회귀 | 성공 | `BUILD SUCCESSFUL in 1m 36s` |
| `P6-R1-GATE` lint/diff | 성공 | `ktlintCheck` `BUILD SUCCESSFUL in 35s`, `git diff --check` 출력 없음 |
## 5. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-027` | High | 처리 완료 | 관리자 목록 pagination이 공개 v2 보정 정책과 반대 | `Task 6.6` | `P6-R1-GATE` |
| `REV-028` | Medium | 처리 완료 | reply body가 미지 JSON 필드를 허용 | `Task 6.6` | `P6-R1-GATE` |
## 6. 발견 사항 상세
### REV-027 — FanTalk pagination 보정 불일치
- **심각도:** High
- **상태:** 처리 완료
- **관련 요구사항:** PRD Feature F, 공개 v2 응답/query policy parity
- **관련 계약:** page default 0·최소 0 보정, size default 20·20..50 보정
- **소유 Task:** `Task 6.6`, `P6-R1`
**관찰 내용**
관리자 facade는 음수 page, size 0, size 51을 400으로 거부하고 size 1을 허용한다. 공개 v2 policy와 OpenAPI는 각각
page 0, size 20, size 50으로 보정해야 하며 size 1도 20으로 올려야 한다. 현재 query 테스트가 잘못된 구현을 의도한
동작으로 고정한다.
**후속 수정 결과**
관리자 목록 facade가 공개 v2 `CreatorChannelFanTalkQueryPolicy`를 재사용하도록 변경되어 `page < 0 -> 0`,
`size < 20 -> 20`, `size > 50 -> 50` 보정이 actual endpoint 테스트로 고정됐다.
**영향**
OpenAPI client가 보정 계약을 신뢰하면 관리자 endpoint에서 예상하지 못한 400을 받으며, size 1 요청의 응답 metadata도
계약과 달라진다.
**권장 조치**
공개 v2 query policy와 동일한 정규화를 적용하고 기존 pagination 테스트를 경계값 기반으로 교정한다.
### REV-028 — FanTalk reply unknown-field 미거부
- **심각도:** Medium
- **상태:** 처리 완료
- **관련 요구사항:** OpenAPI request schema 준수, 잘못된 request no-side-effect
- **관련 계약:** `FanTalkReplyCreateRequest.additionalProperties: false`
- **소유 Task:** `Task 6.6`, `P6-R1`
**관찰 내용**
controller의 기본 DTO binding은 malformed/missing content는 거부하지만 계약 밖 필드를 무시한다. repository 전역 설정에
unknown property 실패 설정이 없고 현재 contract test도 extra field를 다루지 않는다.
**후속 수정 결과**
reply controller는 raw JSON 문자열을 facade로 넘기고, facade가 `FAIL_ON_UNKNOWN_PROPERTIES` strict reader로
`AiCharacterAdminFanTalkReplyRequest`를 역직렬화한다. 미지 필드 요청은 400 `common.error.invalid_request`, reply insert
0건, `LanguageDetectEvent` 0회로 actual endpoint 테스트에 고정됐다.
**권장 조치**
reply body만 strict parse하고 미지 필드가 있으면 400 `common.error.invalid_request`, reply insert 0건,
`LanguageDetectEvent` 0회를 actual endpoint로 고정한다.
## 7. plan·goal 전환
`plan-task.md` Phase 6에 두 finding을 함께 처리하는 `Task 6.6` / `P6-R1``P6-R1-GATE`를 추가했다. 기존
`P6-GATE` 완료 이력은 유지한다.
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| operation/path 대조 | 충족 | FanTalk 2개 route 존재 |
| 공개 v2 pagination parity | 충족 | 공개 v2 query policy 재사용과 경계값 actual test 통과 |
| reply ownership/storage 추적 | 충족 | active owner root 선검증과 target creator 저장 확인 |
| reply JSON schema | 충족 | unknown-field 400/no insert/no event actual test 통과 |
| 실행 검증 | 충족 | focused, FanTalk/common 회귀, lint, diff 성공 |
**최종 결론:** Phase 6 후속 리뷰 종료
**남은 항목:** 없음. 다음 Goal은 `P7-R1`이다.
## 9. 2차 정적 리뷰 및 판정 — 2026-07-28
### 리뷰 정보와 범위
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
- 기준 문서: PRD Feature F, plan Phase 6, OpenAPI FanTalk 2개 operation
- 검토 범위: 관리자 root/reply query, 공개 v2 pagination policy, reply strict JSON·owner/root/active 검증,
writer/creator 저장과 언어 감지 event 테스트
- 검증 방식: 코드·문서·테스트 정적 대조. 컴파일과 테스트는 실행하지 않았다.
### 발견 사항과 판정
확정 발견 사항 없음.
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 목록 query/pagination | 충족 | 공개 v2 `CreatorChannelFanTalkQueryPolicy` 재사용 |
| reply JSON | 충족 | strict reader와 blank/malformed/unknown-field 거부 |
| root/ownership | 충족 | active owner root만 조회하고 nested/cross-owner를 저장 전 차단 |
| writer/event | 충족 | target creator를 writer/creator로 저장하고 언어 감지 event 발행 |
| plan 전환 | 해당 없음 | Phase 6 신규 Task 불필요 |
**최종 결론:** Phase 6 추가 수정 없음
**남은 항목:** 없음. `P7-R2` 통합 재판정에서 기존 FanTalk/common 회귀만 확인한다.
## 10. 3차 정적 리뷰 및 판정 — 2026-07-28
### 리뷰 범위와 방식
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
- 기준 문서: PRD Feature F, plan Phase 6, OpenAPI FanTalk 2개 operation
- 검토 범위: 관리자 목록·답변 facade, 공개 v2 pagination policy, strict JSON, root ownership과 event 테스트
- 검증 방식: 코드·문서·테스트 정적 추적. 컴파일과 테스트는 실행하지 않았다.
### 발견 사항과 판정
확정 발견 사항 없음.
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 목록 query | 충족 | 공개 v2 page/size 보정 정책 재사용 |
| reply JSON | 충족 | malformed/blank/unknown-field 저장 전 거부 |
| root/ownership | 충족 | active owner root만 허용하고 nested/cross-owner 차단 |
| writer/event | 충족 | target creator 저장과 언어 감지 event 유지 |
| plan 전환 | 해당 없음 | Phase 6 신규 Task 불필요 |
**최종 결론:** Phase 6 추가 수정 없음
**남은 항목:** `P7-R3`에서 FanTalk/common 회귀를 통합 재검증한다.
## 11. 4차 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
- 기준 문서: PRD Feature F, plan Phase 6, OpenAPI FanTalk 2개 operation
- 검토 범위: 관리자 root/reply 목록, pagination policy, strict reply JSON, root ownership·event
- 검증 방식: 코드·schema·테스트 정적 대조. 컴파일과 테스트는 실행하지 않았다.
### 발견 사항과 판정
확정 발견 사항 없음.
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 목록/pagination | 충족 | 공개 v2 query policy와 root/reply owner query 유지 |
| reply JSON | 충족 | malformed·blank·unknown field를 저장 전에 거부 |
| root/ownership | 충족 | active owner root만 답변 허용 |
| writer/event | 충족 | target creator를 writer/creator로 저장하고 언어 감지 발행 |
| plan 전환 | 해당 없음 | Phase 6 신규 Task 불필요 |
**최종 결론:** Phase 6 추가 수정 없음
**남은 항목:** 없음.
## 12. 5차 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위
- OpenAPI FanTalk 2개 operation과 controller/facade/query 구현
- reply request의 required/non-null, malformed·unknown·blank 입력 처리
- pagination 보정, root ownership, writer/event 경계
### 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 목록/pagination | 충족 | 공개 v2 query policy 보정과 owner root/reply query 유지 |
| reply JSON | 충족 | request는 non-null `String content` 하나이며 null/malformed/blank/unknown을 저장 전 거부 |
| root/ownership | 충족 | active owner root만 답변 허용 |
| primitive nullability | 해당 없음 | FanTalk JSON request에 primitive 필드가 없음 |
| plan 전환 | 해당 없음 | Phase 6 신규 Task 불필요 |
사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다.
**최종 결론:** Phase 6 신규 수정 없음
**남은 항목:** 없음.
## 13. 팬 작성 FanTalk 원글 삭제 후속 검토 — 2026-07-29
### 확인 결과
- **`REV-049` / High / 구현 대기:** 신규 v2 관리자 경계에 target 채널의 팬 작성 FanTalk root를 삭제할
operation이 없다.
- 삭제는 팬 작성 root row만 `isActive=false`로 변경하고 연결 creator reply row는 유지한다.
- target AI가 작성한 row, reply row, 다른 채널 root는 거부하며 이미 비활성인 같은 target 팬 root는 성공 no-op이다.
- 캐릭터 직접 댓글 삭제는 v2 미사용 API로 별도 구현하지 않는다.
### plan 전환
- 신규 Task: `Task 6.7` / `P6-R2`
- Gate: `P6-R2-GATE`
- 범위 밖: hard delete·cascade, FanTalk 원글 작성, public v2 endpoint 변경
사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다.
**최종 결론:** Phase 6 팬 작성 FanTalk 원글 삭제 구현 필요
**다음 Goal:** `P6-R2`.
## 14. 팬 작성 FanTalk 원글 삭제 구현 검토 — 2026-07-29
### 구현 결과
- `DELETE /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}`를 추가했다.
- target 채널의 팬 작성 root만 `CreatorCheers.isActive=false`로 변경한다.
- 연결 creator reply row는 변경하지 않고, 목록·`fanTalkCount`에서는 삭제된 root가 제외된다.
- target AI 작성 root, reply row, 다른 채널 root, 비활성 target, 누락 ID는 400/no mutation으로 거부한다.
- 같은 target의 이미 비활성인 팬 root는 200 no-op으로 처리한다.
### 실행한 검증
| 명령 | 결과 | 핵심 증거 |
|---|---|---|
| `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.AiCharacterAdminFanTalkDeleteTest` | 성공 | RED 6건 미구현 route 실패 확인 후 GREEN focused `BUILD SUCCESSFUL in 29s` |
| `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest` | 성공 | DELETE 인가 matrix 보강 후 `BUILD SUCCESSFUL in 29s` |
| `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.*' --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest` | 성공 | FanTalk/common 영향 범위 회귀 `BUILD SUCCESSFUL in 58s` |
| `./gradlew ktlintCheck` | 성공 | `BUILD SUCCESSFUL in 14s` |
| `git diff --check` | 성공 | 출력 없음 |
**최종 결론:** `REV-049` 처리 완료. Phase 6 후속 Gate 완료.
**다음 Goal:** `P7-R6`.
## 15. 6차 통합 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 문서: PRD Feature F, OpenAPI FanTalk 3개 operation
- 검토 범위: FanTalk controller의 query/JSON mapping, reply strict parser·저장 경계와 관련 테스트
- 기준 상태: 현재 working tree
- 검증 방식: 문서·코드·테스트 정적 대조. 사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다.
### `REV-054` — High — 처리 완료
- OpenAPI는 reply POST의 requestBody media type을 `application/json` 하나로 정의하고 415 response를 선언한다.
- reply controller mapping에는 `consumes = [MediaType.APPLICATION_JSON_VALUE]`가 없다.
- body를 `String`으로 받으므로 미지원 media type이 mapping 단계에서 차단되지 않고 handler/parser까지 진입할 수 있다.
- 기존 reply 계약·생성·ownership 테스트는 `application/json` 요청만 사용해 415 `Accept` header와
insert/event no-side-effect를 고정하지 않는다.
- 외부 HTTP 요청 수용 범위와 명시된 415가 달라 High로 판정한다.
### plan 전환
- 신규 Task: `Task 6.8` / `P6-R3`
- Gate: `P6-R3-GATE`
- 최소 수정: reply POST mapping에 JSON `consumes` 추가
- 완료 조건: 정상 JSON 축약 응답 회귀, 미지원 media type의 KO/EN/JA 415 envelope, `Accept` header,
reply insert/event 0회
- 범위 밖: strict parser·root/ownership·언어 감지, 목록/삭제, OpenAPI·legacy/public API 변경
### `P6-R3` / `P6-R3-GATE` 처리 결과
- RED: production 변경 전 `AiCharacterAdminFanTalkReplyContractTest`에 KO/EN/JA `text/plain` reply POST 415 matrix를
추가했고, focused 명령은 3개 invocation이 415 기대 실패로 `BUILD FAILED in 33s`였다.
- GREEN: reply POST mapping에 `consumes = [MediaType.APPLICATION_JSON_VALUE]`만 추가했다. strict parser,
root/ownership, 언어 감지, 목록/삭제, OpenAPI schema는 변경하지 않았다.
- Gate: 같은 focused 명령은 `BUILD SUCCESSFUL in 41s`, FanTalk/common 영향 범위와
`AiCharacterAdminErrorContractTest` 회귀는 `BUILD SUCCESSFUL in 47s`였다.
**최종 결론:** `REV-054` 처리 완료. Phase 6은 `P6-R4` 완료 전 종결할 수 없다.
**다음 Goal:** `P6-R4`.
## 16. FanTalk 답변 수정 계약 검토 — 2026-07-29
### 리뷰 범위와 근거
- 요청: FanTalk 답변을 수정하는 V2 관리자 API 추가, 레거시 `PUT /explorer/profile/cheers` 계약 유지
- 레거시 근거: `ExplorerController.modifyCheers`, `ExplorerService.modifyCheers`, `PutWriteCheersRequest`,
`CreatorChannelFanTalkResponse`
- 현재 V2 근거: FanTalk controller/facade/repository/DTO와 목록·답변 작성·팬 원글 삭제 3개 operation
- 검증 방식: 문서·레거시·현재 V2 코드 정적 대조와 `./gradlew tasks --all` 프로젝트 인식 확인. 사용자 지시에 따라
컴파일·테스트·lint는 실행하지 않았다.
### `REV-059` — High — FanTalk 답변 수정 V2 관리자 operation 부재
- 처리 전 V2 관리자 FanTalk에는 선택한 AI 캐릭터가 작성한 기존 reply의 내용이나 활성 상태를 수정할 route가 없었다.
- 레거시 request는 `cheersId`와 optional/nullable `content`, `isActive`를 받고 non-null 값만 반영한다. 두 필드를
함께 입력할 수 있고 `{}` 또는 explicit null은 성공 no-op이다.
- 레거시는 비활성 row도 조회하므로 `isActive=true` 재활성화가 가능하고, 수정 시 `languageCode`와 event를 변경하지 않는다.
- 성공 `data``CreatorChannelFanTalkResponse`이며 reply row를 매핑하므로 `fanTalkId`는 reply ID,
`creatorReplies`는 빈 배열이다.
- 관리자 V2에서는 위 계약에 `characterId`, root `fanTalkId`, `replyId` path를 적용하고 target AI가 writer이자
creator이며 지정한 활성 root의 direct child인 reply로 소유 경계를 강화해야 한다.
### 확정 계약과 plan 전환
- 신규 operation:
`PUT /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}`
- request: optional/nullable `content`, `isActive`; 동시 입력과 빈 객체 no-op 허용, JSON-only·미지 필드 거부
- response: 레거시 `CreatorChannelFanTalkResponse` 필드 형태
- inactive reply 재활성화 허용, inactive root·cross-target/root·팬 작성 row·direct-parent mismatch는 400/no mutation
- 신규 Task: `Task 6.9` / `P6-R4`
- Gate: `P6-R4-GATE`
- OpenAPI 상태: 전체 37개 operation 모두 `implemented`
### 구현 결과와 Gate
- RED: `AiCharacterAdminFanTalkReplyUpdateTest``AiCharacterAdminFanTalkReplyUpdateContractTest` 신규 15건이
미구현 route 404로 `BUILD FAILED in 49s`였다.
- GREEN: 신규 PUT route, JSON `consumes`, strict request DTO, active root와 target AI writer/creator direct reply를
검증하는 repository query, non-null field만 반영하는 facade를 추가했다.
- Gate: focused 재실행은 `BUILD SUCCESSFUL in 42s`, FanTalk/common/legacy 영향 범위 회귀는
`BUILD SUCCESSFUL in 1m 2s`, OpenAPI status 집계는 37개 모두 `implemented`, `ktlintCheck`
`BUILD SUCCESSFUL in 25s`, `git diff --check`는 출력이 없었다.
**최종 결론:** `REV-059` 처리 완료. Phase 6의 P6-R3/P6-R4 후속 보완은 완료됐다.
**다음 Goal:** `P7-R8`.
## 17. 7차 통합 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 문서: PRD Feature F, OpenAPI FanTalk 4개 operation
- 검토 범위: 목록 pagination, 답변 작성·수정 JSON 경계, 팬 root 삭제, target/root/reply ownership
- 검증 방식: 현재 working tree의 문서·코드·관련 테스트를 정적으로 대조했다. 사용자 요청에 따라 컴파일과 테스트는
실행하지 않았다.
### 판정
| 항목 | 결과 | 근거 |
|---|---|---|
| route/operation | 충족 | FanTalk 4개 OpenAPI operation과 controller mapping 일치 |
| JSON request | 충족 | 답변 작성·수정의 JSON-only mapping과 strict unknown-field 거부 유지 |
| ownership/state | 충족 | active root, target AI direct reply, fan root soft delete 조건 유지 |
| 7차 multipart finding 영향 | 없음 | FanTalk에는 multipart request가 없음 |
### finding 및 plan 전환
- 신규 Phase 6 finding 없음.
- Phase 6 신규 Task/Gate 없음.
**최종 결론:** Phase 6 추가 수정 없음
**남은 항목:** `P7-R9` 통합 재판정.
## 18. 8차 통합 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 문서: PRD Feature F/Edge Cases, OpenAPI FanTalk DELETE description, `api-contract.md`
- 검토 범위: FanTalk root delete facade/repository와 `AiCharacterAdminFanTalkDeleteTest`
- 기준 상태: 현재 working tree
- 리뷰어/상태: Codex / 판정 완료
- 검증 방식: 문서·코드·테스트 소스 정적 대조. 사용자 지시에 따라 컴파일과 테스트는 실행하지 않았다.
### `REV-070` — Low — 비활성 팬 root 삭제 설명 상충
- OpenAPI `api-contract.openapi.json:785`
`AiCharacterAdminFanTalkDeleteTest.kt:76-101`은 같은 target의 이미 비활성인 팬 root 삭제를 성공 no-op으로
정의한다. `api-contract.md:229-231`도 같은 결과를 설명한다.
- 반면 PRD `prd.md:227-228`은 비활성 root를 400 거부 대상으로 묶고, `api-contract.md:41`도 “활성 root만”이라고
적어 같은 문서 안에서 뒤쪽 no-op 설명과 상충한다.
- 기계 계약인 OpenAPI와 현재 구현·회귀가 일치하므로 runtime 변경보다 설명 문서를 no-op 계약에 맞추는 최소 보완이
적절하다. 실행 오류가 아니라 문서 불일치이므로 Low로 판정한다.
- 이 판정은 OpenAPI를 기계 계약 원본으로 두고 구현·테스트와 일치하는 쪽을 유지한 결과다. PRD의 400 문장이 최신 제품
의도라면 `P6-R5`를 실행하기 전에 OpenAPI와 runtime/test까지 변경하는 별도 범위로 재확정해야 한다.
### plan 전환
| 항목 | 내용 |
|---|---|
| 신규 Task | `Task 6.10` / `P6-R5` |
| Gate | `P6-R5-GATE` |
| 변경 | PRD와 `api-contract.md`의 상충 문장만 OpenAPI/runtime no-op 계약에 동기화 |
| TDD 예외 | 문서 전용 Task이며 OpenAPI·구현·test 소스 정적 대조로 검증 |
| 범위 제한 | runtime/test/OpenAPI·legacy/public 변경 없음 |
**최종 결론:** Phase 6 문서 보완 필요 — `REV-070` 확정
**다음 Goal:** `P6-R5` (`P5-R10-GATE` 완료 후).
## 19. 8차 후속 문서 정합화 및 Gate — 2026-07-29
- 무엇을: `REV-070`의 FanTalk 비활성 팬 root 삭제 설명 상충을 정리했다.
- 왜: OpenAPI·구현·`AiCharacterAdminFanTalkDeleteTest`는 같은 target의 이미 비활성인 팬 root 삭제를 200 `data:null` no-op으로 고정하지만 PRD 일부 문장이 400 거부로 설명했기 때문이다.
- 어떻게: PRD Edge Cases와 `api-contract.md` 삭제 설명을 같은 target 비활성 팬 root no-op, creator root·reply·다른 target·미존재 root 400으로 동기화했다. runtime/test/OpenAPI는 변경하지 않았다.
- 결과: 문서-only 보완으로 `REV-070` 처리 완료. 정적 대조와 diff check 결과는 `P7-R10-GATE`에 통합 기록한다.
**최종 결론:** `REV-070` 처리 완료. Phase 6 후속 Gate 완료.
**남은 항목:** 없음.
## 20. 9차 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 문서: PRD Feature F/Edge Cases, OpenAPI FanTalk 4개 operation
- 검토 범위: 목록, 답변 작성·수정, 팬 root 삭제, target/root/direct reply ownership
- 검증 방식: 현재 working tree의 문서·production·test 소스를 정적으로 대조했다. 사용자 지시에 따라 컴파일과
테스트는 실행하지 않았다.
### 판정 및 plan 전환
- FanTalk 4개 operation과 pagination, strict JSON, target AI reply ownership, root 삭제 no-op 계약을 대조했다.
- 기존 완료 finding 이후 신규 확정 finding은 없다.
- Phase 6 신규 Task/Gate 없음.
**최종 결론:** Phase 6 추가 수정 없음.
**남은 항목:** Phase 3 보완 뒤 `P7-R11` 통합 재판정.
## 21. 10차 정적 리뷰 및 판정 — 2026-07-30
### 리뷰 범위와 방식
- 기준 문서: PRD Feature F, OpenAPI FanTalk 4개 operation
- 검토 범위: 목록, creator reply 작성·수정, 팬 root 삭제와 target/root/direct reply ownership
- 검증 방식: 현재 working tree의 문서·production·test 소스를 정적으로 대조했다. 사용자 지시에 따라 컴파일과
테스트는 실행하지 않았다.
### 판정
- FanTalk 4개 operation과 controller mapping, 공개 v2 page/size 보정·응답 필드가 일치한다.
- 답변 작성은 active root와 target creator writer/creator를, 수정은 target의 active root direct reply를 검증한다.
- 팬 root row-only soft delete와 동일 target 비활성 root 성공 no-op 계약이 문서·구현에 일치한다.
- 신규 확정 finding이 없어 Phase 6 회귀 수정 Task/Gate를 추가하지 않는다.
**최종 결론:** Phase 6 요구사항 충족, 추가 수정 없음.
**남은 항목:** 없음.

View File

@@ -0,0 +1,759 @@
# Phase 7 통합·문서 정합성 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 7 / 23개 operation 통합 상태와 문서 추적성 |
| 기준 commit 또는 working tree | `2f93e2c9` + 현재 Phase 2~7 working tree |
| 리뷰 일자 | 2026-07-28 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md`, `plan-task.md`, `api-contract.md`, `api-contract.openapi.json` |
| 리뷰 상태 | 후속 수정 및 Gate 완료 |
## 2. 리뷰 목적과 범위
### 목적
- Phase 7 완료 기록과 실제 신규 prefix controller/OpenAPI operation 수를 대조한다.
- 계획, 사람이 읽는 계약 설명, OpenAPI 구현 상태 metadata가 현재 구현 상태를 정확히 나타내는지 확인한다.
### 포함 범위
- 신규 prefix controller mapping 전체
- OpenAPI operation과 `x-implementation-status`
- plan 현재 상태/Endpoint Contract Summary/Phase 7 Progress
- `api-contract.md` 구현 현황
### 제외 범위
- Phase 2~6 finding의 production 수정, API schema 변경
## 3. 판정 기준
| 심각도 | 기준 |
|---|---|
| Blocker | 인수·배포 판정을 무효화하는 미구현 핵심 기능 |
| High | operation 누락 또는 공개 schema 불일치 |
| Medium | 완료 상태·생성 client 판단에 영향을 주는 metadata/문서 불일치 |
| Low | 비핵심 설명·형식 정합성 |
## 4. 검토한 근거
| 근거 | 판정 |
|---|---|
| OpenAPI 정적 집계 | 23개 operation: Character 4, AudioContent 5, Series 9, Community 3, FanTalk 2 |
| controller mapping 정적 집계 | 후속 Gate 후 Character 4, AudioContent 5, Series 9, Community 3, FanTalk 2로 총 23개 |
| OpenAPI status 정적 집계 | 후속 수정 후 23개 `implemented` |
| `api-contract.md:9`~`:12` | 후속 수정 후 endpoint 23개, route 구현 23개, 구현 완료 23개, 예정 0개 |
| plan Endpoint Contract Summary | 후속 수정 후 5개 domain 모두 구현 완료로 표시 |
| plan `P7-GATE`와 후속 기록 | 23개 operation 구현 완료로 판정 |
### 실행한 검증
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|---|---|---|
| `jq` operation/status 집계 | 성공 | operation 23, status 9/14 확인 |
| controller annotation `rg` 집계 | 성공 | mapping 24, 초과 1개는 Series DELETE |
| `P7-R1` 후속 `jq` operation/status 집계 | 성공 | operation 23, implemented 23 |
| `P7-R1` 후속 controller annotation 집계 | 성공 | mapping 23 |
| OpenAPI validate/client 생성/compile | 성공 | validate 이슈 없음, TypeScript compile exit 0 |
| Gradle/문서 diff | 성공 | `./gradlew tasks --all` 성공, `git diff --check` 출력 없음 |
| `P7-R1-GATE` 최종 대조 | 성공 | OpenAPI 23개 implemented, controller mapping 23개, 미처리 finding 0건 |
## 5. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-029` | Medium | 처리 완료 | 구현 완료 기록과 계약 metadata/현황 문서 불일치 | `Task 7.3` | `P7-R1-GATE` |
## 6. 발견 사항 상세
### REV-029 — 구현 상태 metadata와 완료 기록 불일치
- **심각도:** Medium
- **상태:** 처리 완료
- **관련 요구사항:** Phase 7 API contract·diff·문서 추적성 완료 조건
- **관련 계약:** OpenAPI 23개 operation과 실제 controller mapping 일치
- **소유 Task:** `Task 7.3`, `P7-R1`
**관찰 내용**
Phase 7 완료 기록은 모든 operation 구현을 선언하지만 `api-contract.md`, plan Endpoint Contract Summary,
OpenAPI `x-implementation-status`는 계약 확정 당시의 9개 정합화 필요/14개 예정 상태를 유지한다. 실제 controller는
23개가 아니라 계약 밖 Series DELETE를 포함한 24개다.
**후속 수정 결과**
`P4-R1-GATE`에서 계약 밖 Series DELETE route를 제거했고, `P7-R1`에서 plan/API 설명/OpenAPI status를 실제 구현 상태와
동기화했다. OpenAPI는 23개 operation 모두 `implemented`이며, 신규 prefix controller mapping도 23개로 일치한다.
**영향**
문서 독자와 생성 도구가 구현 완료 여부를 다르게 판단하며, Phase 7의 “OpenAPI와 route 일치” 완료 증거를 현재 정적 집계로
재현할 수 없다.
**권장 조치**
먼저 `P4-R1-GATE`에서 계약 밖 route를 제거해 controller를 23개로 맞춘다. 나머지 Phase 후속 Gate가 끝난 뒤
`P7-R1`에서 plan/API 설명/OpenAPI status를 모두 23개 `implemented`로 동기화하고 validator와 client 생성을 재검증한다.
path/request/response schema는 변경하지 않는다.
## 7. plan·goal 전환
`plan-task.md` Phase 7에 `Task 7.3` / `P7-R1``P7-R1-GATE`를 추가했다. `P7-R1`은 Phase 2~6 후속 Gate가 모두
끝난 뒤 실행한다.
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| OpenAPI operation 수 | 충족 | 23개 |
| controller mapping 수 | 충족 | 후속 Gate 후 23개 |
| 구현 상태 문서 | 충족 | plan/API 설명/OpenAPI status 모두 23개 구현 완료로 동기화 |
| dependency/DDL 신규 변경 | 신규 finding 없음 | 정적 변경 범위에서 관련 추가 없음 |
| 실행 검증 | 충족 | jq, validator, TypeScript client 생성·compile, Gradle tasks, diff check 성공 |
**최종 결론:** Phase 7 후속 리뷰 종료
**남은 항목:** 없음.
## 9. 2차 정적 리뷰 및 판정 — 2026-07-28
### 리뷰 정보와 범위
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
- 기준 문서: PRD Acceptance Criteria, plan Phase 7, OpenAPI 23개 operation
- 검토 범위: Phase별 신규 finding 종결 상태, controller/OpenAPI operation 수, 구현 status, dependency/DDL·최종 Gate 조건
- 검증 방식: `rg`, `jq`, `git diff` 기반 정적 점검. 컴파일과 테스트는 실행하지 않았다.
### 발견 사항과 판정
Phase 7 자체의 신규 독립 결함은 없다. 정적 집계는 OpenAPI 23개 operation과 23개 `implemented`, controller mapping
23개를 유지한다. Phase 3~5의 `REV-030`~`REV-033`은 모두 처리 완료됐고, targeted·전체 회귀·lint·diff 검증도 통과했다.
### plan·goal 전환
`plan-task.md` Phase 7에 `Task 7.4` / `P7-R2``P7-R2-GATE`를 추가했다. 이 Task는 독립 production 수정이 아니라
`P3-R10-GATE`, `P4-R2-GATE`, `P5-R2-GATE` 뒤 targeted·전체 회귀와 문서/operation 상태를 재판정한다.
`P7-R2``P7-R2-GATE`를 완료 처리했고, plan 상태를 `구현 완료`로 되돌렸다.
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| OpenAPI operation/status | 충족 | 23개 operation, 23개 `implemented` |
| controller mapping | 충족 | Character 4 + AudioContent 5 + Series 9 + Community 3 + FanTalk 2 |
| 미처리 finding | 충족 | `REV-030`~`REV-033` 처리 완료 |
| 최종 Gate | 충족 | `P7-R2`, `P7-R2-GATE` 완료 |
| 실행 검증 | 충족 | targeted, 전체 회귀, lint, OpenAPI/controller/diff 점검 통과 |
**최종 결론:** 통합 재판정 및 Gate 완료
**남은 항목:** 없음.
## 16. 후속 기능 통합 최종 판정 — 2026-07-29
### Phase별 결과
| Phase | 판정 | finding / 후속 Goal |
|---:|---|---|
| 1 | 변경 없음 | 공통 ADMIN·resolver·오류 경계 재사용 |
| 2 | 처리 완료 | `REV-044`, `P2-R9` / `P2-R9-GATE` |
| 3 | 처리 완료 | `REV-045`, `P3-R13` / `P3-R13-GATE` |
| 4 | 처리 완료 | `REV-046`~`REV-047`, `P4-R5`~`P4-R6-GATE` |
| 5 | 처리 완료 | `REV-048`, `P5-R5` / `P5-R5-GATE` |
| 6 | 처리 완료 | `REV-049`, `P6-R2` / `P6-R2-GATE` |
| 7 | 통합 재판정 완료 | `P7-R6` / `P7-R6-GATE` |
### 통합 판정
- 기존 23개 route와 후속 13개 operation을 합쳐 OpenAPI 계약은 36개다.
- 현재 상태는 36개 operation 모두 `implemented`다.
- 캐릭터 직접 댓글 API는 v2 미사용 결정에 따라 operation과 Task를 추가하지 않는다.
- Phase 2~6 신규 Gate 완료 뒤 36개 operation/mapping/`implemented`, 공통 보안·오류, actor·ownership,
row-only soft delete와 legacy/public 회귀를 `Task 7.8`에서 재판정했다.
targeted 회귀, 전체 `./gradlew test`, `ktlintCheck`, OpenAPI/controller/diff 정적 검증이 모두 성공했다.
**최종 결론:** 36개 operation 통합 재판정 및 Gate 완료
**남은 항목:** 없음.
## 12. 4차 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 정보와 범위
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
- 기준 문서: PRD Acceptance Criteria, plan Phase 1~7, OpenAPI 23개 operation
- 검토 범위: Phase별 runtime 경계, operation/status/mapping, 완료 상태표·Task header, dependency/DDL 범위
- 검증 방식: `sed`, `rg`, `jq`, `git diff` 기반 정적 점검. 컴파일과 테스트는 실행하지 않았다.
### 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-038` | Low | 처리 완료 | 완료된 후속 Task 헤더와 상단 완료 상태가 모순됨 | `Task 7.6` | `P7-R4` |
| `REV-039` | Low | 처리 완료 | Phase 4 콘텐츠 해제 설명이 OpenAPI/controller route와 다름 | `Task 7.6` | `P7-R4` |
### `REV-038` — 완료 Task 헤더와 현재 상태 불일치
- **심각도:** Low
- **상태:** 처리 완료
- **처리 상태:** 처리 완료 (`P7-R4`)
- **관련 요구사항:** 작업절차의 구현 완료 즉시 Task 체크박스 갱신, 문서유지보수의 완료 상태 동기화
- **관련 계약:** plan 상단 현재 상태·Goal Progress·Phase별 Task 완료 증거
- **소유 Task:** `Task 7.6`, `P7-R4`
**관찰 내용**
`Task 2.13`, `Task 3.21`, `Task 4.9`, `Task 7.5`는 하위 단계·Gate·2026-07-29 검증 기록에서 완료됐지만,
Task 헤더는 `[ ]`다. 반면 상단 표는 각 Phase를 전체 완료로 표시해 동일 문서 안의 상태가 모순된다.
**근거**
- 계획: 네 Task header의 `[ ]`
- Gate: `P2-R7-GATE`, `P3-R11-GATE`, `P4-R3-GATE`, `P7-R3-GATE``[x]`
- Progress: 2026-07-29 후속 수정·통합 검증 완료 기록
**영향**
후속 agent가 이미 완료된 기능 Task를 다시 실행하거나 Phase 완료 조건을 잘못 판정할 수 있다.
**권장 조치**
기존 완료 증거를 삭제하지 않고 네 Task header, 상단 상태표와 Progress만 같은 완료 상태로 동기화한다.
### `REV-039` — Phase 4 DELETE 설명의 stale path/body
- **심각도:** Low
- **상태:** 처리 완료
- **처리 상태:** 처리 완료 (`P7-R4`)
- **관련 요구사항:** OpenAPI를 request/response의 기계 검증 가능한 단일 기준으로 사용
- **관련 계약:** `removeAiCharacterSeriesContent`
- **소유 Task:** `Task 7.6`, `P7-R4`
**관찰 내용**
plan Phase 4 endpoint 설명은 콘텐츠 해제를 `DELETE /series/{seriesId}/contents`
`RemoveContentToTheSeriesRequest(contentId)` body로 적는다. OpenAPI와 실제 controller는
`DELETE /series/{seriesId}/contents/{contentId}`이며 request body가 없다.
**근거**
- 계획: Phase 4 `API endpoint와 request/response contract`
- OpenAPI: operationId `removeAiCharacterSeriesContent`
- 코드: `AiCharacterAdminSeriesController.removeContent`
**영향**
runtime은 올바르지만 계획만 읽는 후속 구현·클라이언트 작업이 폐기된 body route를 사용할 수 있다.
**권장 조치**
production/OpenAPI는 변경하지 않고 Phase 4 설명만 현재 path parameter 계약으로 정정한다.
### plan·goal 전환
두 항목은 모두 문서 정합성이고 같은 파일에서 최소 수정할 수 있어 `plan-task.md` Phase 7의
`Task 7.6` / `P7-R4`로 묶었다.
### 실행한 정적 검증
- `jq empty api-contract.openapi.json` — 성공.
- OpenAPI 23개 operation, 고유 operationId 23개, `implemented` 23개, 200 response 누락 0개 — 성공.
- controller mapping — Character 4 + AudioContent 5 + Series 9 + Community 3 + FanTalk 2, 합계 23개.
- `git diff --check` — 출력 없음.
- 신규 dependency/DDL 파일 변경 — 없음.
- Gradle·컴파일·테스트 — 사용자 요청에 따라 실행하지 않음.
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| Phase 1~6 runtime | 충족 | 신규 기능 finding 없음 |
| OpenAPI operation/status | 충족 | 23개 operation·고유 ID·implemented 유지 |
| controller mapping | 충족 | domain별 합계 23개 |
| 완료 상태 문서 | 충족 | `REV-038` 처리 완료 |
| Phase 4 route 설명 | 충족 | `REV-039` 처리 완료 |
| plan 반영 | 충족 | `Task 7.6`, `P7-R4` 추가 |
**최종 결론:** 기능 추가 수정 없음, 문서 정합성 goal 완료
**남은 항목:** 없음.
### `P7-R4` 처리 결과
- `Task 2.13`, `Task 3.21`, `Task 4.9`, `Task 7.5`, `Task 7.6` 헤더를 완료 상태로 동기화했다.
- Phase 4 시리즈 콘텐츠 해제 설명을 `DELETE /series/{seriesId}/contents/{contentId}`와 request body 없음으로 정정했다.
- 검증: `./gradlew tasks --all``BUILD SUCCESSFUL in 767ms`, OpenAPI 23개 operation/status `jq` assertion은 `true`, 미완료 Task header `rg``git diff --check`는 출력 없음, controller mapping은 23개였다.
### `P7-R2` 실행 검증
- `./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*'``BUILD SUCCESSFUL in 2m 45s`.
- `./gradlew test``BUILD SUCCESSFUL in 7m 58s`.
- `./gradlew ktlintCheck``BUILD SUCCESSFUL in 1s`.
- OpenAPI operation/status `jq` assertion — `true`.
- controller mapping 23개 assertion, dependency/DDL diff, `git diff --check` — 출력 없이 통과.
## 10. 3차 정적 리뷰 및 판정 — 2026-07-28
### 리뷰 정보와 범위
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
- 기준 문서: PRD Acceptance Criteria, plan Phase 7, OpenAPI 23개 operation
- 검토 범위: Phase별 신규 finding, operation/status/mapping, 최종 완료 Gate와 dependency/DDL 범위
- 검증 방식: `rg`, `jq`, `git diff` 기반 정적 점검. 컴파일과 테스트는 실행하지 않았다.
### 발견 사항과 판정
Phase 7 자체의 신규 독립 결함은 없다. OpenAPI는 23개 operation과 23개 `implemented` status를 유지하고 controller
mapping 수도 23개다. 다만 Phase 2~4의 `REV-034`~`REV-037`이 미처리이므로 현재 최종 완료 판정은 유지할 수 없다.
### plan·goal 전환
`plan-task.md` Phase 7에 검증 전용 `Task 7.5` / `P7-R3``P7-R3-GATE`를 추가했다. 이 Goal은
`P2-R7-GATE`, `P3-R11-GATE`, `P4-R3-GATE` 완료 후 targeted·전체 회귀와 문서/operation 상태를 fresh 재판정한다.
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| OpenAPI operation/status | 충족 | 23개 operation, 23개 `implemented` 유지 |
| controller mapping | 충족 | Character 4 + AudioContent 5 + Series 9 + Community 3 + FanTalk 2 |
| 독립 Phase 7 결함 | 없음 | route/schema/dependency/DDL 추가 문제 없음 |
| 최종 완료 상태 | 보류 | `REV-034`~`REV-037` 미처리 |
| plan 반영 | 충족 | `Task 7.5`, `P7-R3`, `P7-R3-GATE` 추가 |
| 실행 검증 | 미실행 | 사용자 요청에 따라 컴파일·테스트 미실행 |
**최종 결론:** Phase 7 통합 재판정 요청(당시 판정, 15절에서 처리 완료)
**남은 항목:** `P2-R7-GATE``P3-R11-GATE``P4-R3-GATE``P7-R3``P7-R3-GATE`.
## 11. 3차 통합 재판정 및 Gate — 2026-07-29
### 발견 사항과 판정
Phase 7 자체의 신규 독립 결함은 없다. `REV-034`~`REV-037`은 각 소유 Phase에서 처리 완료됐고, OpenAPI 23개 operation과 23개 `implemented` status 및 controller mapping 23개를 유지한다.
### 실행 검증
- `./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*'``BUILD SUCCESSFUL in 2m 25s`.
- `./gradlew test``BUILD SUCCESSFUL in 6m 54s`.
- `./gradlew ktlintCheck``BUILD SUCCESSFUL in 18s`.
- OpenAPI operation/status `jq` assertion — `true`.
- controller mapping count — 23.
- `git diff --check` — 출력 없음.
- 변경 파일명 점검 결과 신규 dependency/DDL 파일 변경 없음.
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| `REV-034`~`REV-037` | 충족 | Phase 2~4 후속 Gate 처리 완료 |
| OpenAPI operation/status | 충족 | 23개 operation, 23개 `implemented` |
| controller mapping | 충족 | Character 4 + AudioContent 5 + Series 9 + Community 3 + FanTalk 2 |
| targeted/전체 회귀 | 충족 | targeted와 전체 Gradle test 성공 |
| lint/diff/dependency/DDL | 충족 | ktlint 성공, diff check 출력 없음, 신규 dependency/DDL 없음 |
**최종 결론:** 통합 재판정 및 Gate 완료
**남은 항목:** 없음.
## 13. 5차 정적 리뷰 및 판정 — 2026-07-29
### Phase별 결과
| Phase | 판정 | finding / 후속 Goal |
|---:|---|---|
| 1 | 신규 finding 없음 | 공통 보안·resolver·오류 경계 유지 |
| 2 | 후속 처리 요청(당시 판정) | `REV-040`, `P2-R8` / `P2-R8-GATE` |
| 3 | 후속 처리 요청(당시 판정) | `REV-041`, `P3-R12` / `P3-R12-GATE` |
| 4 | 후속 처리 요청(당시 판정) | `REV-042`, `P4-R4` / `P4-R4-GATE` |
| 5 | 후속 처리 요청(당시 판정) | `REV-043`, `P5-R3` / `P5-R3-GATE` |
| 6 | 신규 finding 없음 | FanTalk request에는 primitive 필드 없음 |
| 7 | 통합 재판정 요청(당시 판정) | `P7-R5` / `P7-R5-GATE` |
### 통합 판정
- OpenAPI operation과 controller mapping은 Character 4 + AudioContent 5 + Series 9 + Community 3 + FanTalk 2,
합계 23개를 유지한다.
- 신규 finding은 route 수가 아니라 Phase 2~5 multipart JSON의 primitive required/non-null 의미에 있다.
- 전역 Jackson 정책은 legacy endpoint까지 영향을 넓히므로 각 v2 request 경계의 최소 보완으로 계획했다.
- 네 Phase Gate 완료 전에는 문서의 `구현 완료` 최종 판정을 유지하지 않는다.
- 사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다.
**최종 결론:** `REV-040`~`REV-043` 처리 뒤 통합 재판정 요청(당시 판정, 15절에서 처리 완료)
**당시 Goal:** `P2-R8`.
## 14. Community 목록 계약 변경 영향 판정 — 2026-07-29
### 변경 영향
- `DEC-P5-LIST-001`에 따라 Community GET의 route 수는 유지되지만 query와 성공 response schema가 변경됐다.
- OpenAPI operation은 runtime 정합화 전까지 `implemented-contract-alignment-required`로 표시한다.
- `P7-R5`는 기존 `REV-040`~`REV-043`뿐 아니라 `P5-R4-GATE`의 timezone 제거, pagination wrapper,
active owner count·hasNext 증거를 함께 대조해야 한다.
- 구현 완료 뒤 OpenAPI 23개 operation이 모두 `implemented`로 복구됐는지 확인한다.
**최종 결론:** Phase 7 통합 재판정 시작 조건에 `P5-R4-GATE` 추가
**다음 Goal:** 기존 실행 순서대로 `P2-R8`.
## 15. 5차 통합 재판정 및 Gate — 2026-07-29
### Phase별 결과
| Phase | 판정 | finding / 후속 Goal |
|---:|---|---|
| 1 | 신규 finding 없음 | 공통 보안·resolver·오류 경계 유지 |
| 2 | 처리 완료 | `REV-040`, `P2-R8` / `P2-R8-GATE` |
| 3 | 처리 완료 | `REV-041`, `P3-R12` / `P3-R12-GATE` |
| 4 | 처리 완료 | `REV-042`, `P4-R4` / `P4-R4-GATE` |
| 5 | 처리 완료 | `REV-043`, `P5-R3` / `P5-R3-GATE`, `DEC-P5-LIST-001`, `P5-R4` / `P5-R4-GATE` |
| 6 | 신규 finding 없음 | FanTalk request에는 primitive 필드 없음 |
| 7 | 통합 재판정 완료 | `P7-R5` / `P7-R5-GATE` |
### 통합 판정
- Phase 2~5 focused와 package/common 영향 범위 회귀 증거가 모두 완료 상태다.
- Community 목록은 `timezone` query 없이 `totalCount/page/size/hasNext/items` wrapper를 반환하며 active owner count와 `hasNext` 계약을 유지한다.
- targeted 통합 test와 전체 `./gradlew test`, `ktlintCheck`가 성공했다.
- OpenAPI는 23개 operation과 23개 `implemented`를 유지하고 controller mapping도 23개다.
- 변경 파일 중 신규 dependency, migration, DDL, `.sql` 경로는 없다.
**최종 결론:** 통합 재판정 및 Gate 완료
**남은 항목:** 없음.
## 17. UTC 날짜 계약 변경 통합 판정 — 2026-07-29
### Phase별 결과
| Phase | 판정 | finding / 후속 Goal |
|---:|---|---|
| 1 | 영향 없음 | 공통 보안·resolver·오류 계약 변경 없음 |
| 2 | 영향 없음 | Character 계약 변경 없음 |
| 3 | 처리 완료 | `REV-050`, `P3-R14` / `P3-R14-GATE` |
| 4 | 영향 없음 | Series 계약 변경 없음 |
| 5 | 처리 완료 | `REV-051`, `P5-R6` / `P5-R6-GATE` |
| 6 | 영향 없음 | FanTalk 계약 변경 없음 |
| 7 | 통합 재판정 완료 | `P7-R7` / `P7-R7-GATE` |
### 통합 판정
- route와 operation 수는 36개로 유지된다.
- 최신 OpenAPI 상태는 `implemented` 36개, `alignment-required` 0개, `planned` 0개다.
- 영향 operation은 오디오 생성·상세·댓글·답글 4개와 커뮤니티 댓글·답글 2개다.
- OpenAPI의 query parameter/schema `Timezone`은 0개이고 생성 request의 `timezone` property도 제거했다.
- 생성 nullable `releaseDate`, 상세 nullable `releaseDate`, 댓글 `date`는 기존 필드명을 유지한
ISO-8601 UTC(`Z`) `date-time` 계약이다.
- 기존 `P7-R6-GATE`의 36개 구현 완료 판정에 UTC 날짜 계약 6개 operation 정합화 결과를 누적했다.
- 오디오 focused 재실행은 `BUILD SUCCESSFUL in 52s`, 커뮤니티 댓글 focused `--rerun-tasks` 재실행은
`BUILD SUCCESSFUL in 4m 33s`였고, 각 Gate의 영향 범위 회귀·lint·diff 성공 기록과 OpenAPI/controller 정적 집계를
대조했다.
**최종 결론:** UTC 날짜 계약 36개 operation 통합 재판정 및 Gate 완료
**남은 항목:** 없음.
## 18. 6차 통합 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 문서: PRD acceptance criteria, plan Phase 1~7, OpenAPI 36개 operation
- 검토 범위: Phase별 controller/facade/test, operation/mapping 수, request media type, pagination,
dependency·DDL 변경 범위
- 기준 상태: 현재 working tree
- 검증 방식: `jq`, `rg`, diff 기반 정적 대조. 사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다.
### Phase별 결과
| Phase | 판정 | finding / 후속 Goal |
|---:|---|---|
| 1 | 신규 finding 없음 | 공통 ADMIN 인가·resolver·오류/CORS 경계 유지 |
| 2 | 보완 필요 | `REV-055`, `P2-R10` / `P2-R10-GATE` |
| 3 | 보완 필요 | `REV-052`, `REV-056`, `P3-R15`~`P3-R16-GATE` |
| 4 | 보완 필요 | `REV-057`, `P4-R7` / `P4-R7-GATE` |
| 5 | 보완 필요 | `REV-053`, `REV-058`, `P5-R7`~`P5-R8-GATE` |
| 6 | 보완 필요 | `REV-054`, `P6-R3` / `P6-R3-GATE` |
| 7 | 재판정 대기 | `P7-R8` / `P7-R8-GATE` |
### 정적 검증 결과
- OpenAPI JSON 문법은 유효하고 operationId는 36개 모두 고유하다.
- 실제 controller mapping도 36개이며 신규 dependency·migration·DDL 변경은 없다.
- 영향 operation은 오디오 댓글·답글 GET 2개, 커뮤니티 댓글 POST·PUT 2개, FanTalk reply POST 1개와
Character·AudioContent·Series·Community multipart 생성·수정 8개로 총 13개다.
- OpenAPI의 36개 `x-implementation-status`는 모두 `implemented`지만 위 13개 HTTP 경계가 아직 계약과 달라
전체 구현 완료 판정은 보류한다.
### plan 전환 및 종결 조건
- 소유 Phase 순서: `P2-R10``P3-R15``P3-R16``P4-R7``P5-R7``P5-R8``P6-R3`
- 통합 재판정: `Task 7.10` / `P7-R8`, Gate `P7-R8-GATE`
- 종결 조건: `REV-052`~`REV-058` 처리 완료, 영향 13개 operation 회귀, 36개 contract/mapping 유지,
lint·diff와 dependency/DDL 무변경 확인
**최종 결론:** Phase 7 완료 판정 보류, 일곱 HTTP 계약 보완 후 통합 재판정 필요
**다음 Goal:** `P2-R10`.
## 19. FanTalk 답변 수정 계약 통합 영향 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 문서: PRD Feature F, OpenAPI 2.3.0, plan Phase 6·7
- 검토 범위: 신규 FanTalk 답변 수정 계약, 기존 36개 operation/mapping 상태, `P7-R8` 종결 조건
- 검증 방식: 문서·OpenAPI·controller mapping 정적 대조와 `./gradlew tasks --all` 프로젝트 인식 확인. 사용자 지시에
따라 컴파일·테스트·lint는 실행하지 않았다.
### 통합 판정
- OpenAPI 계약은 기존 36개 `implemented` operation에 FanTalk 답변 수정 `planned` operation 1개를 추가해 총 37개다.
- 현재 controller mapping은 36개이므로 신규 PUT이 구현되기 전 전체 계약 완료로 판정할 수 없다.
- `REV-059`는 Phase 6 `Task 6.9` / `P6-R4``P6-R4-GATE`가 소유한다.
- 기존 미처리 `REV-052`~`REV-058`과 함께 최종 `P7-R8`에서 37개 operation/고유 operationId와 controller 37개
mapping, 영향 14개 operation, 공통 ADMIN·오류·CORS, dependency·DDL·legacy/public 무변경을 재판정한다.
- 별도 Phase 7 Task를 추가하지 않고 아직 미실행인 `Task 7.10` / `P7-R8`의 시작 조건과 완료 증거를 확장했다.
**최종 결론:** Phase 7 완료 판정 보류. `P6-R4-GATE`를 포함한 여덟 소유 Gate 후 37개 operation을 통합 재판정한다.
**다음 Goal:** `P2-R10`.
## 20. HTTP 경계 최종 통합 재판정 및 Gate — 2026-07-29
### 리뷰 범위와 방식
- 기준 문서: PRD acceptance criteria, OpenAPI 2.3.0, plan `P2-R10`~`P7-R8-GATE`
- 검토 범위: `REV-052`~`REV-059`, 37개 operation/mapping, 영향 14개 operation의 pagination·JSON-only·multipart part-level JSON/415·FanTalk 답변 수정 계약, dependency·DDL·legacy/public 변경 범위
- 검증 방식: OpenAPI/controller 정적 대조, focused 회귀, 전체 회귀, lint, diff 확인
### 통합 판정
- OpenAPI는 operationId 37개, 고유 operationId 37개, `x-implementation-status=implemented` 37개다.
- controller mapping은 37개로 OpenAPI operation 수와 일치한다.
- `REV-052`~`REV-059`는 모두 소유 Phase Gate와 회귀 검증으로 처리 완료 상태다.
- dependency·DDL 추가와 legacy/public API 변경은 없다.
### 검증 결과
- 영향 14개 operation과 공통 error/authorization focused 회귀: `BUILD SUCCESSFUL in 1m 26s`
- 전체 회귀: `./gradlew test``BUILD SUCCESSFUL in 8m 8s`
- lint: `./gradlew ktlintCheck``BUILD SUCCESSFUL in 1s`
- OpenAPI/controller 정적 대조: 37개 operationId/status와 37개 controller mapping 일치
- `git diff --check`: 출력 없음
**최종 결론:** Phase 7 HTTP 경계 통합 재판정 및 Gate 완료. AI 캐릭터 관리자 API 37개 operation 구현 완료.
**다음 Goal:** 없음.
## 23. 8차 통합 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 문서: PRD, plan, OpenAPI 37개 operation, API 계약 설명, Phase 1~6 최신 구현·테스트 소스
- 검토 범위: endpoint/controller 집계, multipart 8개 operation, Phase별 신규 finding과 plan 상태
- 기준 상태: 현재 working tree
- 리뷰어/상태: Codex / 판정 완료
- 검증 방식: 문서·코드·테스트 소스 정적 대조. 사용자 지시에 따라 컴파일과 테스트는 실행하지 않았다.
### Phase별 판정
| Phase | 판정 | finding / 후속 Goal |
|---:|---|---|
| 1 | 충족 | 신규 finding 없음 |
| 2 | 보완 필요 | `REV-065` / `P2-R12` |
| 3 | 보완 필요 | `REV-066` / `P3-R18` |
| 4 | 보완 필요 | `REV-067`, `REV-068` / `P4-R9`, `P4-R10` |
| 5 | 보완 필요 | `REV-069` / `P5-R10` |
| 6 | 문서 보완 필요 | `REV-070` / `P6-R5` |
| 7 | 통합 보완 필요 | `REV-071` / `P7-R10` |
### `REV-071` — Low — 완료 Gate와 finding 상태 불일치
- `P2-R11-GATE`, `P3-R17-GATE`, `P4-R8-GATE`, `P5-R9-GATE`, `P7-R9-GATE`는 완료 기록이 있고
OpenAPI/controller/API 계약 설명도 37개 구현 완료로 동기화돼 있다.
- 그러나 `plan-task.md` finding 표의 `REV-060`~`REV-062`, `REV-064`는 여전히 `확정`으로 남아 완료 상태와
모순된다. 기존 완료 이력을 다시 열지 않고 8차 후속 Gate가 끝난 뒤 상태 표만 정리해야 한다.
### plan 전환
| 항목 | 내용 |
|---|---|
| 신규 Task | `Task 7.12` / `P7-R10` |
| Gate | `P7-R10-GATE` |
| 선행조건 | `P2-R12-GATE`, `P3-R18-GATE`, `P4-R9-GATE`, `P4-R10-GATE`, `P5-R10-GATE`, `P6-R5-GATE` |
| 통합 검증 | 8개 multipart operation, Series 장르 ID, FanTalk 문서, OpenAPI/controller 37개, finding/Phase 상태 |
| 범위 제한 | 신규 기능·route/schema·legacy/public·dependency·DDL 변경 없음 |
### 현재 통합 집계
- OpenAPI: 37개 operationId, 37개 `implemented`
- controller mapping: Character 5 + AudioContent 10 + Series 10 + Community 8 + FanTalk 4 = 37
- 이번 리뷰의 production/test/OpenAPI 변경: 없음
- 이번 리뷰에서 실행한 컴파일·테스트: 없음
- 문서 검증: `./gradlew tasks --all``BUILD SUCCESSFUL in 1s`, `git diff --check`는 출력 없이 성공
**최종 결론:** Phase 7 보완 필요 — Phase 2~6의 6개 소유 Task와 통합 Task 완료 전에는 전체 구현 완료로
재판정할 수 없다.
**다음 Goal:** `P2-R12`부터 실행하고 마지막에 `P7-R10`으로 통합한다.
## 21. 7차 통합 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 문서: PRD acceptance criteria, plan Phase 1~7, OpenAPI 37개 operation
- 검토 범위: Phase별 controller/facade/test, 8개 multipart schema와 runtime part binding,
operation/mapping/status 및 계약 설명 문서
- 검증 방식: `jq`, `rg`, diff 기반 정적 대조. 사용자 요청에 따라 컴파일과 테스트는 실행하지 않았다.
### Phase별 결과
| Phase | 판정 | finding / 후속 Goal |
|---:|---|---|
| 1 | 신규 finding 없음 | 공통 ADMIN 인가·resolver·오류/CORS 유지 |
| 2 | 보완 필요 | `REV-060`, `P2-R11` / `P2-R11-GATE` |
| 3 | 보완 필요 | `REV-061`, `P3-R17` / `P3-R17-GATE` |
| 4 | 보완 필요 | `REV-062`, `P4-R8` / `P4-R8-GATE` |
| 5 | 보완 필요 | `REV-063`, `P5-R9` / `P5-R9-GATE` |
| 6 | 신규 finding 없음 | FanTalk 4개 operation 계약 유지 |
| 7 | 보완·재판정 필요 | `REV-064`, `P7-R9` / `P7-R9-GATE` |
### `REV-064` — Low — 구현 현황 설명이 실제 37개 구현 상태보다 오래됨
- OpenAPI는 37개 operationId가 모두 고유하고 `x-implementation-status=implemented` 37개다.
- 실제 controller mapping도 37개이며 FanTalk 답변 수정 PUT이 구현돼 있다.
- 그러나 `plan-task.md` Endpoint Contract Summary는 여전히 36개 구현과 답변 수정 1개 `planned`,
선행 보완 완료 전 상태를 기술한다.
- `api-contract.md`도 상단 집계, endpoint 표, client 생성 설명에서 route 36개·구현 예정 1개로 남아 있다.
- 후속 작업자가 완료 상태를 잘못 판단할 수 있지만 runtime 결함은 아니므로 Low로 판정한다.
### multipart 통합 판정
- OpenAPI의 Character·AudioContent·Series·Community 생성·수정 8개 schema는 모두
`additionalProperties: false`다.
- Phase 2~5 controller는 request part media type은 확인하지만 전체 part 이름 집합을 operation별 허용 목록과
비교하지 않아 `REV-060`~`REV-063`을 확정했다.
- OpenAPI와 production route/schema는 변경하지 않고 각 소유 Phase controller 경계에서 최소 보완한다.
### plan 전환 및 종결 조건
- 소유 Phase 순서:
`P2-R11``P2-R11-GATE``P3-R17``P3-R17-GATE``P4-R8``P4-R8-GATE`
`P5-R9``P5-R9-GATE`
- 통합 재판정: `Task 7.11` / `P7-R9`, Gate `P7-R9-GATE`
- 종결 조건: 미정의 multipart part 400/no-side-effect, 기존 정상/필수/415 회귀, 37개
operation/mapping/implemented 일치, `plan-task.md`·`api-contract.md` 구현 상태 동기화
**최종 결론:** Phase 7 완료 판정 보류. `REV-060`~`REV-064` 처리 후 통합 재판정이 필요하다.
**다음 Goal:** `P2-R11`.
## 22. multipart part 이름·문서 상태 최종 통합 재판정 및 Gate — 2026-07-29
### 리뷰 범위와 방식
- 기준 문서: PRD acceptance criteria, OpenAPI 37개 operation, plan `P2-R11`~`P7-R9-GATE`
- 검토 범위: `REV-060`~`REV-064`, 8개 multipart schema의 part allow-list, 37개 operation/mapping/status, 계약 설명 문서 상태
- 검증 방식: OpenAPI/controller 정적 대조, Phase 2~5 소유 Gate 증거 대조, 문서 diff 확인
### 통합 판정
- `REV-060`~`REV-063`은 각 소유 Phase Gate에서 처리 완료됐다.
- OpenAPI의 8개 multipart schema는 모두 `additionalProperties: false`이며, runtime allow-list와 actual endpoint 회귀가 이를 따른다.
- OpenAPI는 operation 37개, 고유 operationId 37개, `implemented` 37개, `alignment-required` 0개, `planned` 0개다.
- controller mapping은 Character 5, AudioContent 10, Series 10, Community 8, FanTalk 4로 총 37개다.
- `api-contract.md`의 상단 집계, FanTalk 답변 수정 endpoint 상태, client 생성 설명을 37개 구현 완료/예정 0개로 동기화했다.
- dependency·DDL 추가와 legacy/public API 변경은 없다.
### 검증 결과
- OpenAPI operation/status 집계: `operations=37 uniqueOperationIds=37 implemented=37 alignmentRequired=0 planned=0`
- controller mapping 집계: Character 5 + AudioContent 10 + Series 10 + Community 8 + FanTalk 4 = 37
- 8개 multipart schema 집계: Character/Series `{image, request}`, AudioContent create `{contentFile, coverImage, request}`, AudioContent update `{coverImage, request}`, Community create `{audioFile, postImage, request}`, Community update `{postImage, request}`, 모두 `additionalProperties=false`
- Phase 2~5 Gate 회귀: focused/영향 범위 회귀와 `ktlintCheck` 성공 기록 대조 완료
- `git diff --check`: 출력 없음
**최종 결론:** Phase 7 multipart part 이름·문서 상태 통합 재판정 및 Gate 완료. AI 캐릭터 관리자 API 37개 operation 구현 완료.
## 24. 8차 후속 통합 Gate 완료 — 2026-07-29
### 통합 판정
- `REV-065`~`REV-069`는 Phase 2~5 controller가 파일 map과 servlet 전체 part 이름을 모두 operation별 allow-list와 대조하도록 보완해 처리 완료됐다.
- `REV-068`은 Series 생성·수정의 `genreId <= 0`을 legacy 호출 전 400으로 거부하도록 보완해 처리 완료됐다.
- `REV-070`은 FanTalk 비활성 팬 root 삭제를 OpenAPI·구현·테스트와 같은 200 no-op 계약으로 PRD와 `api-contract.md`에 동기화해 처리 완료됐다.
- `REV-071`은 plan finding 표와 상단 Phase 상태를 실제 완료 상태로 동기화해 처리 완료됐다.
### 검증 결과
- RED: Phase 2~5 multipart 일반 form-field와 Phase 4 `genreId <= 0` focused RED 묶음에서 신규 multipart/genre 36건 실패.
- GREEN: 같은 focused 묶음 재실행 `BUILD SUCCESSFUL in 1m 17s`.
- 통합 회귀: ai-character admin character/content/series/community/fantalk focused와 authorization/error/token 회귀 `BUILD SUCCESSFUL in 4m 11s`.
- 정적 검증: OpenAPI `operations=37 uniqueOperationIds=37 implemented=37 alignmentRequired=0 planned=0`, controller mapping 37개, FanTalk 삭제 no-op 정적 대조 완료.
- 정적 품질: `./gradlew ktlintCheck` `BUILD SUCCESSFUL in 51s`, `git diff --check` 출력 없음.
**최종 결론:** Phase 7 8차 후속 Gate 완료. AI 캐릭터 관리자 API 37개 operation 구현 완료 상태와 문서 상태가 일치한다.
**남은 항목:** 없음.
**다음 Goal:** 없음.
## 25. 9차 통합 정적 리뷰 및 판정 — 2026-07-29
### 리뷰 범위와 방식
- 기준 문서: PRD acceptance criteria, plan Phase 1~7, OpenAPI 37개 operation
- 검토 범위: Phase별 최신 production/test 소스, operation/controller 집계, 신규·미처리 finding과 문서 상태
- 검증 방식: `rg`·`sed`·`jq` 기반 정적 대조. 사용자 지시에 따라 Gradle·컴파일·테스트는 실행하지 않았다.
### Phase별 결과
| Phase | 판정 | 신규 finding/후속 |
|---:|---|---|
| 1 | 추가 수정 없음 | 없음 |
| 2 | 추가 수정 없음 | 없음 |
| 3 | 완료 | `REV-072` 처리 완료 |
| 4 | 추가 수정 없음 | 없음 |
| 5 | 추가 수정 없음 | 없음 |
| 6 | 추가 수정 없음 | 없음 |
| 7 | 완료 | `P7-R11` / `P7-R11-GATE` 완료 |
### 통합 판정
- OpenAPI JSON 문법, operation 37개, 고유 operationId 37개, `x-implementation-status=implemented` 37개는
정적으로 확인했다.
- `REV-072`는 Phase 3에서 처리 완료됐다. v2 actual endpoint의 preview 오류 3종 KO/EN/JA, no-side-effect,
정상 preview metadata, legacy 생성 회귀가 통과했다.
- OpenAPI implemented count는 37개이고 controller mapping은 파일별 4/5/10/9/8/1 합계 37개다. 신규 dependency·DDL 변경은 없다.
- 기존 완료 이력은 변경하지 않고 `Task 3.29`와 그 후속 `Task 7.13`만 완료로 동기화했다.
**최종 결론:** Phase 7 통합 재판정 및 Gate 완료. AI 캐릭터 관리자 API 37개 operation 구현 완료.
**다음 Goal:** 없음.
## 26. 10차 통합 정적 리뷰 및 판정 — 2026-07-30
### 리뷰 범위와 방식
- 기준 문서: PRD acceptance criteria, plan Phase 1~7, OpenAPI 37개 operation
- 검토 범위: Phase별 최신 production/test 소스, operation/controller 집계, 내부 `$ref`, finding·Task 상태
- 검증 방식: `rg`·`sed`·`jq` 기반 정적 대조. 사용자 지시에 따라 Gradle·컴파일·테스트는 실행하지 않았다.
### Phase별 결과
| Phase | 판정 | 신규 finding/후속 |
|---:|---|---|
| 1 | 충족 | 없음 |
| 2 | 충족 | 없음 |
| 3 | 충족 | 없음 |
| 4 | 충족 | 없음 |
| 5 | 충족 | 없음 |
| 6 | 충족 | 없음 |
| 7 | 완료 유지 | 없음 |
### 통합 판정
- OpenAPI JSON과 내부 `$ref`가 유효하고 operation 37개·고유 operationId 37개·`implemented` 37개다.
- controller mapping은 Character 5, AudioContent 10, Series 10, Community 8, FanTalk 4로 총 37개다.
- 기존 `REV-001`~`REV-072`는 모두 `처리 완료`이며 신규 확정 finding과 미완료 Task/Gate가 없다.
- Phase별 완료 수와 계획 상태가 실제 구현 현황과 일치하므로 신규 회귀 수정 Task/Goal을 추가하지 않는다.
**최종 결론:** Phase 7 통합 완료 상태 유지. AI 캐릭터 관리자 API는 문서 기준 37개 operation 구현 완료다.
**다음 Goal:** 없음.

View File

@@ -4,20 +4,35 @@
- PRD 문서와 구현 계획/TASK 문서는 `docs/[날짜]_구현할내용한글/` 아래에 함께 둔다. - PRD 문서와 구현 계획/TASK 문서는 `docs/[날짜]_구현할내용한글/` 아래에 함께 둔다.
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다. - 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
- PRD 문서 파일명은 `prd.md`, 구현 계획/TASK 문서 파일명은 `plan-task.md`를 사용한다. - PRD 문서 파일명은 `prd.md`, 구현 계획/TASK 문서 파일명은 `plan-task.md`를 사용한다.
- PRD 문서는 `sample-prd.md`에서 필요한 섹션만 발췌해 작성하고, 불필요한 빈 섹션을 기계적으로 복사하지 않는다. - 리뷰 문서는 같은 작업 디렉터리의 `reviews/` 아래에 둔다. 기본 경로는
- `sample-prd.md`가 없거나 위치가 불명확하면 추측하지 말고 사용자에게 확인한다. `docs/[날짜]_구현할내용한글/reviews/[리뷰범위]-review.md`이며, 여러 리뷰는 범위별 파일로 누적한다.
- 요구사항 문서는 `docs/sample/sample-prd.md`, 구현 계획/TASK 문서는 `docs/sample/sample-plan-task.md`, 요구사항·구현·완료 상태
리뷰 문서는 `docs/sample/sample-review.md`를 기준 템플릿으로 참조한다.
- 샘플의 `<...>`, 예시 ID, frontend 전용 항목은 실제 작업의 확정 근거와 범위에 맞게 교체·발췌하고 불필요한 빈 섹션을
기계적으로 복사하지 않는다.
- 필요한 샘플이 없거나 위치가 불명확하면 추측하지 말고 사용자에게 확인한다.
- 구현 계획/TASK 문서는 의미 단위 phase로 나누고 `### Phase 1: ...`, `### Phase 2: ...` 형식의 heading을 사용한다. - 구현 계획/TASK 문서는 의미 단위 phase로 나누고 `### Phase 1: ...`, `### Phase 2: ...` 형식의 heading을 사용한다.
- 각 phase 아래에는 단계별 task를 체크박스(`- [ ] **Task N.N: ...**`) 형태로 작성한다. - 각 phase 아래에는 단계별 task를 체크박스(`- [ ] **Task N.N: ...**`) 형태로 작성한다.
- goal 기능으로 실행할 계획은 문서 상단에 현재 상태·활성/다음 Goal을 기록하고, Task 또는 Phase Gate 하나만 단일 goal로
실행할 수 있도록 고유 Goal ID와 실행 순서를 둔다.
- 각 goal에는 objective, 시작 조건, 완료 증거, 범위 밖을 명시하며, Phase마다 모든 Task 완료 후 별도로 판정하는 Gate Goal을 둔다.
- 각 task에는 구현 시 생성/수정/확인할 파일 경로를 명시한다. - 각 task에는 구현 시 생성/수정/확인할 파일 경로를 명시한다.
- 각 task에는 TDD 절차를 명시한다. 기본 형식은 `RED: 실패 테스트 작성/실패 확인`, `GREEN: 최소 구현/통과 확인`, `REFACTOR: 정리/회귀 확인`을 포함한다. - 각 task에는 TDD 절차를 명시한다. 기본 형식은 `RED: 실패 테스트 작성/실패 확인`, `GREEN: 최소 구현/통과 확인`, `REFACTOR: 정리/회귀 확인`을 포함한다.
- 테스트 작성이 현실적으로 불가능한 task는 `TDD 예외 사유``대체 검증 방법`을 task에 명시한다. - 테스트 작성이 현실적으로 불가능한 task는 `TDD 예외 사유``대체 검증 방법`을 task에 명시한다.
- 각 phase 또는 task에는 실행 명령, 기대 결과, 수동 확인 항목 등 검증 기준을 함께 작성한다. - 각 phase 또는 task에는 실행 명령, 기대 결과, 수동 확인 항목 등 검증 기준을 함께 작성한다.
- 각 task의 검증 기준에는 단일 테스트 실행 명령과 필요한 경우 전체 회귀 명령을 포함한다. - 각 task의 검증 기준에는 focused test와 직접 영향받는 회귀 명령을 우선 명시한다. 전체 회귀는 광범위한 공통 코드 변경,
여러 domain/phase 변경, release/final Gate에서 전체 상태 증거를 별도로 요구하는 경우, targeted test만으로 영향 범위를 판단할
수 없는 경우 또는 사용자 요청일 때만 포함한다.
- 계획에서 전체 회귀를 조건부로 두면 실행 조건, 생략 시 기록할 근거와 대체 focused/영향 범위 회귀 명령을 함께 명시한다.
- 구현 완료 즉시 해당 task 체크박스를 `- [x]`로 갱신한다. - 구현 완료 즉시 해당 task 체크박스를 `- [x]`로 갱신한다.
- 작업 도중 범위가 변경되면 계획 문서 체크리스트를 먼저 업데이트한 뒤 구현한다. - 작업 도중 범위가 변경되면 계획 문서 체크리스트를 먼저 업데이트한 뒤 구현한다.
- 결과 보고 시 개별 task 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)은 해당 task 아래에 한국어로 남긴다. - 결과 보고 시 개별 task 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)은 해당 task 아래에 한국어로 남긴다.
- 여러 task/phase에 걸친 회귀 검증, 전체 빌드/포맷 검증, 문서 변경 범위 확인처럼 전체에 해당하는 검증 기록은 문서 하단에 한국어로 남긴다. - 여러 task/phase에 걸친 회귀 검증, 전체 빌드/포맷 검증, 문서 변경 범위 확인처럼 전체에 해당하는 검증 기록은 문서 하단에 한국어로 남긴다.
- 후속 수정이 발생해도 기존 검증 기록은 삭제하거나 덮어쓰지 않고 누적한다. - 후속 수정이 발생해도 기존 검증 기록은 삭제하거나 덮어쓰지 않고 누적한다.
- 완료된 Task의 후속 리뷰는 기존 체크박스를 되돌리지 않는다. `docs/sample/sample-review.md` 형식으로 후보를 판정하고 확정된
항목만 새 회귀 수정 Task/Goal과 Progress 기록으로 누적한다.
- 기존 리뷰 파일을 삭제하거나 덮어쓰지 않는다. 같은 범위의 후속 검증은 기존 파일에 차수별로 누적하고, 검토 범위가 다르면
`reviews/` 아래에 새 파일을 만든다.
- `build.gradle.kts` 변경 시 실행 명령 섹션을 함께 갱신한다. - `build.gradle.kts` 변경 시 실행 명령 섹션을 함께 갱신한다.
- 테스트 클래스 추가/이동 시 단일 테스트 실행 예시를 최신 상태로 유지한다. - 테스트 클래스 추가/이동 시 단일 테스트 실행 예시를 최신 상태로 유지한다.
- `.editorconfig` 변경 시 포맷 규칙 섹션을 동기화한다. - `.editorconfig` 변경 시 포맷 규칙 섹션을 동기화한다.

View File

@@ -3,8 +3,18 @@
## 실행 기준 ## 실행 기준
- 아래 명령은 저장소 루트(`/Users/klaus/Develop/sodalive/Server/sodalive`)에서 실행한다. - 아래 명령은 저장소 루트(`/Users/klaus/Develop/sodalive/Server/sodalive`)에서 실행한다.
- 변경 범위에 맞는 최소 명령으로 검증하고, 결과는 계획 문서 하단 검증 기록에 남긴다. - 변경 범위에 맞는 최소 명령으로 검증하고, 결과는 계획 문서 하단 검증 기록에 남긴다.
- 검증은 focused test → 영향받는 package/feature 회귀 → 전체 회귀 순서로 범위를 넓힌다.
- 전체 회귀 테스트(`./gradlew test`, `./gradlew check`, 전체 `build`)는 실행 시간이 길므로 매 Task나 일반적인 작은 변경에서
관성적으로 실행하지 않는다.
- 공통 인증·보안·예외·설정·serialization처럼 영향 범위가 넓은 코드 변경, 여러 domain/phase에 걸친 변경, release/final Gate에서
전체 상태 증거를 별도로 요구하는 경우, targeted test만으로 영향 범위를 판단할 수 없는 실패 또는 사용자 명시 요청일 때만
전체 회귀를 실행한다.
- 전체 회귀를 생략하면 실행하지 않은 사실, 생략 근거와 대신 실행한 focused/영향 범위 회귀 명령을 계획 문서에 기록한다.
## Build/Lint/Test ## Build/Lint/Test
아래는 사용 가능한 명령 목록이며 모든 변경에서 전부 실행하라는 의미가 아니다.
```bash ```bash
./gradlew tasks --all ./gradlew tasks --all
./gradlew bootRun ./gradlew bootRun

View File

@@ -2,11 +2,20 @@
## 작업 절차 체크리스트 ## 작업 절차 체크리스트
- 변경 전: 모든 구현 작업은 PRD 문서와 구현 계획/TASK 문서가 모두 준비된 뒤에 시작한다. - 변경 전: 모든 구현 작업은 PRD 문서와 구현 계획/TASK 문서가 모두 준비된 뒤에 시작한다.
- 변경 전: 사용자 프롬프트를 받으면 먼저 PRD 문서를 작성한다. - 변경 전: 사용자 프롬프트를 받으면 먼저 PRD 문서를 작성한다. 새 요구사항 문서는
`docs/sample/sample-prd.md`에서 필요한 섹션을 발췌하고 모든 placeholder와 예시 ID를 실제 값으로 교체한다.
- 변경 전: PRD 작성 중 애매하거나 더 필요한 내용, 결정해야 하는 사항이 있으면 애매한 사항이 없어질 때까지 사용자와 인터뷰하고 PRD를 보강한다. - 변경 전: PRD 작성 중 애매하거나 더 필요한 내용, 결정해야 하는 사항이 있으면 애매한 사항이 없어질 때까지 사용자와 인터뷰하고 PRD를 보강한다.
- 변경 전: PRD는 `sample-prd.md`에서 작업에 필요한 부분만 발췌해 작성한다. `sample-prd.md`가 없거나 위치가 불명확하면 추측하지 말고 사용자에게 확인한다. - 변경 전: PRD는 `docs/sample/sample-prd.md`에서 작업에 필요한 부분만 발췌해 작성한다. 파일이 없거나 읽을 수 없으면 추측하지
말고 사용자에게 확인한다.
- 변경 전: 문서는 `docs/[날짜]_구현할내용한글/prd.md`, `docs/[날짜]_구현할내용한글/plan-task.md` 형식으로 작성한다. - 변경 전: 문서는 `docs/[날짜]_구현할내용한글/prd.md`, `docs/[날짜]_구현할내용한글/plan-task.md` 형식으로 작성한다.
- 변경 전: 보강된 PRD를 바탕으로 구현 계획/TASK 문서를 작성한 뒤, 해당 문서를 기준으로 필요한 내용만 최소 구현한다. - 변경 전: 보강된 PRD를 바탕으로 `docs/sample/sample-plan-task.md`를 참조해 goal 실행형 구현 계획/TASK 문서를 작성한 뒤,
해당 문서를 기준으로 필요한 내용만 최소 구현한다.
- 변경 전: 구현 계획은 Phase를 결과·의존성 단위로, Task와 Phase Gate를 한 번에 하나씩 실행할 goal 단위로 작성한다. 각 Goal에는
고유 ID, 한 문장 objective, 시작 조건, 완료 증거, 범위 밖, 정확한 파일 경로, 실행 체크박스와 검증 명령을 포함한다.
- 변경 전: 요구사항 또는 구현 완료 상태를 재검토하는 문서는 `docs/sample/sample-review.md`를 참조한다. 리뷰 후보는 재현·판정하고,
확정 항목만 기존 계획에 새 회귀 수정 Task와 goal로 추가한다.
- 변경 전: 리뷰 문서는 PRD·구현 계획과 같은 `docs/[날짜]_구현할내용한글/` 아래의 `reviews/` 폴더에 저장한다. 리뷰가 여러
개면 목적과 범위가 드러나는 개별 파일명으로 분리하고 기존 리뷰를 덮어쓰지 않는다.
- 변경 전: 구현 계획/TASK 문서의 각 task에는 TDD 기준의 실패 테스트 작성, 실패 확인, 최소 구현, 통과 확인, 리팩터링/회귀 확인 단계를 포함한다. - 변경 전: 구현 계획/TASK 문서의 각 task에는 TDD 기준의 실패 테스트 작성, 실패 확인, 최소 구현, 통과 확인, 리팩터링/회귀 확인 단계를 포함한다.
- 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다. - 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다.
- 변경 전: 신규 API나 하위 코드 작성 시 `docs/agent-guides/코드스타일.md`의 패키지/코드 배치 규칙을 확인한다. - 변경 전: 신규 API나 하위 코드 작성 시 `docs/agent-guides/코드스타일.md`의 패키지/코드 배치 규칙을 확인한다.
@@ -16,6 +25,12 @@
- 변경 중: Todo를 사용할 때는 사용자에게 보이는 Todo 내용을 한국어로 작성한다. 경로, 클래스명, 명령어, 코드 식별자는 원문을 유지한다. - 변경 중: Todo를 사용할 때는 사용자에게 보이는 Todo 내용을 한국어로 작성한다. 경로, 클래스명, 명령어, 코드 식별자는 원문을 유지한다.
- 변경 중: 공개 API 스키마를 임의 변경하지 말고, 작은 단위로 안전하게 수정한다. - 변경 중: 공개 API 스키마를 임의 변경하지 말고, 작은 단위로 안전하게 수정한다.
- 변경 중: 구현 완료 즉시 해당 task 체크박스를 `- [x]`로 갱신한다. - 변경 중: 구현 완료 즉시 해당 task 체크박스를 `- [x]`로 갱신한다.
- 변경 : 최소 단일 테스트 또는 `./gradlew test`를 실행하고, 필요 시 `./gradlew ktlintCheck`를 수행한다. - 변경 : 동시에 하나의 미완료 goal만 운용한다. Goal의 체크박스, focused test, 완료 증거와 Progress 기록이 모두 충족되기
전에는 goal이나 Phase Gate를 완료 처리하지 않는다.
- 변경 중: 완료된 Task와 기존 검증 기록은 되돌리거나 삭제하지 않는다. 후속 리뷰에서 발견한 회귀는 별도 Task/Goal로 누적한다.
- 변경 후: 변경 범위의 focused test를 우선 실행하고, 공유 경계와 직접 영향받는 package/feature 회귀까지만 단계적으로 확장한다.
- 변경 후: 전체 회귀 테스트는 공통 인증·보안·예외·설정 등 광범위한 변경, 여러 domain/phase 변경, release/final Gate에서 전체
상태 증거를 별도로 요구하는 경우, targeted test만으로 영향 범위를 판단할 수 없는 실패 또는 사용자 명시 요청일 때만 실행한다.
- 변경 후: 전체 회귀 테스트를 실행하지 않으면 생략 사실과 근거, 대신 실행한 focused/영향 범위 회귀 명령을 검증 기록에 남긴다.
- 변경 후: 각 task의 검증 결과는 해당 task 아래에 무엇을, 왜, 어떻게 검증했는지, 실행 명령과 결과를 한국어로 누적 기록한다. - 변경 후: 각 task의 검증 결과는 해당 task 아래에 무엇을, 왜, 어떻게 검증했는지, 실행 명령과 결과를 한국어로 누적 기록한다.
- 변경 후: 여러 task/phase에 걸친 회귀 검증, 전체 빌드/포맷 검증, 문서 변경 범위 확인처럼 전체에 해당하는 검증은 계획 문서 하단의 검증 기록에 누적한다. - 변경 후: 여러 task/phase에 걸친 회귀 검증, 전체 빌드/포맷 검증, 문서 변경 범위 확인처럼 전체에 해당하는 검증은 계획 문서 하단의 검증 기록에 누적한다.

View File

@@ -1,106 +0,0 @@
# PRD: [제품명]
## 1. Overview
이 제품이 무엇인지 한 줄 설명
---
## 2. Problem
어떤 문제를 해결하는가?
- 현재 사용자의 불편
- 기존 방식의 한계
- 왜 지금 필요한가
---
## 3. Goals
성공 기준
예:
- 가입 전환율 20%
- 작업 시간 50% 감소
---
## 4. Non-Goals
이번에 하지 않을 것
매우 중요함.
예:
- 모바일 앱 지원 안 함
- 실시간 협업 제외
- 다국어 제외
---
## 5. Target Users
누가 사용하는가?
- 초보 개발자
- PM
- 디자이너
---
## 6. User Stories
사용자 행동 시나리오
예:
- 사용자는 버튼 하나로 요약하고 싶다
- 사용자는 로그인 없이 체험하고 싶다
---
## 7. Core Features
### Feature A
설명
#### Requirements
- must
- should
- constraints
#### Edge Cases
- 빈 입력
- timeout
- 중복 요청
---
## 8. UX / UI Expectations
- 반응속도
- 클릭 수
- 모바일 대응
- 접근성
---
## 9. Technical Constraints
- Next.js 사용
- PostgreSQL 사용
- API latency 2초 이하
---
## 10. Metrics
무엇을 측정할 것인가?
- retention
- DAU
- conversion
---
## 11. Open Questions
아직 결정 안 된 것
- OAuth 제공?
- pricing?
- offline mode?

View File

@@ -0,0 +1,317 @@
# Goal 실행형 구현 계획 샘플
> 이 문서는 goal 기능으로 구현 계획을 실행하기 위한 템플릿이다. 실제 `plan-task.md`를 만들 때 `<...>` placeholder를 모두 구체적인 값으로 교체한다. Phase는 결과와 의존성을 묶고, `create_goal`에는 Task 또는 Phase Gate 하나만 등록한다.
| 문서 항목 | 내용 |
|---|---|
| 상태 | 계획 작성 중 / 구현 중 / 외부 조건 대기 / 구현 완료 |
| 작성일 | `YYYY-MM-DD` |
| 요구사항 기준 | `<대상 prd.md 경로>` |
| API 기준 | `<대상 api-contract.md 경로>` |
| 현재 Phase | `<Phase 번호와 이름>` |
| 현재 활성 Goal | `<없음 또는 Goal ID>` |
## 목표
`<사용자가 얻는 최종 결과를 한 문장으로 작성한다. 구현 수단보다 완결된 사용자 흐름을 먼저 쓴다.>`
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1 | 대기 / 진행 중 / 완료 / 제외 | `0/N` | `P1-T1` | `<없음 또는 조건>` |
| 2 | 대기 / 진행 중 / 완료 / 제외 | `0/N` | `P2-T1` | `<없음 또는 조건>` |
- 동시에 하나의 미완료 goal만 운용한다.
- 완료된 Task와 검증 기록은 되돌리거나 삭제하지 않는다. 후속 수정은 회귀 수정 Task와 새 goal ID를 추가한다.
- 사용자가 명시적으로 요청하지 않으면 goal에 token budget을 설정하지 않는다.
## 범위
### 포함
- `<구현할 사용자 흐름>`
- `<구현할 route/API/UI 범위>`
- `<필수 오류·반응형·접근성·test 범위>`
### 제외
- `<이번 릴리스에서 만들지 않을 기능>`
- `<외부 시스템 또는 다음 Phase 소유 범위>`
- `<복원, hard delete처럼 명시적으로 금지한 기능>`
## 기술적 제약
- 기술 스택: `<언어, framework, 주요 library와 version 기준>`
- 아키텍처: `<상태·API·UI·파일 책임 경계>`
- 데이터·보안: `<저장 위치, 인증, 민감정보 비기록 규칙>`
- 호환성: `<지원 browser, viewport, runtime>`
- 의존성: 실제 소비 Task에서 필요한 최소 dependency만 추가한다.
- 계약: 제공되지 않은 endpoint, DTO, enum, 오류 status/key와 validation 상한을 추정하지 않는다.
- backend 구현 전 UI 확인이 필요하면 제공 계약 기반 explicit mock mode를 사용하고 실제 404 자동 fallback·production mock을 금지하며 mock/server 완료 증거를 분리한다.
- 구현: 모든 구현 Task는 아래 `RED → GREEN → REFACTOR` 순서를 체크박스에 명시하고, 가장 작은 실패 test에서 시작해 최소 구현으로 통과시킨다.
- 검증: focused test에서 시작해 영향받는 package/feature 회귀로 확장한다. 전체 회귀는 공통 경계 변경, 여러 domain/phase 변경,
release/final Gate에서 전체 상태 증거를 별도로 요구하는 경우, targeted test만으로 영향 범위를 판단할 수 없는 실패 또는 사용자
명시 요청이 있을 때만 실행한다.
- 전체 회귀를 생략하면 생략 사실·근거와 대신 실행한 focused/영향 범위 회귀 명령을 Progress에 기록한다.
## Task TDD 작성 규칙
구현 Task의 실행 체크박스는 아래 순서와 표제어를 그대로 사용한다. 각 단계에는 대상 test, 실행 명령과 관찰할 실패·성공 결과를
구체적으로 적고, `RED` 확인 없이 production 구현을 시작하거나 `GREEN` 확인 없이 `REFACTOR`로 넘어가지 않는다.
- [ ] **RED:** 가장 작은 실패 test를 작성한다.
- [ ] **RED 확인:** focused test를 실행해 요구 동작이 없어서 발생한 의도한 assertion 실패를 확인한다.
- [ ] **GREEN:** RED를 통과시키는 최소 구현을 작성한다.
- [ ] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
- [ ] **REFACTOR:** 새 동작을 바꾸지 않는 범위에서 이번 Task가 만든 중복만 정리하고, focused test·직접 영향 회귀·lint를 다시 실행해 실제 결과를 Progress에 기록한다.
test 작성이 현실적으로 불가능한 read-only 리뷰·문서·외부 확인 Task는 TDD 단계를 형식적으로 만들지 않는다. 대신 Task 본문에
`TDD 예외 사유`와 실행 명령·대조표·수동 확인을 포함한 `대체 검증 방법`을 명시한다.
## Phase 1
**Phase 결과:** `<이 Phase가 끝나면 사용자가 완료할 수 있는 흐름>`
**선행조건:** `<없음 또는 선행 Goal/Gate ID>`
**Phase 완료 조건:** `P1-T1`~`P1-TN``P1-GATE` 완료, 검증 기록 누적.
### 구현 항목
#### Task 1.1 `<독립적으로 검토 가능한 결과>`
**Goal 실행 `P1-T1`:** `<이 Task가 만드는 결과를 한 문장으로 작성한다.>`
- **시작 조건:** `<선행 Goal, 요구사항 ID, API Contract section>`
- **완료 증거:** `<체크박스 전체, focused test, 산출물, 문서 기록>`
- **범위 밖:** `<다음 Task 또는 다른 Phase가 소유하는 항목>`
**Files:**
- Create: `<정확한 파일 경로>`
- Modify: `<정확한 파일 경로>`
- Test: `<정확한 test 파일 경로>`
**Interfaces:**
- Consumes: `<선행 Task가 제공하는 type/function/component contract>`
- Produces: `<후속 Task가 사용할 정확한 type/function/component contract>`
- [ ] **RED:** `<요구 동작>`을 재현하는 가장 작은 실패 test를 작성한다.
- [ ] **RED 확인:** `<focused test 명령>`을 실행해 `<요구 동작 미구현 때문에 발생할 assertion 실패>`를 확인한다.
- [ ] **GREEN:** RED를 통과시키는 최소 구현을 작성한다.
- [ ] **GREEN 확인:** `<focused test 명령>`을 다시 실행해 성공을 확인한다.
- [ ] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 focused test·직접 영향 회귀·typecheck·lint 결과를 Progress에 기록한다.
#### Task 1.2 `<두 번째 독립 결과>`
**Goal 실행 `P1-T2`:** `<이 Task가 만드는 결과를 한 문장으로 작성한다.>`
- **시작 조건:** `P1-T1` 완료와 `<필요한 계약>`.
- **완료 증거:** `<체크박스 전체, test와 문서 기록>`
- **범위 밖:** `<이 Task에서 다루지 않는 항목>`
**Files:**
- Create: `<정확한 파일 경로>`
- Modify: `<정확한 파일 경로>`
- Test: `<정확한 test 파일 경로>`
**Interfaces:**
- Consumes: `<P1-T1이 제공한 정확한 contract>`
- Produces: `<Phase 2 또는 Gate가 사용할 정확한 contract>`
- [ ] **RED:** `<두 번째 요구 동작>`의 실패 test를 작성한다.
- [ ] **RED 확인:** `<focused test 명령>`으로 의도한 assertion 실패를 확인한다.
- [ ] **GREEN:** 최소 구현으로 focused test를 통과시킨다.
- [ ] **GREEN 확인:** 오류·loading·empty·success와 접근성 상태를 포함한 focused test 성공을 확인한다.
- [ ] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 관련 test·typecheck·lint 결과를 Progress에 기록한다.
### 완료 조건
- [ ] `P1-T1`, `P1-T2`의 체크박스와 완료 증거가 모두 충족됐다.
- [ ] Phase 1의 확정 요구사항이 구현·명시적 제외·후속 결정 중 하나로 추적된다.
- [ ] 알려진 문서와 구현의 차이가 없다.
### 검증 방법
#### Phase 1 Gate
**Goal 실행 `P1-GATE`:** Phase 1의 사용자 흐름과 공통 품질 기준을 최종 판정한다.
- **시작 조건:** Phase 1의 모든 활성 Task goal 완료.
- **완료 증거:** 아래 자동·수동 검증 통과와 Progress 기록.
- **범위 밖:** Gate 통과를 위한 test 삭제·완화와 관련 없는 기능 수정.
```bash
<focused unit/integration test 명령>
<Phase 전용 E2E 명령>
<typecheck 명령>
<lint 명령>
<build 명령>
```
**Expected:** `<0 exit code, test 수, 사용자가 완료할 흐름, 금지 요청 0회 등 관찰 가능한 결과>`
수동 검증:
- [ ] `<viewport와 사용자 흐름>`
- [ ] `<keyboard·focus·zoom·접근성 검사>`
- [ ] `<민감정보·network request 검증>`
## Phase 2
**Phase 결과:** `<Phase 1 결과를 소비해 완성하는 다음 사용자 흐름>`
**선행조건:** `P1-GATE` 또는 `<Phase 1의 부분 contract Goal ID>` 완료.
**Phase 완료 조건:** `P2-T1`~`P2-TN``P2-GATE` 완료, 검증 기록 누적.
### 구현 항목
#### Task 2.1 계약·상태 확인
**Goal 실행 `P2-T1`:** Phase 2 구현에 필요한 계약, 상태/action inventory와 component/file map을 확정한다.
- **시작 조건:** `<선행 Goal>` 완료, 관련 PRD/API Contract 확인.
- **완료 증거:** 계약 제공 또는 제외 결정이 기준 문서에 일치하고 구현 map이 기록됨.
- **범위 밖:** 계약을 추정한 production adapter와 실제 기능 구현.
- **TDD 예외 사유:** production 동작을 변경하지 않는 read-only 계약·상태 확인 Task다.
- **대체 검증 방법:** PRD/API Contract/현재 구현의 field·상태·파일 대조표와 누락·결정 기록을 작성하고 문서 검증 명령 결과를 Progress에 남긴다.
- [ ] 필요한 endpoint·DTO·오류·pagination 계약을 확인한다.
- [ ] loading·empty·error·success·read-only·viewport 상태와 action을 inventory한다.
- [ ] 계약이 없으면 담당 주체·영향·재개 조건과 제외/후속 결정을 문서화한다.
- [ ] Page·feature·shared component와 test file 책임을 확정한다.
#### Task 2.2 `<Phase 2의 독립 구현 결과>`
**Goal 실행 `P2-T2`:** `<사용자가 직접 확인할 수 있는 흐름을 한 문장으로 작성한다.>`
- **시작 조건:** `P2-T1` 완료.
- **완료 증거:** `<contract/UI/E2E test와 문서 기록>`
- **범위 밖:** `<다음 Task 또는 후속 Phase>`
**Files:**
- Create: `<정확한 파일 경로>`
- Modify: `<정확한 파일 경로>`
- Test: `<정확한 test 파일 경로>`
- [ ] **RED:** contract·serializer와 사용자 action의 가장 작은 실패 test를 작성한다.
- [ ] **RED 확인:** `<focused test 명령>`을 실행해 각 요구 동작이 없어 발생한 assertion 실패를 확인한다.
- [ ] **GREEN:** RED를 통과시키는 최소 구현을 작성한다.
- [ ] **GREEN 확인:** 같은 focused test를 다시 실행해 contract와 사용자 흐름의 성공을 확인한다.
- [ ] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 관련 integration/E2E·공통 품질 명령의 실제 결과와 남은 항목을 Progress에 기록한다.
### 완료 조건
- [ ] `P2-T1`, `P2-T2`의 체크박스와 완료 증거가 모두 충족됐다.
- [ ] 외부 의존은 제공 계약 구현 또는 명시적 제외/후속 결정으로 종결됐다.
- [ ] Phase 2의 사용자 흐름과 오류·반응형·접근성 상태가 검증됐다.
### 검증 방법
#### Phase 2 Gate
**Goal 실행 `P2-GATE`:** Phase 2의 contract, 사용자 흐름과 회귀 방지를 최종 판정한다.
- **시작 조건:** Phase 2의 모든 활성 Task goal 완료.
- **완료 증거:** 아래 명령과 Expected 통과, Progress에 실제 결과 누적.
- **범위 밖:** 실패와 무관한 다음 Phase 구현.
```bash
<Phase 2 focused test 명령>
<Phase 2 E2E 명령>
<typecheck·lint·build 명령>
```
**Expected:** `<사용자 journey, 오류 처리, request payload와 금지 동작을 포함한 최종 결과>`
## 실행 순서와 의존성
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|---:|---|---|---|---|
| 1 | `P1-T1` | 없음 | 아니요 | 근거 문서 보정 |
| 2 | `P1-T2` | `P1-T1` | 아니요 | 독립 검증 계속 |
| 3 | `P1-GATE` | Phase 1 Task 전체 | 아니요 | 실패 소유 Task의 회귀 수정 goal 생성 |
| 4 | `P2-T1` | `P1-GATE` | 아니요 | 외부 계약 담당·재개 조건 기록 |
| 5 | `P2-T2` | `P2-T1` | `<예/아니요>` | 독립 범위만 계속 수행 |
| 6 | `P2-GATE` | Phase 2 Task 전체 | 아니요 | 실패 소유 Task로 되돌림 |
```text
P1-T1 → P1-T2 → P1-GATE → P2-T1 → P2-T2 → P2-GATE
```
## 변경 금지 항목
- 확정된 요구사항·API Contract를 근거 없이 변경하지 않는다.
- 기존 완료 체크박스와 Progress·Decision Log·검증 기록을 삭제하거나 덮어쓰지 않는다.
- 제공되지 않은 endpoint·DTO·enum·오류 값과 validation 상한을 만들지 않는다.
- 요청 범위 밖의 리팩터링, dependency 추가와 공통 abstraction을 확장하지 않는다.
- test를 삭제·skip·완화하거나 타입 오류를 우회해 Gate를 통과시키지 않는다.
- JWT, password, signed URL, 파일 본문 같은 민감정보를 log·fixture·문서에 기록하지 않는다.
## 의사결정 및 중단 규칙
- PRD와 API Contract가 충돌하면 프로젝트가 정한 우선순위에 따라 결정하고 Decision Log에 근거를 남긴다.
- 구현 범위가 바뀌면 `plan-task.md` 체크박스와 범위를 먼저 갱신한 뒤 코드를 수정한다.
- 안전한 최소 기본값이 문서에 있으면 그 값만 구현한다. 안전하게 구현할 수 없으면 추정하지 않는다.
- 계약 미제공 기능을 제외할 때는 PRD 결정 기록 → API Contract → 이 계획 순서로 갱신한다.
- 같은 차단 사유가 최초 시도와 자동 후속을 포함해 3회 연속 반복되고, 문서화나 독립 작업도 불가능할 때만 goal을 `blocked`로 갱신한다.
- 체크박스 일부, test 일부 또는 코드 작성만 끝난 상태에서는 goal을 `complete`로 갱신하지 않는다.
- 완료 증거와 Progress 기록까지 충족한 뒤에만 goal을 `complete`로 갱신한다.
## Progress
기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다.
### `<Goal ID>` N차 실행 — YYYY-MM-DD
- 상태: 진행 중 / 완료 / 차단 감사 중 / 차단
- 무엇을: `<이번 실행에서 완료한 체크박스와 산출물>`
- 왜: `<Task objective와 요구사항 근거>`
- 어떻게:
- `<실행 명령>``<성공/실패, exit code, test 수와 핵심 결과>`
- `<수동 검증>``<성공/실패/불가 사유>`
- 남은 항목: `<체크박스, 외부 조건 또는 없음>`
- 다음 행동: `<같은 goal에서 이어서 할 가장 작은 단계>`
## Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|---|---|---|---|---|---|
| `YYYY-MM-DD` | `DEC-001` | 제안 / 확정 / 정정 | `<결정 내용>` | `<요구사항, 계약, 검증 근거>` | `<Goal ID와 문서>` |
- 기존 결정을 정정할 때 원문을 지우지 않고 새 `정정` 행을 추가한다.
- 외부 의존 제외, 새로운 dependency, 범위 변경, 안전한 기본값 선택은 반드시 기록한다.
## 발견된 문제
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|---|---|---|---|---|---|
| `ISSUE-001` | Blocker / High / Medium / Low | 후보 / 확정 / 보류 / 해결 | `<관찰 사실>` | `<Goal ID>` | `<수정 Task, 외부 담당 또는 없음>` |
- 구현 중 발견한 범위 내 문제는 근거와 재현 방법을 기록하고 해당 Task에서 처리한다.
- 완료된 범위의 회귀는 기존 Task를 다시 열지 않고 별도 회귀 수정 Task와 goal을 만든다.
- 범위 밖 문제는 임의로 수정하지 않고 사용자에게 보고하거나 후속 Task로 결정한다.
- 상세 코드 리뷰 결과가 필요하면 `docs/sample/sample-review.md` 형식으로
`docs/[날짜]_구현할내용한글/reviews/[리뷰범위]-review.md`에 별도 문서를 만든다.
## 최종 보고 형식
```markdown
구현 결과: <완료한 Phase와 사용자 흐름>
- 변경: <주요 파일과 동작>
- 결정: <중요한 Decision Log ID와 내용>
- 검증:
- `<실행 명령>`<성공/실패와 핵심 수치>
- `<수동 검증>`<성공/실패/불가 사유>
- 남은 항목: <외부 의존, 후속 범위 또는 없음>
- 문서: <갱신한 PRD/API Contract/plan/review 링크>
```
최종 보고는 성공을 추정하지 않는다. 실제 실행한 최신 검증 결과와 완료되지 않은 범위를 함께 전달한다.

271
docs/sample/sample-prd.md Normal file
View File

@@ -0,0 +1,271 @@
# 제품 요구사항 문서(PRD) 샘플
> 이 문서는 요구사항을 API Contract와 goal 실행형 `plan-task.md`로 연결하기 위한 템플릿이다. 실제 `prd.md`를 만들 때 `<...>` placeholder와 예시 ID를 모두 제품의 구체적인 값으로 교체한다.
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 초안 / 검토 중 / 구현 기준 확정 / 구현 완료 |
| 작성일 | `YYYY-MM-DD` |
| 최종 수정일 | `YYYY-MM-DD` |
| 대상 제품 | `<제품 또는 기능 이름>` |
| 작성자·결정권자 | `<이름 또는 역할>` |
| 관련 API Contract | `<대상 api-contract.md 경로>` |
| 관련 구현 계획 | `<대상 plan-task.md 경로>` |
| 관련 review | `<없음 또는 같은 작업 디렉터리의 reviews/ 아래 문서 링크>` |
### 요구사항 상태
| 상태 | 의미 | 구현 처리 |
|---|---|---|
| 확정 | 제품·기술 결정이 완료되어 구현 기준으로 사용 | `plan-task.md`의 Task와 완료 증거로 추적 |
| 미결 | 제품·UX·운영 결정이 더 필요함 | 권고안과 결정 주체·기한을 기록하고 임의 구현 금지 |
| 외부 의존 | 프론트엔드 밖의 계약·권한·환경 제공이 필요함 | 담당 주체·영향·재개 조건을 기록하고 추정 구현 금지 |
| 권고 | 미결 항목에 대한 현재 추천안 | 확정되기 전 계약이나 수용 기준으로 사용하지 않음 |
| 제외 | 현재 릴리스에서 구현하지 않기로 결정 | 제외 이유와 후속 조건을 Decision Log에 기록 |
### 문서 우선순위와 갱신 순서
1. 사용자·제품 결정은 이 PRD에 기록한다.
2. request/response/error 계약은 `api-contract.md`에 정규화한다.
3. 구현 범위·순서·완료 증거는 `plan-task.md`에 반영한다.
4. 요구사항이 바뀌면 Decision Log → 관련 요구사항·수용 기준 → API Contract → plan 순서로 갱신한다.
5. 기존 결정과 검증 기록은 삭제하거나 덮어쓰지 않고 정정 기록을 누적한다.
## 1. Overview
`<누가 어떤 상황에서 어떤 가치를 얻는 제품인지 2~4문장으로 설명한다.>`
## 2. Problem Statement
현재 사용자는 다음 문제를 겪는다.
- `<관찰 가능한 현재 문제>`
- `<기존 방식의 비용·위험·제약>`
- `<해결하지 않을 때의 사용자 또는 사업 영향>`
문제를 해결했다는 판단은 `<측정하거나 직접 확인할 결과>`로 한다.
## 3. Goals
### 3.1 제품 목표
- `<사용자가 완료할 수 있어야 하는 핵심 결과>`
- `<안전성·운영 효율·데이터 품질 목표>`
- `<릴리스 후 측정 가능한 성공 목표>`
### 3.2 UX 목표
- `<핵심 흐름의 명확성·속도 목표>`
- `<오류·loading·empty·success feedback 목표>`
- `<반응형·keyboard·접근성 목표>`
## 4. Non-Goals
- `<명시적으로 구현하지 않을 기능>`
- `<다음 릴리스 또는 외부 시스템 소유 범위>`
- `<복원, hard delete, 자동 갱신처럼 금지할 동작>`
Non-Goal을 변경하려면 Decision Log와 `plan-task.md` 범위를 먼저 갱신한다.
## 5. Target Users and Permissions
### 5.1 사용자
| 사용자 | 목표 | 주요 작업 | 사용 환경 |
|---|---|---|---|
| `<역할>` | `<달성 목표>` | `<조회·생성·수정 등>` | `<desktop/mobile 등>` |
### 5.2 권한
- 인증 주체: `<사용자 또는 시스템>`
- 허용 역할: `<role 목록>`
- 거부 조건: `<401/403 또는 제품 정책>`
- 리소스 소유권: `<tenant, characterId, ownerId 등 격리 기준>`
- read-only 조건: `<비활성·권한 부족·외부 상태 등>`
## 6. 핵심 사용자 흐름
1. `<시작 조건과 진입점>`
2. `<대상 탐색·선택>`
3. `<핵심 생성·조회·수정 동작>`
4. `<성공 feedback과 다음 화면>`
5. `<오류·권한·session 만료 복구>`
각 흐름은 `plan-task.md`의 최소 하나의 Phase 결과와 E2E 완료 증거로 연결한다.
## 7. 정보 구조와 라우팅
```text
/<entry>
/<resource>
/<resource>/:resourceId
/<child-resource>
```
- 전역 화면과 선택된 리소스 문맥의 경계를 명시한다.
- URL path와 query에 보존할 식별자·검색·filter·page 상태를 명시한다.
- 직접 링크·새로고침이 가능한 화면과 collection modal/Sheet처럼 별도 route가 없는 화면을 구분한다.
- 존재하지 않음, 다른 소유자, 비활성 리소스의 처리는 서버 계약과 공통 오류 정책을 따른다.
## 8. 기능 요구사항
요구사항 ID는 `<DOMAIN>-NNN` 형식을 사용한다. 하나의 행에는 독립적으로 판정 가능한 요구사항 하나만 작성한다.
### 8.1 `<도메인 A>`
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| `DOMAINA-001` | 확정 | `<사용자가 할 수 있어야 하는 동작>` | `<관찰 가능한 성공·오류 결과>` | `api-contract.md §<번호>`, `P1-T1` |
| `DOMAINA-002` | 확정 | `<payload·상태·권한 불변식>` | `<보내야/보내지 말아야 할 값과 test>` | `P1-T2` |
| `DOMAINA-003` | 외부 의존 | `<외부 제공이 필요한 계약>` | `<제공 전 network integration 0건>` | `EXT-001`, `P1-T1` |
### 8.2 `<도메인 B>`
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| `DOMAINB-001` | 확정 | `<목록·상세·mutation 흐름>` | `<loading·empty·error·success 포함>` | `api-contract.md §<번호>`, `P2-T2` |
| `DOMAINB-002` | 미결 | `<제품 결정이 필요한 항목>` | `<확정 전 최대값·동작 추정 금지>` | `OQ-001` |
### 8.3 공통 파일·데이터 정책
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| `FILE-001` | 확정 | `<허용 확장자·MIME·크기>` | `<정확한 byte 경계 test>` | `api-contract.md §<번호>`, `<Goal ID>` |
| `DATA-001` | 확정 | `<날짜·가격·enum·pagination 규칙>` | `<formatter/schema/contract test>` | `<Goal ID>` |
## 9. 반응형 기능 범위
| 기능 | Desktop | Tablet | Mobile | 비고 |
|---|---:|---:|---:|---|
| 조회 | 전체 | 전체 | 전체 | `<예외>` |
| 생성·수정 | 허용 | 허용 | 허용 / 조회 전용 | `<직접 route 차단 정책>` |
| 파일 upload | 허용 | 허용 | 허용 / 미지원 | `<이유>` |
- 화면에서 action을 숨기는 것뿐 아니라 직접 route와 mutation capability도 같은 정책으로 차단한다.
- 최소 viewport, zoom, touch target과 virtual keyboard 조건을 수용 기준에 연결한다.
## 10. UI/UX Expectations
### 10.1 디자인과 component 원칙
- `<브랜드 token, theme 범위, typography>`
- Page는 route·query·permission·component 조합을 담당한다.
- 도메인 표시·입력·상호작용은 feature component가 담당한다.
- 두 개 이상 Phase에서 같은 의미·동작으로 재사용할 때만 shared component로 올린다.
### 10.2 화면 상태
- 모든 비동기 화면에 loading·empty·error·success 상태를 정의한다.
- mutation에는 진행 중·성공·실패·재시도·중복 제출 정책을 정의한다.
- destructive action에는 대상·영향·복구 여부를 알리는 확인 절차를 둔다.
### 10.3 접근성
- 모든 input은 visible label과 연결된 오류를 가진다.
- keyboard-only 흐름, focus 표시·복귀, skip link와 live region 기준을 명시한다.
- 색상만으로 상태를 전달하지 않고 목표 대비와 touch target을 명시한다.
- `<지원 viewport>`, 200% zoom과 axe critical·serious 0건을 수용 기준으로 사용한다.
## 11. API 계약
### 11.1 공통 규칙
- base URL과 인증 header: `<값>`
- locale: `<header와 값>`
- 성공 envelope: `<type 또는 예시>`
- 오류 envelope와 status: `<type 또는 예시>`
- pagination: `<page/size/total/hasNext 규칙>`
- multipart JSON part: `<이름>`
### 11.2 Endpoint 추적
| 요구사항 | Method | Path | 계약 상태 | API Contract | 소유 Goal |
|---|---|---|---|---|---|
| `DOMAINA-001` | `<METHOD>` | `<path>` | 제공됨 / 보정 필요 / 제공 대기 | `§<번호>` | `<Goal ID>` |
### 11.3 외부 제공 대기 계약
| ID | 우선순위 | 제공 필요 계약 | 담당 주체 | 구현 영향 | 재개 조건 |
|---|---:|---|---|---|---|
| `EXT-001` | P0 / P1 | `<endpoint·DTO·오류 규칙>` | `<팀/역할>` | `<차단되는 흐름>` | `<문서와 fixture 제공>` |
- P0 계약이 없으면 영향을 받는 network flow를 완료로 표시하지 않는다.
- 계약에 의존하지 않는 UI shell·상태 inventory·문서화는 독립적으로 진행할 수 있다.
## 12. 보안과 데이터 취급
- 인증 정보 저장 위치와 lifecycle: `<정확한 정책>`
- log·분석·오류 리포트 금지 값: `<token, password, signed URL 등>`
- 업로드 파일명·MIME·본문 취급: `<정책>`
- 리소스 격리와 ownership 검증: `<정책>`
- 401·403·동시 실패 처리: `<정책>`
- 감사 로그: `<포함, 외부 의존 또는 후속 범위>`
## 13. 성능과 품질 요구사항
- 목록 pagination과 전체 로드 예외: `<정책>`
- 검색 debounce와 기존 데이터 유지: `<정책>`
- image layout shift와 lazy loading: `<정책>`
- mutation 중복 제출·upload 취소/재시도: `<정책>`
- 지원 runtime·browser: `<정확한 범위>`
- test stack과 필수 Gate: `<unit/integration/E2E/typecheck/lint/build>`
- backend 구현 전 UI 확인: `<불필요 또는 explicit mock mode, production 금지, no-auto-fallback, mock/server 완료 상태 분리>`
## 14. 성공 기준
### 14.1 기능 수용 기준
- [ ] `<핵심 사용자 journey가 성공한다.>` (`DOMAINA-001`, `P1-GATE`)
- [ ] `<권한·오류·payload 불변식이 검증된다.>` (`DOMAINA-002`, `<test>`)
- [ ] `<외부 의존의 구현 또는 제외 결정이 문서화된다.>` (`EXT-001`)
### 14.2 UI/UX 수용 기준
- [ ] loading·empty·error·success 상태가 있다.
- [ ] keyboard-only로 핵심 흐름을 완료한다.
- [ ] `<최소 viewport>`와 200% zoom에서 핵심 control이 가려지지 않는다.
- [ ] axe critical·serious 위반이 0건이다.
### 14.3 추적성 완료 기준
- [ ] 모든 `확정` 요구사항이 API Contract와 하나 이상의 Task/Goal 완료 증거로 연결된다.
- [ ] 모든 `미결` 항목에 결정 주체와 다음 행동이 있다.
- [ ] 모든 `외부 의존` 항목에 담당 주체·영향·재개 조건이 있다.
- [ ] `제외` 항목에 Decision Log와 후속 조건이 있다.
## 15. Open Questions
| ID | 상태 | 결정 필요 사항 | 현재 권고 | 결정 주체 | 결정 기한/시점 | 영향 Goal |
|---|---|---|---|---|---|---|
| `OQ-001` | 미결 | `<질문>` | `<권고안 또는 없음>` | `<역할>` | `<날짜 또는 UI 작성 후>` | `<Goal ID>` |
- Open Question과 외부 의존을 혼합하지 않는다. 제품이 결정할 수 없는 backend 계약은 `EXT-*`로 관리한다.
- 미결 값을 임의의 상수·enum·endpoint로 구현하지 않는다.
## 16. 요구사항 추적표
| 요구사항 범위 | API Contract | 계획 Phase | Goal | 자동 검증 | 수동 검증 |
|---|---|---:|---|---|---|
| `DOMAINA-001~003` | `§<번호>` | 1 | `P1-T1`, `P1-T2`, `P1-GATE` | `<test 경로>` | `<흐름>` |
| `DOMAINB-001~002` | `§<번호 또는 제공 대기>` | 2 | `P2-T1`, `P2-T2`, `P2-GATE` | `<test 경로>` | `<흐름>` |
## 17. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal |
|---|---|---|---|---|---|
| `YYYY-MM-DD` | `DEC-001` | 확정 / 정정 / 폐기 | `<결정 내용>` | `<인터뷰·계약·검증 근거>` | `<ID와 문서 section>` |
- 기존 결정을 수정할 때 원문을 지우지 않고 `정정` 행을 추가한다.
- 범위 변경, Non-Goal 변경, 외부 의존 제외, 안전한 기본값과 주요 기술 선택을 기록한다.
## 18. 변경 관리
요구사항 변경 시 다음을 확인한다.
- [ ] Decision Log에 변경 이유와 날짜를 기록했다.
- [ ] 관련 요구사항 상태·본문·수용 기준을 갱신했다.
- [ ] API Contract의 request/response/error와 fixture를 갱신했다.
- [ ] `plan-task.md`의 범위·Files·Interfaces·체크박스·완료 증거를 코드 변경 전에 갱신했다.
- [ ] 기존 Progress·review·검증 기록을 삭제하거나 덮어쓰지 않았다.

View File

@@ -0,0 +1,187 @@
# 코드 리뷰 보고서 샘플
> 이 문서는 완료된 Phase를 다시 검토할 때 사용하는 템플릿이다. 리뷰에서 발견한 후보를 먼저 검증하고, **확정**된 항목만 `plan-task.md`의 회귀 수정 Task와 goal로 전환한다. 기존 완료 체크박스와 검증 기록은 삭제하거나 되돌리지 않는다.
> 실제 리뷰 문서는 대상 PRD·`plan-task.md`와 같은 작업 디렉터리의 `reviews/` 아래에
> `docs/[날짜]_구현할내용한글/reviews/[리뷰범위]-review.md` 형식으로 저장한다.
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase `<번호>` / Task `<번호 또는 범위>` |
| 기준 commit 또는 working tree | `<commit SHA 또는 변경 상태>` |
| 리뷰 일자 | `YYYY-MM-DD` |
| 리뷰어 | `<이름 또는 agent>` |
| 기준 문서 | `<대상 prd.md, api-contract.md, plan-task.md 경로>` |
| 리뷰 상태 | 진행 중 / 판정 완료 / 수정 검증 완료 |
## 2. 리뷰 목적과 범위
### 목적
- `<예: Phase 0~1 구현이 요구사항과 API Contract를 충족하는지 확인한다.>`
- `<예: 완료 체크박스와 실제 코드·test·검증 기록이 일치하는지 확인한다.>`
### 포함 범위
- 코드: `<검토할 경로>`
- 테스트: `<검토할 unit/integration/E2E 경로>`
- 문서: `<검토할 요구사항·계약·Task 범위>`
- 수동 검증: `<브라우저, viewport, keyboard, 접근성 등>`
### 제외 범위
- `<이번 리뷰에서 다루지 않는 Phase, 기능 또는 외부 계약>`
## 3. 판정 기준
### 심각도
| 심각도 | 기준 |
|---|---|
| Blocker | 보안·데이터 손실 위험, 핵심 흐름 불능, 완료 판정을 무효화하는 문제 |
| High | 확정 요구사항·API Contract 위반 또는 주요 회귀 |
| Medium | 제한된 조건에서 발생하는 기능·접근성·복구 문제 |
| Low | 유지보수성, 문서 정합성 또는 비핵심 UX 문제 |
### 상태
| 상태 | 의미 | 후속 처리 |
|---|---|---|
| 후보 | 근거를 발견했지만 아직 재현·판정하지 않음 | 검증 후 상태 변경 |
| 확정 | 코드·test·문서 근거로 문제가 확인됨 | `plan-task.md` 회귀 수정 Task 후보 |
| 오탐 | 요구사항이나 실행 결과상 문제가 아님 | 근거를 남기고 종료 |
| 보류 | 외부 계약·환경·제품 결정이 필요함 | 담당 주체와 재개 조건 기록 |
| 수정 완료 | 수정과 관련 검증이 완료됨 | 실행 명령과 결과 연결 |
## 4. 검토한 근거
### 문서와 코드
- 요구사항: `<예: AUTH-001~013>`
- API Contract: `<예: §3 인증>`
- 계획: `<예: P1-T2, P1-T3, P1-GATE>`
- 코드: `<파일 경로와 line>`
- 테스트: `<테스트 파일과 test name>`
### 실행 환경
```text
OS: <값>
Node: <값>
npm: <값>
Browser/viewport: <값>
환경 변수: 민감정보를 제외한 이름과 사용 mode만 기록
```
### 실행한 검증
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|---|---|---|
| `<실제 실행 명령>` | 성공 / 실패 / 불가 | `<exit code, test 수, 오류 또는 불가 사유>` |
| `<수동 검증 절차>` | 성공 / 실패 / 불가 | `<관찰 결과>` |
## 5. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P1-001` | `<심각도>` | 후보 | `<한 문장 제목>` | `<예: P1-T3>` | 판정 전 |
발견 사항이 없으면 “확정 발견 사항 없음”이라고 명시하고, 검토 범위와 실행 증거는 그대로 남긴다.
## 6. 발견 사항 상세
### REV-P1-001 — `<한 문장 제목>`
- **심각도:** `<Blocker | High | Medium | Low>`
- **상태:** `<후보 | 확정 | 오탐 | 보류 | 수정 완료>`
- **관련 요구사항:** `<요구사항 ID 또는 없음>`
- **관련 계약:** `<api-contract.md section 또는 없음>`
- **소유 Task:** `<기존 Goal ID 또는 신규 회귀 Task>`
**관찰 내용**
`<실제로 관찰한 동작을 추정 없이 작성한다.>`
**근거**
- 코드: `<파일 경로:line과 관련 동작>`
- 테스트: `<테스트 파일:test name 또는 누락 사실>`
- 문서: `<문서 경로와 요구사항/계약/계획 항목>`
**재현 또는 검증 절차**
1. `<사전 조건>`
2. `<실행 명령 또는 사용자 동작>`
3. `<실제 결과>`
4. `<요구되는 결과>`
**영향**
`<사용자, 데이터, 보안, 접근성, 회귀 범위를 구체적으로 작성한다.>`
**권장 조치**
`<최소 수정 방향과 추가해야 할 회귀 test를 작성한다. 구현 전 확정되지 않은 endpoint·DTO·오류 값은 추정하지 않는다.>`
**판정 기록**
- `YYYY-MM-DD``<확정/오탐/보류 판정과 근거>`
- `YYYY-MM-DD``<후속 정정이 있으면 기존 기록을 지우지 않고 추가>`
## 7. 확정 항목의 plan·goal 전환
확정 발견 사항이 없으면 이 절에 “전환 항목 없음”을 기록한다. 확정 항목이 있으면 구현 전에 `plan-task.md`에 아래 내용을 반영한다.
### 신규 회귀 수정 Task 초안
```markdown
### Task R<번호>.<번호> <수정할 결과>
**Goal 실행 `P<Phase>-R<번호>`:** <확정된 문제를 수정하고 회귀를 방지하는 문장 objective>
- **시작 조건:** <관련 review ID, 기존 Task/Gate, 필요한 계약>
- **완료 증거:** <실패 재현 test 수정 focused test Phase Gate 검증 기록>
- **범위 밖:** <이번 수정에서 건드리지 않을 기능>
- [ ] `<재현 가능한 실패 test를 먼저 추가하고 의도한 assertion 실패를 확인한다.>`
- [ ] `<최소 수정으로 test를 통과시킨다.>`
- [ ] `<관련 focused test와 Phase Gate를 실행한다.>`
- [ ] `<plan-task.md 하단에 무엇을/왜/어떻게를 누적한다.>`
```
### create_goal objective 초안
```text
[P<Phase>-R<번호>]의 확정 review 항목 <REV-ID 목록>을 수정하고 회귀를 방지한다.
plan-task.md에 추가된 회귀 수정 Task만 수행한다.
실패 재현, 최소 수정, focused test, Phase Gate와 검증 기록이 모두 끝나기 전에는 complete로 표시하지 않는다.
관련 없는 리팩터링과 계약 추정은 범위 밖이다.
```
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 / 미충족 | `<근거>` |
| 후보 항목 판정 완료 | 충족 / 미충족 | `<근거>` |
| 확정 항목 plan 반영 | 충족 / 해당 없음 / 미충족 | `<Task 또는 사유>` |
| 보류 항목의 담당·재개 조건 기록 | 충족 / 해당 없음 / 미충족 | `<근거>` |
| 검증 명령과 결과 기록 | 충족 / 미충족 | `<근거>` |
**최종 결론:** `<확정 발견 사항 없음 | 수정 goal 필요 | 외부 조건 대기 | 수정 검증 완료>`
**남은 항목:** `<없음 또는 review ID와 다음 행동>`
## 9. 수정 후 검증 기록
기존 기록을 삭제하거나 덮어쓰지 않고 차수별로 누적한다.
### N차 수정 검증 — YYYY-MM-DD
- 무엇을: `<수정한 review ID와 결과>`
- 왜: `<요구사항·계약 위반 또는 회귀 위험>`
- 어떻게:
- `<실행 명령>``<성공/실패와 핵심 수치>`
- `<수동 검증>``<성공/실패/불가 사유>`
- 남은 항목: `<없음, 보류 또는 후속 review ID>`

7
openapitools.json Normal file
View File

@@ -0,0 +1,7 @@
{
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "7.24.0"
}
}

View File

@@ -1,8 +1,11 @@
package kr.co.vividnext.sodalive.chat.character.repository package kr.co.vividnext.sodalive.chat.character.repository
import kr.co.vividnext.sodalive.chat.character.ChatCharacter import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRole
import org.springframework.data.domain.Page import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.EntityGraph
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param import org.springframework.data.repository.query.Param
@@ -53,6 +56,48 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
pageable: Pageable pageable: Pageable
): Page<ChatCharacter> ): Page<ChatCharacter>
@EntityGraph(attributePaths = ["creatorMember", "originalWork"])
@Query(
value = """
SELECT DISTINCT c FROM ChatCharacter c
JOIN c.creatorMember cm
LEFT JOIN c.tagMappings tm
LEFT JOIN tm.tag t
WHERE c.isActive = true
AND cm.role = :role
AND cm.memberKind = :memberKind
AND (
:searchTerm = '' OR
LOWER(c.name) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR
LOWER(c.description) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR
(c.mbti IS NOT NULL AND LOWER(c.mbti) LIKE LOWER(CONCAT('%', :searchTerm, '%'))) OR
(t.tag IS NOT NULL AND LOWER(t.tag) LIKE LOWER(CONCAT('%', :searchTerm, '%')))
)
""",
countQuery = """
SELECT COUNT(DISTINCT c) FROM ChatCharacter c
JOIN c.creatorMember cm
LEFT JOIN c.tagMappings tm
LEFT JOIN tm.tag t
WHERE c.isActive = true
AND cm.role = :role
AND cm.memberKind = :memberKind
AND (
:searchTerm = '' OR
LOWER(c.name) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR
LOWER(c.description) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR
(c.mbti IS NOT NULL AND LOWER(c.mbti) LIKE LOWER(CONCAT('%', :searchTerm, '%'))) OR
(t.tag IS NOT NULL AND LOWER(t.tag) LIKE LOWER(CONCAT('%', :searchTerm, '%')))
)
"""
)
fun searchAiCharacters(
@Param("searchTerm") searchTerm: String,
@Param("role") role: MemberRole,
@Param("memberKind") memberKind: MemberKind,
pageable: Pageable
): Page<ChatCharacter>
/** /**
* 특정 캐릭터와 태그를 공유하는 다른 캐릭터를 무작위로 조회 (현재 캐릭터 제외) * 특정 캐릭터와 태그를 공유하는 다른 캐릭터를 무작위로 조회 (현재 캐릭터 제외)
*/ */
@@ -142,6 +187,15 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
) )
fun findByIdInWithTagMappings(@Param("ids") ids: List<Long>): List<ChatCharacter> fun findByIdInWithTagMappings(@Param("ids") ids: List<Long>): List<ChatCharacter>
@Query(
"""
SELECT c FROM ChatCharacter c
LEFT JOIN FETCH c.creatorMember
WHERE c.id = :id
"""
)
fun findByIdWithCreatorMember(@Param("id") id: Long): ChatCharacter?
fun findByCreatorMemberId(creatorMemberId: Long): ChatCharacter? fun findByCreatorMemberId(creatorMemberId: Long): ChatCharacter?
fun existsByCreatorMemberId(creatorMemberId: Long): Boolean fun existsByCreatorMemberId(creatorMemberId: Long): Boolean
} }

View File

@@ -1,12 +1,22 @@
package kr.co.vividnext.sodalive.common package kr.co.vividnext.sodalive.common
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import io.jsonwebtoken.JwtException
import org.springframework.security.authentication.BadCredentialsException
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.security.web.util.matcher.RequestMatcher
import org.springframework.web.filter.OncePerRequestFilter import org.springframework.web.filter.OncePerRequestFilter
import org.springframework.web.servlet.HandlerExceptionResolver
import javax.servlet.FilterChain import javax.servlet.FilterChain
import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse import javax.servlet.http.HttpServletResponse
class ExceptionHandlerFilter(private val objectMapper: ObjectMapper) : OncePerRequestFilter() { class ExceptionHandlerFilter(
private val objectMapper: ObjectMapper,
private val apiRequestMatcher: RequestMatcher,
private val apiAuthenticationEntryPoint: AuthenticationEntryPoint,
private val apiExceptionResolver: HandlerExceptionResolver
) : OncePerRequestFilter() {
override fun doFilterInternal( override fun doFilterInternal(
request: HttpServletRequest, request: HttpServletRequest,
response: HttpServletResponse, response: HttpServletResponse,
@@ -15,6 +25,18 @@ class ExceptionHandlerFilter(private val objectMapper: ObjectMapper) : OncePerRe
try { try {
filterChain.doFilter(request, response) filterChain.doFilter(request, response)
} catch (e: Exception) { } catch (e: Exception) {
if (apiRequestMatcher.matches(request)) {
if (isAuthenticationFailure(e)) {
val authenticationException = e as? BadCredentialsException
?: BadCredentialsException("Authentication failed", e)
apiAuthenticationEntryPoint.commence(request, response, authenticationException)
} else {
val resolved = apiExceptionResolver.resolveException(request, response, null, e)
if (resolved == null) throw e
}
return
}
response.status = 401 response.status = 401
response.contentType = "application/json" response.contentType = "application/json"
response.characterEncoding = "UTF-8" response.characterEncoding = "UTF-8"
@@ -23,4 +45,10 @@ class ExceptionHandlerFilter(private val objectMapper: ObjectMapper) : OncePerRe
response.writer.write(json) response.writer.write(json)
} }
} }
private fun isAuthenticationFailure(exception: Exception): Boolean {
return exception is JwtException ||
exception is BadCredentialsException ||
(exception is SodaException && exception.messageKey == "common.error.bad_credentials")
}
} }

View File

@@ -1,12 +1,9 @@
package kr.co.vividnext.sodalive.common package kr.co.vividnext.sodalive.common
import org.springframework.http.HttpStatus
class SodaException( class SodaException(
message: String? = null, message: String? = null,
val errorProperty: String? = null, val errorProperty: String? = null,
val messageKey: String? = null, val messageKey: String? = null
val httpStatus: HttpStatus? = null
) : RuntimeException(message) ) : RuntimeException(message)
class AdsChargeException( class AdsChargeException(

View File

@@ -6,7 +6,6 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.dao.DataIntegrityViolationException import org.springframework.dao.DataIntegrityViolationException
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.access.AccessDeniedException import org.springframework.security.access.AccessDeniedException
import org.springframework.security.authentication.BadCredentialsException import org.springframework.security.authentication.BadCredentialsException
import org.springframework.security.authentication.InternalAuthenticationServiceException import org.springframework.security.authentication.InternalAuthenticationServiceException
@@ -14,9 +13,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.multipart.MaxUploadSizeExceededException import org.springframework.web.multipart.MaxUploadSizeExceededException
import org.springframework.web.multipart.MultipartException
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
import javax.servlet.http.HttpServletRequest
@RestControllerAdvice @RestControllerAdvice
class SodaExceptionHandler( class SodaExceptionHandler(
@@ -41,25 +38,12 @@ class SodaExceptionHandler(
) )
} }
@ExceptionHandler(MaxUploadSizeExceededException::class, MultipartException::class) @ExceptionHandler(MaxUploadSizeExceededException::class)
fun handleMultipartException(e: MultipartException, request: HttpServletRequest) = run { fun handleMaxUploadSizeExceededException(e: MaxUploadSizeExceededException) = run {
val isAiCharacterAdminPath = isAiCharacterAdminPath(request.requestURI) val logMessage = messageSource.getMessage("common.error.max_upload_size", logLang)
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) logger.error("API error: {}", logMessage, e)
val message = messageSource.getMessage(messageKey, langContext.lang) val message = messageSource.getMessage("common.error.max_upload_size", langContext.lang)
val body = ApiResponse.error(message = message) ApiResponse.error(message = message)
if (isAiCharacterAdminPath) {
ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body)
} else {
body
}
} }
@ExceptionHandler(AccessDeniedException::class) @ExceptionHandler(AccessDeniedException::class)
@@ -115,12 +99,4 @@ class SodaExceptionHandler(
val message = messageSource.getMessage("common.error.unknown", langContext.lang) val message = messageSource.getMessage("common.error.unknown", langContext.lang)
ApiResponse.error(message) 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"
}
} }

View File

@@ -2,16 +2,19 @@ package kr.co.vividnext.sodalive.configs
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.common.ExceptionHandlerFilter import kr.co.vividnext.sodalive.common.ExceptionHandlerFilter
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
import kr.co.vividnext.sodalive.jwt.JwtFilter import kr.co.vividnext.sodalive.jwt.JwtFilter
import kr.co.vividnext.sodalive.jwt.TokenProvider 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.api.admin.aicharacter.error.AiCharacterAdminErrorResponseWriter
import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAuthenticationEntryPoint import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminExceptionHandler
import org.springframework.beans.factory.ObjectProvider import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.security.AiCharacterAdminSecurityErrorHandler
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.core.Ordered
import org.springframework.http.HttpMethod import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.builders.WebSecurity import org.springframework.security.config.annotation.web.builders.WebSecurity
@@ -20,8 +23,16 @@ import org.springframework.security.config.annotation.web.configuration.WebSecur
import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.access.AccessDeniedHandler
import org.springframework.security.web.access.RequestMatcherDelegatingAccessDeniedHandler
import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
import org.springframework.security.web.util.matcher.AntPathRequestMatcher
import org.springframework.security.web.util.matcher.RequestMatcher
import org.springframework.web.HttpRequestHandler
import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@@ -30,9 +41,7 @@ class SecurityConfig(
private val objectMapper: ObjectMapper, private val objectMapper: ObjectMapper,
private val tokenProvider: TokenProvider, private val tokenProvider: TokenProvider,
private val accessDeniedHandler: JwtAccessDeniedHandler, private val accessDeniedHandler: JwtAccessDeniedHandler,
private val authenticationEntryPoint: JwtAuthenticationEntryPoint, private val authenticationEntryPoint: JwtAuthenticationEntryPoint
private val aiCharacterAdminAuthenticationEntryPoint: ObjectProvider<AiCharacterAdminAuthenticationEntryPoint>,
private val aiCharacterAdminAccessDeniedHandler: ObjectProvider<AiCharacterAdminAccessDeniedHandler>
) { ) {
@Bean @Bean
fun passwordEncoder(): PasswordEncoder { fun passwordEncoder(): PasswordEncoder {
@@ -40,39 +49,94 @@ class SecurityConfig(
} }
@Bean @Bean
fun webSecurityCustomizer(): WebSecurityCustomizer { fun webSecurityCustomizer(
aiCharacterAdminSecurityErrorHandler: AiCharacterAdminSecurityErrorHandler
): WebSecurityCustomizer {
return WebSecurityCustomizer { web: WebSecurity -> return WebSecurityCustomizer { web: WebSecurity ->
web web
.requestRejectedHandler(aiCharacterAdminSecurityErrorHandler)
.ignoring() .ignoring()
.antMatchers("/h2-console/**", "/favicon.ico", "/error") .antMatchers("/h2-console/**", "/favicon.ico", "/error")
} }
} }
@Bean @Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain { fun aiCharacterAdminErrorResponseWriter(messageSource: SodaMessageSource): AiCharacterAdminErrorResponseWriter {
return AiCharacterAdminErrorResponseWriter(objectMapper, messageSource)
}
@Bean
fun aiCharacterAdminSecurityErrorHandler(
responseWriter: AiCharacterAdminErrorResponseWriter
): AiCharacterAdminSecurityErrorHandler {
return AiCharacterAdminSecurityErrorHandler(
responseWriter,
AntPathRequestMatcher(AI_CHARACTER_ADMIN_PATH),
WebConfig.createAiCharacterAdminCorsConfiguration()
)
}
@Bean
fun aiCharacterAdminExceptionHandler(
responseWriter: AiCharacterAdminErrorResponseWriter
): AiCharacterAdminExceptionHandler {
return AiCharacterAdminExceptionHandler(
responseWriter,
AntPathRequestMatcher(AI_CHARACTER_ADMIN_PATH)
)
}
@Bean
fun aiCharacterAdminFallbackHandlerMapping(
responseWriter: AiCharacterAdminErrorResponseWriter
): SimpleUrlHandlerMapping {
val notFoundHandler = HttpRequestHandler { request, response ->
responseWriter.write(
request,
response,
HttpStatus.NOT_FOUND,
"common.error.invalid_request"
)
}
return SimpleUrlHandlerMapping(
mapOf(AI_CHARACTER_ADMIN_PATH to notFoundHandler),
Ordered.LOWEST_PRECEDENCE - 2
).apply {
setCorsConfigurations(
mapOf(AI_CHARACTER_ADMIN_PATH to WebConfig.createAiCharacterAdminCorsConfiguration())
)
}
}
@Bean
fun filterChain(
http: HttpSecurity,
aiCharacterAdminSecurityErrorHandler: AiCharacterAdminSecurityErrorHandler,
aiCharacterAdminExceptionHandler: AiCharacterAdminExceptionHandler
): SecurityFilterChain {
val jwtFilter = JwtFilter(tokenProvider) val jwtFilter = JwtFilter(tokenProvider)
val aiCharacterAdminRequestMatcher = AntPathRequestMatcher(AI_CHARACTER_ADMIN_PATH)
val authenticationEntryPoints = linkedMapOf<RequestMatcher, AuthenticationEntryPoint>(
aiCharacterAdminRequestMatcher to aiCharacterAdminSecurityErrorHandler
)
val delegatingAuthenticationEntryPoint = DelegatingAuthenticationEntryPoint(authenticationEntryPoints).apply {
setDefaultEntryPoint(authenticationEntryPoint)
}
val accessDeniedHandlers = linkedMapOf<RequestMatcher, AccessDeniedHandler>(
aiCharacterAdminRequestMatcher to aiCharacterAdminSecurityErrorHandler
)
val delegatingAccessDeniedHandler = RequestMatcherDelegatingAccessDeniedHandler(
accessDeniedHandlers,
accessDeniedHandler
)
return http return http
.cors() .cors()
.and() .and()
.csrf().disable() .csrf().disable()
.exceptionHandling() .exceptionHandling()
.authenticationEntryPoint { request, response, authException -> .authenticationEntryPoint(delegatingAuthenticationEntryPoint)
val adminEntryPoint = aiCharacterAdminAuthenticationEntryPoint.getIfAvailable() .accessDeniedHandler(delegatingAccessDeniedHandler)
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() .and()
.headers() .headers()
.frameOptions() .frameOptions()
@@ -82,7 +146,15 @@ class SecurityConfig(
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and() .and()
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java)
.addFilterBefore(ExceptionHandlerFilter(objectMapper), JwtFilter::class.java) .addFilterBefore(
ExceptionHandlerFilter(
objectMapper,
aiCharacterAdminRequestMatcher,
aiCharacterAdminSecurityErrorHandler,
aiCharacterAdminExceptionHandler
),
JwtFilter::class.java
)
.authorizeRequests() .authorizeRequests()
.antMatchers("/member/check/email").permitAll() .antMatchers("/member/check/email").permitAll()
.antMatchers("/member/check/nickname").permitAll() .antMatchers("/member/check/nickname").permitAll()
@@ -119,7 +191,6 @@ class SecurityConfig(
.antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll()
.antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll()
.antMatchers(HttpMethod.GET, "/api/chat/original/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.POST, "/charge/payverse/webhook").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/home/recommendations").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/audio/recommendations").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/audio/recommendations").permitAll()
@@ -128,19 +199,20 @@ class SecurityConfig(
.antMatchers(HttpMethod.GET, "/api/v2/home/rankings/creators").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/home/rankings/creators").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/home/following").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/home/following").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/home/on-air-lives").authenticated() .antMatchers(HttpMethod.GET, "/api/v2/home/on-air-lives").authenticated()
.antMatchers(AI_CHARACTER_ADMIN_PATH)
.access(
"hasRole('ADMIN') and " +
"principal instanceof T(kr.co.vividnext.sodalive.member.MemberAdapter) and " +
"principal.member.role == T(kr.co.vividnext.sodalive.member.MemberRole).ADMIN"
)
// 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수 // 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated() .antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated()
.antMatchers(AI_CHARACTER_ADMIN_PATH_PREFIX, "$AI_CHARACTER_ADMIN_PATH_PREFIX/**").hasRole("ADMIN")
.anyRequest().authenticated() .anyRequest().authenticated()
.and() .and()
.build() .build()
} }
private fun isAiCharacterAdminPath(requestUri: String): Boolean {
return requestUri == AI_CHARACTER_ADMIN_PATH_PREFIX || requestUri.startsWith("$AI_CHARACTER_ADMIN_PATH_PREFIX/")
}
companion object { companion object {
private const val AI_CHARACTER_ADMIN_PATH_PREFIX = "/admin/ai-characters" private const val AI_CHARACTER_ADMIN_PATH = "/api/v2/admin/ai-characters/**"
} }
} }

View File

@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.configs
import kr.co.vividnext.sodalive.common.CountryInterceptor import kr.co.vividnext.sodalive.common.CountryInterceptor
import kr.co.vividnext.sodalive.i18n.LangInterceptor import kr.co.vividnext.sodalive.i18n.LangInterceptor
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.servlet.config.annotation.CorsRegistry import org.springframework.web.servlet.config.annotation.CorsRegistry
import org.springframework.web.servlet.config.annotation.InterceptorRegistry import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@@ -18,15 +19,49 @@ class WebConfig(
} }
override fun addCorsMappings(registry: CorsRegistry) { override fun addCorsMappings(registry: CorsRegistry) {
listOf("/admin/member/login", "/member/logout").forEach { path ->
registry.addMapping(path)
.allowedOrigins(*AI_CHARACTER_ADMIN_SHARED_AUTH_ALLOWED_ORIGINS.toTypedArray())
.allowedMethods("*")
.allowCredentials(true)
}
registry.addMapping("/api/v2/admin/ai-characters/**")
.allowedOrigins(*AI_CHARACTER_ADMIN_ALLOWED_ORIGINS.toTypedArray())
.allowedMethods("*")
.allowCredentials(true)
registry.addMapping("/**") registry.addMapping("/**")
.allowedOrigins( .allowedOrigins(*ALLOWED_ORIGINS.toTypedArray())
"http://localhost:8888",
"https://creator.sodalive.net",
"https://test-creator.sodalive.net",
"https://test-admin.sodalive.net",
"https://admin.sodalive.net"
)
.allowedMethods("*") .allowedMethods("*")
.allowCredentials(true) .allowCredentials(true)
} }
companion object {
private val ALLOWED_ORIGINS = listOf(
"http://localhost:8888",
"https://creator.sodalive.net",
"https://test-creator.sodalive.net",
"https://test-admin.sodalive.net",
"https://admin.sodalive.net"
)
private val AI_CHARACTER_ADMIN_ALLOWED_ORIGINS = listOf(
"http://localhost:8888",
"https://test-character-admin.sodalive.net",
"https://character-admin.sodalive.net"
)
private val AI_CHARACTER_ADMIN_SHARED_AUTH_ALLOWED_ORIGINS =
(ALLOWED_ORIGINS + AI_CHARACTER_ADMIN_ALLOWED_ORIGINS).distinct()
internal fun createAiCharacterAdminCorsConfiguration(): CorsConfiguration {
return CorsConfiguration().apply {
applyPermitDefaultValues()
allowedOrigins = AI_CHARACTER_ADMIN_ALLOWED_ORIGINS
allowedMethods = listOf(CorsConfiguration.ALL)
allowCredentials = true
}
}
}
} }

View File

@@ -220,9 +220,6 @@ class AudioContentService(
// request 내용 파싱 // request 내용 파싱
val request = objectMapper.readValue(requestString, CreateAudioContentRequest::class.java) val request = objectMapper.readValue(requestString, CreateAudioContentRequest::class.java)
// 미리듣기 시간 체크
validatePreviewTime(request.previewStartTime, request.previewEndTime)
val releaseDate = if (request.releaseDate != null) { val releaseDate = if (request.releaseDate != null) {
request.releaseDate.convertLocalDateTime("yyyy-MM-dd HH:mm") request.releaseDate.convertLocalDateTime("yyyy-MM-dd HH:mm")
.atZone(ZoneId.of(request.timezone)) .atZone(ZoneId.of(request.timezone))
@@ -232,11 +229,26 @@ class AudioContentService(
LocalDateTime.now() LocalDateTime.now()
} }
return createAudioContent(contentFile, coverImage, request, releaseDate, member)
}
@Transactional
fun createAudioContent(
contentFile: MultipartFile?,
coverImage: MultipartFile?,
request: CreateAudioContentRequest,
releaseDate: LocalDateTime,
member: Member
): CreateAudioContentResponse {
if (coverImage == null) throw SodaException(messageKey = "content.error.cover_image_required")
// contentFile 체크 // contentFile 체크
if (contentFile == null) { if (contentFile == null) {
throw SodaException(messageKey = "content.error.content_required") throw SodaException(messageKey = "content.error.content_required")
} }
validatePreviewTime(request.previewStartTime, request.previewEndTime)
// 테마 체크 // 테마 체크
val theme = themeQueryRepository.findThemeByIdAndActive(id = request.themeId) val theme = themeQueryRepository.findThemeByIdAndActive(id = request.themeId)
?: throw SodaException(messageKey = "content.error.invalid_theme") ?: throw SodaException(messageKey = "content.error.invalid_theme")

View File

@@ -7,8 +7,19 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seri
import kr.co.vividnext.sodalive.creator.admin.content.series.content.QSearchContentNotInSeriesResponse import kr.co.vividnext.sodalive.creator.admin.content.series.content.QSearchContentNotInSeriesResponse
import kr.co.vividnext.sodalive.creator.admin.content.series.content.SearchContentNotInSeriesResponse import kr.co.vividnext.sodalive.creator.admin.content.series.content.SearchContentNotInSeriesResponse
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import javax.persistence.LockModeType
interface CreatorAdminContentSeriesRepository : JpaRepository<Series, Long>, CreatorAdminContentSeriesQueryRepository interface CreatorAdminContentSeriesRepository : JpaRepository<Series, Long>, CreatorAdminContentSeriesQueryRepository {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Series s where s.member.id = :creatorId and s.id in :ids and s.isActive = true order by s.id asc")
fun findActiveByCreatorIdAndIdInForUpdate(
@Param("creatorId") creatorId: Long,
@Param("ids") ids: List<Long>
): List<Series>
}
interface CreatorAdminContentSeriesQueryRepository { interface CreatorAdminContentSeriesQueryRepository {
fun findByIdAndCreatorId(id: Long, creatorId: Long): Series? fun findByIdAndCreatorId(id: Long, creatorId: Long): Series?

View File

@@ -83,15 +83,21 @@ class TokenProvider(
.parseClaimsJws(token) .parseClaimsJws(token)
.body .body
val authorities = claims[AUTHORITIES_KEY].toString().split(",").map { SimpleGrantedAuthority(it) } val memberId = claims.subject?.toLongOrNull()
val memberToken = tokenRepository.findByIdOrNull(id = claims.subject.toLong()) ?: throw SodaException(messageKey = "common.error.bad_credentials")
val authorityNames = (claims[AUTHORITIES_KEY] as? String)
?.split(",")
?.takeIf { names -> names.all { it.isNotBlank() } }
?: throw SodaException(messageKey = "common.error.bad_credentials")
val authorities = authorityNames.map { SimpleGrantedAuthority(it) }
val memberToken = tokenRepository.findByIdOrNull(id = memberId)
?: throw SodaException(messageKey = "common.error.bad_credentials") ?: throw SodaException(messageKey = "common.error.bad_credentials")
if (!memberToken.tokenSet.contains(token)) { if (!memberToken.tokenSet.contains(token)) {
throw SodaException(messageKey = "common.error.bad_credentials") throw SodaException(messageKey = "common.error.bad_credentials")
} }
val member = repository.findByIdOrNull(id = claims.subject.toLong()) val member = repository.findByIdOrNull(id = memberId)
?: throw SodaException(messageKey = "common.error.bad_credentials") ?: throw SodaException(messageKey = "common.error.bad_credentials")
val principal = MemberAdapter(member) val principal = MemberAdapter(member)

View File

@@ -1,27 +0,0 @@
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("권한이 없습니다.")))
}
}

View File

@@ -1,27 +0,0 @@
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("로그인 정보를 확인해주세요.")))
}
}

View File

@@ -1,395 +0,0 @@
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
)

View File

@@ -1,49 +0,0 @@
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<String>): 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<String>): 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
}
}

View File

@@ -1,106 +0,0 @@
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<ApiResponse<Any>> {
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<ApiResponse<Any>> {
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<ApiResponse<Any>> {
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<ApiResponse<Any>> {
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<JsonMappingException>()
.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."
}
}

View File

@@ -1,26 +0,0 @@
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
}

View File

@@ -1,128 +0,0 @@
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
}

View File

@@ -1,37 +0,0 @@
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<T>(
val totalCount: Long,
val items: List<T>,
val page: Int,
val size: Int,
val hasNext: Boolean
) {
companion object {
fun <T> of(totalCount: Long, content: List<T>, page: Int, size: Int): AdminPageResponse<T> {
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
)

View File

@@ -1,51 +0,0 @@
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
}
}
}

View File

@@ -1,44 +0,0 @@
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
)
}
}

View File

@@ -1,13 +0,0 @@
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
)

View File

@@ -1,7 +0,0 @@
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?
}

View File

@@ -0,0 +1,43 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class AiCharacterAdminTargetResolver(
private val chatCharacterRepository: ChatCharacterRepository
) {
@Transactional(readOnly = true)
fun resolve(characterId: Long): AiCharacterAdminTarget {
val chatCharacter = chatCharacterRepository.findByIdWithCreatorMember(characterId)
?: throw invalidTarget()
val creatorMember = chatCharacter.creatorMember
if (creatorMember?.role != MemberRole.CREATOR || creatorMember.memberKind != MemberKind.AI_CHARACTER) {
throw invalidTarget()
}
return AiCharacterAdminTarget(
characterId = characterId,
chatCharacter = chatCharacter,
creatorMember = creatorMember
)
}
private fun invalidTarget(): AiCharacterAdminApiException {
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
}
data class AiCharacterAdminTarget(
val characterId: Long,
val chatCharacter: ChatCharacter,
val creatorMember: Member
)

View File

@@ -0,0 +1,90 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkResponse
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.web.HttpMediaTypeNotSupportedException
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.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.multipart.MultipartHttpServletRequest
@RestController
@RequestMapping("/api/v2/admin/ai-characters")
class AiCharacterAdminCharacterController(
private val facade: AiCharacterAdminCharacterFacade
) {
@GetMapping
fun list(
@RequestParam(required = false) searchTerm: String?,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
): ApiResponse<AiCharacterAdminListResponse> {
return ApiResponse.ok(facade.list(searchTerm, page, size))
}
@GetMapping("/original-works/search")
fun searchOriginalWorks(
@RequestParam("searchTerm") searchTerm: String
): ApiResponse<List<OriginalWorkResponse>> {
return ApiResponse.ok(facade.searchOriginalWorks(searchTerm))
}
@GetMapping("/{characterId:[0-9]+}")
fun detail(@PathVariable characterId: Long): ApiResponse<AiCharacterAdminCharacterResponse> {
return ApiResponse.ok(facade.detail(characterId))
}
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun create(
@RequestPart("image") image: MultipartFile,
@RequestPart("request") request: String,
multipartRequest: MultipartHttpServletRequest
): ApiResponse<Nothing> {
requireAllowedMultipartParts(multipartRequest)
requireJsonRequestPart(multipartRequest)
facade.create(image, request)
return ApiResponse.ok(null)
}
@PutMapping("/{characterId:[0-9]+}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun update(
@PathVariable characterId: Long,
@RequestPart(value = "image", required = false) image: MultipartFile?,
@RequestPart("request") request: String,
multipartRequest: MultipartHttpServletRequest
): ApiResponse<Nothing> {
requireAllowedMultipartParts(multipartRequest)
requireJsonRequestPart(multipartRequest)
facade.update(characterId, image, request)
return ApiResponse.ok(null)
}
private fun requireAllowedMultipartParts(multipartRequest: MultipartHttpServletRequest) {
if (multipartRequest.fileMap.keys.any { it !in CHARACTER_MULTIPART_PARTS } ||
multipartRequest.parts.any { it.name !in CHARACTER_MULTIPART_PARTS }
) {
throw AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
}
private fun requireJsonRequestPart(multipartRequest: MultipartHttpServletRequest) {
val contentType = multipartRequest.getMultipartHeaders("request")?.contentType
?: multipartRequest.getPart("request")?.contentType?.let(MediaType::parseMediaType)
if (contentType == null || !MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
throw HttpMediaTypeNotSupportedException(contentType, listOf(MediaType.APPLICATION_JSON))
}
}
companion object {
private val CHARACTER_MULTIPART_PARTS = setOf("image", "request")
}
}

View File

@@ -0,0 +1,40 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
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.ChatCharacterRegisterRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRelationshipRequest
typealias AiCharacterAdminListResponse = ChatCharacterListPageResponse
typealias AiCharacterAdminCharacterListItemResponse = ChatCharacterListResponse
typealias AiCharacterAdminCharacterResponse = ChatCharacterDetailResponse
typealias AiCharacterAdminCharacterCreateRequest = ChatCharacterRegisterRequest
data class AiCharacterAdminCharacterUpdateRequest(
val name: String? = null,
val systemPrompt: String? = null,
val description: String? = null,
val age: String? = null,
val gender: String? = null,
val mbti: String? = null,
val speechPattern: String? = null,
val speechStyle: String? = null,
val appearance: String? = null,
val originalTitle: String? = null,
val originalLink: String? = null,
val originalWorkId: Long? = null,
val characterType: String? = null,
val isActive: Boolean? = null,
val tags: List<String>? = null,
val hobbies: List<String>? = null,
val values: List<String>? = null,
val goals: List<String>? = null,
val relationships: List<ChatCharacterRelationshipRequest>? = null,
val personalities: List<ChatCharacterPersonalityRequest>? = null,
val backgrounds: List<ChatCharacterBackgroundRequest>? = null,
val memories: List<ChatCharacterMemoryRequest>? = null
)

View File

@@ -0,0 +1,139 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.client.SimpleClientHttpRequestFactory
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClientException
import org.springframework.web.client.RestTemplate
@Component
class AiCharacterAdminCharacterExternalApiClient(
@Value("\${weraser.api-key}") private val apiKey: String,
@Value("\${weraser.api-url}") private val apiUrl: String
) {
fun create(request: AiCharacterAdminCharacterCreateRequest): String {
val body = mutableMapOf<String, Any>(
"name" to request.name,
"systemPrompt" to request.systemPrompt,
"description" to request.description,
"region" to request.region
)
request.age?.let { body["age"] = it }
request.gender?.let { body["gender"] = mapGender(request.region, it) }
request.mbti?.let { body["mbti"] = it }
request.speechPattern?.let { body["speechPattern"] = it }
request.speechStyle?.let { body["speechStyle"] = it }
request.appearance?.let { body["appearance"] = it }
if (request.tags.isNotEmpty()) body["tags"] = request.tags
if (request.hobbies.isNotEmpty()) body["hobbies"] = request.hobbies
if (request.values.isNotEmpty()) body["values"] = request.values
if (request.goals.isNotEmpty()) body["goals"] = request.goals
if (request.relationships.isNotEmpty()) body["relationships"] = request.relationships
if (request.personalities.isNotEmpty()) body["personalities"] = request.personalities
if (request.backgrounds.isNotEmpty()) body["backgrounds"] = request.backgrounds
if (request.memories.isNotEmpty()) body["memories"] = request.memories
return exchange(HttpMethod.POST, "/api/characters", body) ?: throw invalidRequest()
}
fun update(
chatCharacter: ChatCharacter,
request: AiCharacterAdminCharacterUpdateRequest,
requestName: String?
) {
val body = mutableMapOf<String, Any>()
if (request.isActive == false) {
val inactiveName = java.util.UUID.randomUUID().toString().replace("-", "")
body["name"] = "inactive_${requestName}_$inactiveName"
} else {
request.name?.let { body["name"] = it }
request.systemPrompt?.let { body["systemPrompt"] = it }
request.description?.let { body["description"] = it }
request.age?.let { body["age"] = it }
request.gender?.let { body["gender"] = it }
request.mbti?.let { body["mbti"] = it }
request.speechPattern?.let { body["speechPattern"] = it }
request.speechStyle?.let { body["speechStyle"] = it }
request.appearance?.let { body["appearance"] = it }
request.tags?.let { body["tags"] = it }
request.hobbies?.let { body["hobbies"] = it }
request.values?.let { body["values"] = it }
request.goals?.let { body["goals"] = it }
request.relationships?.let { body["relationships"] = it }
request.personalities?.let { body["personalities"] = it }
request.backgrounds?.let { body["backgrounds"] = it }
request.memories?.let { body["memories"] = it }
}
exchange(HttpMethod.PUT, "/api/characters/${chatCharacter.characterUUID}", body)
}
private fun exchange(method: HttpMethod, path: String, body: Map<String, Any>): String? {
val headers = HttpHeaders().apply {
set("x-api-key", apiKey)
contentType = MediaType.APPLICATION_JSON
}
val response = try {
createRestTemplate().exchange(
"$apiUrl$path",
method,
HttpEntity(body, headers),
ExternalCharacterResponse::class.java
)
} catch (_: RestClientException) {
throw invalidRequest()
}
val externalResponse = response.body ?: throw invalidRequest()
if (!externalResponse.success) {
throw invalidRequest()
}
return externalResponse.data?.id
}
private fun createRestTemplate(): RestTemplate {
return RestTemplate(
SimpleClientHttpRequestFactory().apply {
setConnectTimeout(EXTERNAL_API_TIMEOUT_MILLIS)
setReadTimeout(EXTERNAL_API_TIMEOUT_MILLIS)
}
)
}
private fun mapGender(region: String, gender: String): String {
if (!region.equals("JP", ignoreCase = true)) return gender
return when (gender) {
"여성" -> "女性"
"남성" -> "男性"
"기타" -> "その他"
else -> gender
}
}
private fun invalidRequest(): AiCharacterAdminApiException {
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
@JsonIgnoreProperties(ignoreUnknown = true)
private data class ExternalCharacterResponse(
val success: Boolean,
val data: ExternalCharacterData? = null
)
@JsonIgnoreProperties(ignoreUnknown = true)
private data class ExternalCharacterData(
val id: String
)
companion object {
private const val EXTERNAL_API_TIMEOUT_MILLIS = 20_000
}
}

View File

@@ -0,0 +1,191 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkResponse
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberService
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
@Service
class AiCharacterAdminCharacterFacade(
private val objectMapper: ObjectMapper,
private val chatCharacterService: ChatCharacterService,
private val chatCharacterRepository: ChatCharacterRepository,
private val targetResolver: AiCharacterAdminTargetResolver,
private val externalApiClient: AiCharacterAdminCharacterExternalApiClient,
private val imageStorage: AiCharacterAdminCharacterImageStorage,
private val characterMapper: AiCharacterAdminCharacterMapper,
private val originalWorkService: AdminOriginalWorkService,
private val creatorMemberService: ChatCharacterCreatorMemberService,
private val applicationEventPublisher: ApplicationEventPublisher,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
@Transactional(readOnly = true)
fun list(searchTerm: String?, page: Int, size: Int): AiCharacterAdminListResponse {
val normalizedPage = page.coerceAtLeast(0)
val normalizedSize = size.coerceAtLeast(MINIMUM_PAGE_SIZE)
val characters = chatCharacterRepository.searchAiCharacters(
searchTerm = searchTerm?.trim().orEmpty(),
role = MemberRole.CREATOR,
memberKind = MemberKind.AI_CHARACTER,
pageable = PageRequest.of(normalizedPage, normalizedSize, Sort.by(Sort.Direction.DESC, "createdAt"))
)
return AiCharacterAdminListResponse(
totalCount = characters.totalElements,
content = characters.content.map(characterMapper::toListItemResponse)
)
}
@Transactional(readOnly = true)
fun detail(characterId: Long): AiCharacterAdminCharacterResponse {
return characterMapper.toResponse(targetResolver.resolve(characterId).chatCharacter)
}
@Transactional(readOnly = true)
fun searchOriginalWorks(searchTerm: String): List<OriginalWorkResponse> {
return originalWorkService.searchOriginalWorksAll(searchTerm)
.map { OriginalWorkResponse.from(it, imageHost) }
}
@Transactional
fun create(image: MultipartFile?, requestString: String) {
if (image?.isEmpty == true) throw invalidRequest()
val request = readRequest(requestString, AiCharacterAdminCharacterCreateRequest::class.java)
val characterType = characterMapper.toCharacterType(request)
if (chatCharacterService.findByName(request.name) != null) {
throw invalidRequest()
}
request.originalWorkId?.takeIf { it != 0L }?.let(originalWorkService::getOriginalWork)
val chatCharacter = chatCharacterService.createChatCharacterWithDetails(
characterUUID = externalApiClient.create(request),
name = request.name,
description = request.description,
systemPrompt = request.systemPrompt,
age = request.age?.toIntOrNull(),
gender = request.gender,
mbti = request.mbti,
speechPattern = request.speechPattern,
speechStyle = request.speechStyle,
appearance = request.appearance,
originalTitle = request.originalTitle,
originalLink = request.originalLink,
characterType = characterType,
region = request.region,
tags = request.tags,
values = request.values,
hobbies = request.hobbies,
goals = request.goals,
memories = request.memories.map { Triple(it.title, it.content, it.emotion) },
personalities = request.personalities.map { Pair(it.trait, it.description) },
backgrounds = request.backgrounds.map { Pair(it.topic, it.description) },
relationships = request.relationships
)
if (image?.isEmpty == false) {
chatCharacter.imagePath = imageStorage.upload(chatCharacter.id ?: throw invalidRequest(), image)
chatCharacterService.saveChatCharacter(chatCharacter)
creatorMemberService.syncAiCharacterCreatorMemberDisplayFields(chatCharacter)
}
request.originalWorkId?.let { originalWorkService.assignOneCharacter(it, chatCharacter.id ?: throw invalidRequest()) }
if (chatCharacter.languageCode.isNullOrBlank() && chatCharacter.description.isNotBlank()) {
applicationEventPublisher.publishEvent(
LanguageDetectEvent(
id = chatCharacter.id ?: throw invalidRequest(),
query = chatCharacter.description,
targetType = LanguageDetectTargetType.CHARACTER
)
)
}
}
@Transactional
fun update(characterId: Long, image: MultipartFile?, requestString: String) {
val target = targetResolver.resolve(characterId)
val request = readRequest(requestString, AiCharacterAdminCharacterUpdateRequest::class.java)
if (request.isActive == false && image?.isEmpty == false) {
throw invalidRequest()
}
val effectiveRequest = if (request.isActive == false) {
AiCharacterAdminCharacterUpdateRequest(isActive = false)
} else {
request
}
characterMapper.validateCharacterType(effectiveRequest)
if (
!characterMapper.hasExternalChanges(effectiveRequest) &&
!characterMapper.hasDbOnlyChanges(effectiveRequest) &&
image?.isEmpty != false
) {
throw invalidRequest()
}
if (
effectiveRequest.name != null && effectiveRequest.name != target.chatCharacter.name &&
chatCharacterService.findByName(effectiveRequest.name) != null
) {
throw invalidRequest()
}
effectiveRequest.originalWorkId?.takeIf { it != 0L }?.let(originalWorkService::getOriginalWork)
val requestName = if (effectiveRequest.isActive == false) target.chatCharacter.name else effectiveRequest.name
if (characterMapper.hasExternalChanges(effectiveRequest)) {
externalApiClient.update(target.chatCharacter, effectiveRequest, requestName)
}
val imagePath = if (image?.isEmpty == false) imageStorage.upload(characterId, image) else null
chatCharacterService.updateChatCharacterWithDetails(
imagePath = imagePath,
request = characterMapper.toLegacyRequest(effectiveRequest, characterId, requestName)
)
effectiveRequest.originalWorkId?.let { originalWorkService.assignOneCharacter(it, characterId) }
chatCharacterRepository.flush()
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = characterId,
targetType = LanguageTranslationTargetType.CHARACTER,
waitTransactionCommit = true
)
)
}
private fun <T> readRequest(requestString: String, requestClass: Class<T>): T {
return try {
objectMapper.readerFor(requestClass)
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.with(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
.readValue(requestString)
} catch (_: JsonProcessingException) {
throw invalidRequest()
}
}
private fun invalidRequest(): AiCharacterAdminApiException {
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
companion object {
private const val MINIMUM_PAGE_SIZE = 1
}
}

View File

@@ -0,0 +1,24 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
import com.amazonaws.services.s3.model.ObjectMetadata
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import org.springframework.web.multipart.MultipartFile
@Component
class AiCharacterAdminCharacterImageStorage(
private val s3Uploader: S3Uploader,
@Value("\${cloud.aws.s3.bucket}") private val s3Bucket: String
) {
fun upload(characterId: Long, image: MultipartFile): String {
val metadata = ObjectMetadata().apply { contentLength = image.size }
return s3Uploader.upload(
inputStream = image.inputStream,
bucket = s3Bucket,
filePath = "characters/$characterId/${generateFileName(prefix = "character")}",
metadata = metadata
)
}
}

View File

@@ -0,0 +1,89 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest
import kr.co.vividnext.sodalive.chat.character.CharacterType
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
@Component
class AiCharacterAdminCharacterMapper(
@Value("\${cloud.aws.cloud-front.host}") private val imageHost: String
) {
fun toListItemResponse(chatCharacter: ChatCharacter): AiCharacterAdminCharacterListItemResponse {
return ChatCharacterListResponse.from(chatCharacter, imageHost)
}
fun toResponse(chatCharacter: ChatCharacter): AiCharacterAdminCharacterResponse {
return ChatCharacterDetailResponse.from(chatCharacter, imageHost)
}
fun toCharacterType(request: AiCharacterAdminCharacterCreateRequest): CharacterType {
return request.characterType?.let { toCharacterType(it) } ?: CharacterType.Character
}
fun validateCharacterType(request: AiCharacterAdminCharacterUpdateRequest) {
request.characterType?.let { toCharacterType(it) }
}
fun hasExternalChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean {
return hasRegularExternalChanges(request) || request.isActive != null
}
private fun hasRegularExternalChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean {
return request.name != null || request.systemPrompt != null || request.description != null || request.age != null ||
request.gender != null || request.mbti != null || request.speechPattern != null || request.speechStyle != null ||
request.appearance != null || request.tags != null || request.hobbies != null ||
request.values != null || request.goals != null || request.relationships != null || request.personalities != null ||
request.backgrounds != null || request.memories != null
}
fun hasDbOnlyChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean {
return request.originalTitle != null || request.originalLink != null || request.characterType != null ||
request.originalWorkId != null
}
fun toLegacyRequest(
request: AiCharacterAdminCharacterUpdateRequest,
characterId: Long,
requestName: String?
): ChatCharacterUpdateRequest {
return ChatCharacterUpdateRequest(
id = characterId,
name = requestName,
systemPrompt = request.systemPrompt,
description = request.description,
age = request.age,
gender = request.gender,
mbti = request.mbti,
speechPattern = request.speechPattern,
speechStyle = request.speechStyle,
appearance = request.appearance,
originalTitle = request.originalTitle,
originalLink = request.originalLink,
originalWorkId = request.originalWorkId,
characterType = request.characterType,
isActive = request.isActive,
tags = request.tags,
hobbies = request.hobbies,
values = request.values,
goals = request.goals,
relationships = request.relationships,
personalities = request.personalities,
backgrounds = request.backgrounds,
memories = request.memories
)
}
private fun toCharacterType(characterType: String): CharacterType {
return runCatching { CharacterType.valueOf(characterType) }.getOrElse { throw invalidRequest() }
}
private fun invalidRequest(): AiCharacterAdminApiException {
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
}

View File

@@ -0,0 +1,138 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.web.HttpMediaTypeNotSupportedException
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.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.multipart.MultipartHttpServletRequest
@RestController
@RequestMapping("/api/v2/admin/ai-characters/{characterId:[0-9]+}/community-posts")
class AiCharacterAdminCommunityPostController(
private val facade: AiCharacterAdminCommunityPostFacade
) {
@GetMapping
fun list(
@PathVariable characterId: Long,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
): ApiResponse<AiCharacterAdminCommunityPostListResponse> {
return ApiResponse.ok(facade.list(characterId, page, size))
}
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun create(
@PathVariable characterId: Long,
@RequestPart(value = "audioFile", required = false) audioFile: MultipartFile?,
@RequestPart(value = "postImage", required = false) postImage: MultipartFile?,
@RequestPart("request") request: String,
multipartRequest: MultipartHttpServletRequest
): ApiResponse<Nothing> {
requireAllowedMultipartParts(multipartRequest, CREATE_MULTIPART_PARTS)
requireJsonRequestPart(multipartRequest)
facade.create(characterId, audioFile, postImage, request)
return ApiResponse.ok(null)
}
@PutMapping("/{postId:[0-9]+}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun update(
@PathVariable characterId: Long,
@PathVariable postId: Long,
@RequestPart(value = "postImage", required = false) postImage: MultipartFile?,
@RequestPart("request") request: String,
multipartRequest: MultipartHttpServletRequest
): ApiResponse<Nothing> {
requireAllowedMultipartParts(multipartRequest, UPDATE_MULTIPART_PARTS)
requireJsonRequestPart(multipartRequest)
facade.update(characterId, postId, postImage, request)
return ApiResponse.ok(null)
}
private fun requireAllowedMultipartParts(
multipartRequest: MultipartHttpServletRequest,
allowedParts: Set<String>
) {
if (multipartRequest.fileMap.keys.any { it !in allowedParts } ||
multipartRequest.parts.any { it.name !in allowedParts }
) {
throw AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
}
private fun requireJsonRequestPart(multipartRequest: MultipartHttpServletRequest) {
val contentType = multipartRequest.getMultipartHeaders("request")?.contentType
?: multipartRequest.getPart("request")?.contentType?.let(MediaType::parseMediaType)
if (contentType == null || !MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
throw HttpMediaTypeNotSupportedException(contentType, listOf(MediaType.APPLICATION_JSON))
}
}
companion object {
private val CREATE_MULTIPART_PARTS = setOf("audioFile", "postImage", "request")
private val UPDATE_MULTIPART_PARTS = setOf("postImage", "request")
}
@GetMapping("/{postId:[0-9]+}/comments")
fun comments(
@PathVariable characterId: Long,
@PathVariable postId: Long,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
): ApiResponse<AiCharacterAdminCommunityPostCommentListResponse> {
return ApiResponse.ok(facade.comments(characterId, postId, page, size))
}
@PostMapping("/{postId:[0-9]+}/comments", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun createComment(
@PathVariable characterId: Long,
@PathVariable postId: Long,
@RequestBody request: String
): ApiResponse<Nothing> {
facade.createComment(characterId, postId, request)
return ApiResponse.ok(null)
}
@PutMapping("/{postId:[0-9]+}/comments/{commentId:[0-9]+}", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun updateComment(
@PathVariable characterId: Long,
@PathVariable postId: Long,
@PathVariable commentId: Long,
@RequestBody request: String
): ApiResponse<Nothing> {
facade.updateComment(characterId, postId, commentId, request)
return ApiResponse.ok(null)
}
@DeleteMapping("/{postId:[0-9]+}/comments/{commentId:[0-9]+}")
fun deleteComment(
@PathVariable characterId: Long,
@PathVariable postId: Long,
@PathVariable commentId: Long
): ApiResponse<Nothing> {
facade.deleteComment(characterId, postId, commentId)
return ApiResponse.ok(null)
}
@GetMapping("/{postId:[0-9]+}/comments/{commentId:[0-9]+}/replies")
fun commentReplies(
@PathVariable characterId: Long,
@PathVariable postId: Long,
@PathVariable commentId: Long,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
): ApiResponse<AiCharacterAdminCommunityPostCommentListResponse> {
return ApiResponse.ok(facade.commentReplies(characterId, postId, commentId, page, size))
}
}

View File

@@ -0,0 +1,33 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.GetCommunityPostListResponse
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.GetCommunityPostCommentListResponse
typealias AiCharacterAdminCommunityPostDto = GetCommunityPostListResponse
typealias AiCharacterAdminCommunityPostCommentListResponse = GetCommunityPostCommentListResponse
data class AiCharacterAdminCommunityPostListResponse(
val totalCount: Long,
val page: Int,
val size: Int,
val hasNext: Boolean,
val items: List<AiCharacterAdminCommunityPostDto>
)
data class AiCharacterAdminCommunityPostUpdateRequest(
val content: String? = null,
val isCommentAvailable: Boolean? = null,
val isAdult: Boolean? = null,
val isActive: Boolean? = null,
val isFixed: Boolean? = null
)
data class AiCharacterAdminCommunityPostCommentCreateRequest(
val comment: String,
val parentId: Long? = null,
val isSecret: Boolean = false
)
data class AiCharacterAdminCommunityPostCommentUpdateRequest(
val comment: String
)

View File

@@ -0,0 +1,286 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreateCommunityPostRequest
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.ModifyCommunityPostRequest
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.UpdateCommunityPostFixedRequest
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityCommentRepository
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.GetCommunityPostCommentListItem
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.ModifyCommunityPostCommentRequest
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLikeRepository
import kr.co.vividnext.sodalive.extensions.getTimeAgoString
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTarget
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.PageRequest
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
import java.time.ZoneId
@Service
class AiCharacterAdminCommunityPostFacade(
private val targetResolver: AiCharacterAdminTargetResolver,
private val legacyService: CreatorCommunityService,
private val objectMapper: ObjectMapper,
private val repository: AiCharacterAdminCommunityPostRepository,
private val memberRepository: MemberRepository,
private val likeRepository: CreatorCommunityLikeRepository,
private val commentRepository: CreatorCommunityCommentRepository,
private val audioContentCloudFront: AudioContentCloudFront,
@Value("\${cloud.aws.cloud-front.host}") private val imageHost: String
) {
@Transactional
fun create(characterId: Long, audioFile: MultipartFile?, postImage: MultipartFile?, request: String) {
val target = resolveActiveTarget(characterId)
readRequest(request, CreateCommunityPostRequest::class.java)
legacyService.createCommunityPost(audioFile, postImage, request, target.creatorMember)
}
@Transactional
fun update(characterId: Long, postId: Long, postImage: MultipartFile?, requestString: String) {
val target = resolveActiveTarget(characterId)
val creatorMember = target.creatorMember
val creatorMemberId = creatorMember.id ?: throw invalidRequest()
repository.findActiveByIdAndCreatorMemberId(postId, creatorMemberId) ?: throw invalidRequest()
rejectExplicitNull(requestString, "isFixed")
val request = readRequest(requestString, AiCharacterAdminCommunityPostUpdateRequest::class.java)
val modifyRequest = ModifyCommunityPostRequest(
creatorCommunityId = postId,
content = request.content,
isCommentAvailable = request.isCommentAvailable,
isAdult = request.isAdult,
isActive = request.isActive
)
if (request.isActive != false && request.isFixed != null) {
memberRepository.findByIdForUpdate(creatorMemberId) ?: throw invalidRequest()
legacyService.updateCommunityPostFixed(
UpdateCommunityPostFixedRequest(postId = postId, isFixed = request.isFixed),
creatorMember
)
}
legacyService.modifyCommunityPost(postImage, objectMapper.writeValueAsString(modifyRequest), creatorMember)
}
@Transactional(readOnly = true)
fun list(
characterId: Long,
page: Int,
size: Int
): AiCharacterAdminCommunityPostListResponse {
val target = resolveActiveTarget(characterId)
if (page < 0 || size < 1) throw invalidRequest()
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
val pageable = PageRequest.of(page, size)
val items = repository.findActiveByCreatorMemberId(creatorMemberId, pageable)
.map { post -> post.toResponse(target.creatorMember, creatorMemberId) }
val totalCount = repository.countActiveByCreatorMemberId(creatorMemberId)
return AiCharacterAdminCommunityPostListResponse(
totalCount = totalCount,
page = page,
size = size,
hasNext = (page + 1L) * size < totalCount,
items = items
)
}
@Transactional(readOnly = true)
fun comments(
characterId: Long,
postId: Long,
page: Int,
size: Int
): AiCharacterAdminCommunityPostCommentListResponse {
val target = resolveOwnedActivePost(characterId, postId)
validateListRequest(page, size)
val pageable = PageRequest.of(page, size)
return utcCommentDates(
legacyService.getCommunityPostCommentList(
postId = postId,
memberId = target.creatorMember.id ?: throw invalidRequest(),
timezone = UTC_TIMEZONE,
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
isAdult = true
),
postId
)
}
@Transactional(readOnly = true)
fun commentReplies(
characterId: Long,
postId: Long,
commentId: Long,
page: Int,
size: Int
): AiCharacterAdminCommunityPostCommentListResponse {
val target = resolveOwnedActivePost(characterId, postId)
validateListRequest(page, size)
repository.findActiveRootCommentByIdAndPostId(commentId, postId) ?: throw invalidRequest()
val pageable = PageRequest.of(page, size)
return utcCommentDates(
legacyService.getCommentReplyList(
commentId = commentId,
memberId = target.creatorMember.id ?: throw invalidRequest(),
timezone = UTC_TIMEZONE,
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
isAdult = true
),
postId
)
}
@Transactional
fun createComment(characterId: Long, postId: Long, requestString: String) {
val target = resolveOwnedActivePost(characterId, postId)
val request = readRequest(requestString, AiCharacterAdminCommunityPostCommentCreateRequest::class.java)
request.parentId?.let { parentId ->
repository.findActiveRootCommentByIdAndPostId(parentId, postId) ?: throw invalidRequest()
}
legacyService.createCommunityPostComment(
member = target.creatorMember,
comment = request.comment,
postId = postId,
parentId = request.parentId,
isSecret = request.isSecret,
isAdult = true
)
}
@Transactional
fun updateComment(characterId: Long, postId: Long, commentId: Long, requestString: String) {
val target = resolveOwnedActivePost(characterId, postId)
val request = readRequest(requestString, AiCharacterAdminCommunityPostCommentUpdateRequest::class.java)
val comment = repository.findCommentByIdAndPostId(commentId, postId) ?: throw invalidRequest()
if (!comment.isActive || comment.member?.id != target.creatorMember.id) throw invalidRequest()
legacyService.modifyCommunityPostComment(
ModifyCommunityPostCommentRequest(commentId = commentId, comment = request.comment, isActive = null),
target.creatorMember
)
}
@Transactional
fun deleteComment(characterId: Long, postId: Long, commentId: Long) {
val target = resolveOwnedActivePost(characterId, postId)
val comment = repository.findCommentByIdAndPostId(commentId, postId) ?: throw invalidRequest()
if (!comment.isActive) return
legacyService.modifyCommunityPostComment(
ModifyCommunityPostCommentRequest(commentId = commentId, comment = null, isActive = false),
target.creatorMember
)
}
private fun CreatorCommunity.toResponse(member: Member, creatorMemberId: Long): AiCharacterAdminCommunityPostDto {
val postId = id ?: throw invalidRequest()
val createdAt = createdAt ?: throw invalidRequest()
val audioUrl = audioPath?.let {
audioContentCloudFront.generateSignedURL(it, AUDIO_URL_EXPIRATION_MILLIS)
}
val commentCount = if (isCommentAvailable) {
commentRepository.totalCountCommentByPostId(
postId = postId,
memberId = creatorMemberId,
isContentCreator = true
)
} else {
0
}
return AiCharacterAdminCommunityPostDto(
postId = postId,
creatorId = creatorMemberId,
creatorNickname = member.nickname,
creatorProfileUrl = "$imageHost/${member.profileImage ?: DEFAULT_PROFILE_IMAGE_PATH}",
imageUrl = imagePath?.let { "$imageHost/$it" },
audioUrl = audioUrl,
content = content,
price = price,
date = createdAt.getTimeAgoString(),
dateUtc = createdAt.atZone(ZoneId.of("UTC")).toInstant().toString(),
isCommentAvailable = isCommentAvailable,
isAdult = isAdult,
isFixed = isFixed,
isLike = false,
existOrdered = true,
likeCount = likeRepository.totalCountCommunityPostLikeByPostId(postId),
commentCount = commentCount,
firstComment = null
)
}
private fun resolveActiveTarget(characterId: Long): AiCharacterAdminTarget {
val target = targetResolver.resolve(characterId)
if (!target.chatCharacter.isActive) throw invalidRequest()
return target
}
private fun resolveOwnedActivePost(characterId: Long, postId: Long): AiCharacterAdminTarget {
val target = resolveActiveTarget(characterId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
repository.findActiveByIdAndCreatorMemberId(postId, creatorMemberId) ?: throw invalidRequest()
return target
}
private fun validateListRequest(page: Int, size: Int) {
if (page < 0 || size < 1) throw invalidRequest()
}
private fun utcCommentDates(
response: AiCharacterAdminCommunityPostCommentListResponse,
postId: Long
): AiCharacterAdminCommunityPostCommentListResponse {
return response.copy(items = response.items.map { it.withUtcDate(postId) })
}
private fun GetCommunityPostCommentListItem.withUtcDate(postId: Long): GetCommunityPostCommentListItem {
val createdAt = repository.findCommentByIdAndPostId(id, postId)?.createdAt ?: throw invalidRequest()
return copy(date = createdAt.toUtcIso())
}
private fun <T> readRequest(requestString: String, requestClass: Class<T>): T {
return try {
objectMapper.readerFor(requestClass)
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.with(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
.with(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES)
.readValue(requestString)
} catch (_: JsonProcessingException) {
throw invalidRequest()
}
}
private fun rejectExplicitNull(requestString: String, fieldName: String) {
try {
if (objectMapper.readTree(requestString).path(fieldName).isNull) throw invalidRequest()
} catch (_: JsonProcessingException) {
throw invalidRequest()
}
}
private fun invalidRequest(): AiCharacterAdminApiException {
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
private companion object {
const val AUDIO_URL_EXPIRATION_MILLIS = 1_800_000L
const val DEFAULT_PROFILE_IMAGE_PATH = "profile/default_profile.png"
const val UTC_TIMEZONE = "UTC"
}
}

View File

@@ -0,0 +1,75 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreatorCommunityComment.creatorCommunityComment
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Repository
@Repository
class AiCharacterAdminCommunityPostRepository(
private val queryFactory: JPAQueryFactory
) {
fun findActiveByCreatorMemberId(creatorMemberId: Long, pageable: Pageable): List<CreatorCommunity> {
return queryFactory
.selectFrom(creatorCommunity)
.where(
creatorCommunity.member.id.eq(creatorMemberId)
.and(creatorCommunity.isActive.isTrue)
)
.offset(pageable.offset)
.limit(pageable.pageSize.toLong())
.orderBy(
creatorCommunity.isFixed.desc(),
creatorCommunity.fixedAt.desc().nullsLast(),
creatorCommunity.createdAt.desc()
)
.fetch()
}
fun countActiveByCreatorMemberId(creatorMemberId: Long): Long {
return queryFactory
.select(creatorCommunity.count())
.from(creatorCommunity)
.where(
creatorCommunity.member.id.eq(creatorMemberId)
.and(creatorCommunity.isActive.isTrue)
)
.fetchOne() ?: 0L
}
fun findActiveByIdAndCreatorMemberId(postId: Long, creatorMemberId: Long): CreatorCommunity? {
return queryFactory
.selectFrom(creatorCommunity)
.where(
creatorCommunity.id.eq(postId)
.and(creatorCommunity.member.id.eq(creatorMemberId))
.and(creatorCommunity.isActive.isTrue)
)
.fetchOne()
}
fun findCommentByIdAndPostId(commentId: Long, postId: Long): CreatorCommunityComment? {
return queryFactory
.selectFrom(creatorCommunityComment)
.where(
creatorCommunityComment.id.eq(commentId)
.and(creatorCommunityComment.creatorCommunity.id.eq(postId))
)
.fetchOne()
}
fun findActiveRootCommentByIdAndPostId(commentId: Long, postId: Long): CreatorCommunityComment? {
return queryFactory
.selectFrom(creatorCommunityComment)
.where(
creatorCommunityComment.id.eq(commentId)
.and(creatorCommunityComment.creatorCommunity.id.eq(postId))
.and(creatorCommunityComment.parent.isNull)
.and(creatorCommunityComment.isActive.isTrue)
)
.fetchOne()
}
}

View File

@@ -0,0 +1,164 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.util.MultiValueMap
import org.springframework.web.HttpMediaTypeNotSupportedException
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.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.multipart.MultipartHttpServletRequest
@RestController
@RequestMapping("/api/v2/admin/ai-characters")
class AiCharacterAdminAudioContentController(
private val facade: AiCharacterAdminAudioContentFacade
) {
@GetMapping("/audio-content-themes")
fun themes(): ApiResponse<List<AiCharacterAdminAudioContentThemeResponse>> {
return ApiResponse.ok(facade.themes())
}
@GetMapping("/{characterId:[0-9]+}/audio-contents")
fun list(
@PathVariable characterId: Long,
@RequestParam(value = "search_word", required = false) searchWord: String?,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
): ApiResponse<AiCharacterAdminAudioContentListResponse> {
return ApiResponse.ok(facade.list(characterId, searchWord, page, size))
}
@GetMapping("/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}")
fun detail(
@PathVariable characterId: Long,
@PathVariable contentId: Long,
@RequestParam queryParameters: MultiValueMap<String, String>
): ApiResponse<AiCharacterAdminAudioContentResponse> {
return ApiResponse.ok(facade.detail(characterId, contentId, queryParameters.keys))
}
@PostMapping("/{characterId:[0-9]+}/audio-contents", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun create(
@PathVariable characterId: Long,
@RequestPart("coverImage") coverImage: MultipartFile,
@RequestPart("contentFile") contentFile: MultipartFile,
@RequestPart("request") request: String,
multipartRequest: MultipartHttpServletRequest
): ApiResponse<AiCharacterAdminAudioContentCreateResponse> {
requireAllowedMultipartParts(multipartRequest, CREATE_MULTIPART_PARTS)
requireJsonRequestPart(multipartRequest)
return ApiResponse.ok(facade.create(characterId, coverImage, contentFile, request))
}
@PutMapping(
"/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}",
consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]
)
fun update(
@PathVariable characterId: Long,
@PathVariable contentId: Long,
@RequestPart(value = "coverImage", required = false) coverImage: MultipartFile?,
@RequestPart("request") request: String,
multipartRequest: MultipartHttpServletRequest
): ApiResponse<Nothing> {
requireAllowedMultipartParts(multipartRequest, UPDATE_MULTIPART_PARTS)
requireJsonRequestPart(multipartRequest)
facade.update(characterId, contentId, coverImage, request)
return ApiResponse.ok(null)
}
private fun requireAllowedMultipartParts(
multipartRequest: MultipartHttpServletRequest,
allowedParts: Set<String>
) {
if (multipartRequest.fileMap.keys.any { it !in allowedParts } ||
multipartRequest.parts.any { it.name !in allowedParts }
) {
throw AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
}
private fun requireJsonRequestPart(multipartRequest: MultipartHttpServletRequest) {
val contentType = multipartRequest.getMultipartHeaders("request")?.contentType
?: multipartRequest.getPart("request")?.contentType?.let(MediaType::parseMediaType)
if (contentType == null || !MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
throw HttpMediaTypeNotSupportedException(contentType, listOf(MediaType.APPLICATION_JSON))
}
}
companion object {
private val CREATE_MULTIPART_PARTS = setOf("coverImage", "contentFile", "request")
private val UPDATE_MULTIPART_PARTS = setOf("coverImage", "request")
}
@GetMapping("/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments")
fun comments(
@PathVariable characterId: Long,
@PathVariable contentId: Long,
@RequestParam queryParameters: MultiValueMap<String, String>,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
): ApiResponse<AiCharacterAdminAudioContentCommentListResponse> {
return ApiResponse.ok(facade.comments(characterId, contentId, queryParameters.keys, page, size))
}
@PostMapping(
"/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments",
consumes = [MediaType.APPLICATION_JSON_VALUE]
)
fun createComment(
@PathVariable characterId: Long,
@PathVariable contentId: Long,
@RequestBody request: String
): ApiResponse<Nothing> {
facade.createComment(characterId, contentId, request)
return ApiResponse.ok(null)
}
@PutMapping(
"/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments/{commentId:[0-9]+}",
consumes = [MediaType.APPLICATION_JSON_VALUE]
)
fun updateComment(
@PathVariable characterId: Long,
@PathVariable contentId: Long,
@PathVariable commentId: Long,
@RequestBody request: String
): ApiResponse<Nothing> {
facade.updateComment(characterId, contentId, commentId, request)
return ApiResponse.ok(null)
}
@DeleteMapping("/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments/{commentId:[0-9]+}")
fun deleteComment(
@PathVariable characterId: Long,
@PathVariable contentId: Long,
@PathVariable commentId: Long
): ApiResponse<Nothing> {
facade.deleteComment(characterId, contentId, commentId)
return ApiResponse.ok(null)
}
@GetMapping("/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments/{commentId:[0-9]+}/replies")
fun replies(
@PathVariable characterId: Long,
@PathVariable contentId: Long,
@PathVariable commentId: Long,
@RequestParam queryParameters: MultiValueMap<String, String>,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
): ApiResponse<AiCharacterAdminAudioContentCommentListResponse> {
return ApiResponse.ok(facade.replies(characterId, contentId, commentId, queryParameters.keys, page, size))
}
}

View File

@@ -0,0 +1,70 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
import kr.co.vividnext.sodalive.content.CreateAudioContentRequest
import kr.co.vividnext.sodalive.content.CreateAudioContentResponse
import kr.co.vividnext.sodalive.content.GetAudioContentDetailResponse
import kr.co.vividnext.sodalive.content.PurchaseOption
import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListResponse
import kr.co.vividnext.sodalive.content.theme.GetAudioContentThemeResponse
import kr.co.vividnext.sodalive.creator.admin.content.GetCreatorAdminContentListItem
import kr.co.vividnext.sodalive.creator.admin.content.GetCreatorAdminContentListResponse
typealias AiCharacterAdminAudioContentListResponse = GetCreatorAdminContentListResponse
typealias AiCharacterAdminAudioContentListItem = GetCreatorAdminContentListItem
typealias AiCharacterAdminAudioContentResponse = GetAudioContentDetailResponse
typealias AiCharacterAdminAudioContentThemeResponse = GetAudioContentThemeResponse
typealias AiCharacterAdminAudioContentCreateResponse = CreateAudioContentResponse
typealias AiCharacterAdminAudioContentCommentListResponse = GetAudioContentCommentListResponse
data class AiCharacterAdminAudioContentCreateRequest(
val title: String,
val detail: String,
val tags: String,
val price: Int,
val purchaseOption: PurchaseOption = PurchaseOption.BOTH,
val limited: Int? = null,
val releaseDate: String? = null,
val themeId: Long = 0,
val isAdult: Boolean = false,
val isGeneratePreview: Boolean = false,
val isOnlyRental: Boolean = false,
val isPointAvailable: Boolean = false,
val isCommentAvailable: Boolean = false,
val isFullDetailVisible: Boolean = true,
val previewStartTime: String? = null,
val previewEndTime: String? = null,
val languageCode: String? = null
) {
fun toLegacyRequest(): CreateAudioContentRequest {
return CreateAudioContentRequest(
title = title,
detail = detail,
tags = tags,
price = price,
purchaseOption = purchaseOption,
limited = limited,
timezone = "UTC",
themeId = themeId,
isAdult = isAdult,
isGeneratePreview = isGeneratePreview,
isOnlyRental = isOnlyRental,
isPointAvailable = isPointAvailable,
isCommentAvailable = isCommentAvailable,
isFullDetailVisible = isFullDetailVisible,
previewStartTime = previewStartTime,
previewEndTime = previewEndTime,
languageCode = languageCode
)
}
}
data class AiCharacterAdminAudioContentUpdateRequest(
val title: String? = null,
val detail: String? = null,
val tags: String? = null,
val price: Int? = null,
val isAdult: Boolean? = null,
val isActive: Boolean? = null,
val isPointAvailable: Boolean? = null,
val isCommentAvailable: Boolean? = null
)

View File

@@ -0,0 +1,315 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.content.AudioContentService
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentService
import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListItem
import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListResponse
import kr.co.vividnext.sodalive.content.comment.ModifyCommentRequest
import kr.co.vividnext.sodalive.content.comment.RegisterCommentRequest
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
import kr.co.vividnext.sodalive.creator.admin.content.CreatorAdminContentService
import kr.co.vividnext.sodalive.creator.admin.content.UpdateCreatorAdminContentRequest
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTarget
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.data.domain.PageRequest
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
import java.time.DateTimeException
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneOffset
@Service
class AiCharacterAdminAudioContentFacade(
private val objectMapper: ObjectMapper,
private val targetResolver: AiCharacterAdminTargetResolver,
private val repository: AiCharacterAdminAudioContentRepository,
private val themeQueryRepository: AudioContentThemeQueryRepository,
private val mapper: AiCharacterAdminAudioContentMapper,
private val audioContentService: AudioContentService,
private val creatorAdminContentService: CreatorAdminContentService,
private val audioContentCommentService: AudioContentCommentService
) {
@Transactional(readOnly = true)
fun themes(): List<AiCharacterAdminAudioContentThemeResponse> {
return themeQueryRepository.getActiveThemes()
}
@Transactional(readOnly = true)
fun list(
characterId: Long,
searchWord: String?,
page: Int,
size: Int
): AiCharacterAdminAudioContentListResponse {
val target = targetResolver.resolve(characterId)
val normalizedPage = page.coerceAtLeast(0)
val pageable = PageRequest.of(normalizedPage, size.coerceAtLeast(MINIMUM_PAGE_SIZE))
return if (searchWord == null) {
creatorAdminContentService.getAudioContentList(pageable, target.creatorMember)
} else {
creatorAdminContentService.searchAudioContent(searchWord, target.creatorMember, pageable)
}
}
@Transactional(readOnly = true)
fun detail(
characterId: Long,
contentId: Long,
queryParameterNames: Set<String>
): AiCharacterAdminAudioContentResponse {
if (queryParameterNames.isNotEmpty()) throw invalidRequest()
val target = targetResolver.resolve(characterId)
val content = repository.findByIdAndCreatorMemberId(
contentId = contentId,
creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
) ?: throw invalidRequest()
return mapper.toResponse(content)
}
@Transactional(readOnly = true)
fun comments(
characterId: Long,
contentId: Long,
queryParameterNames: Set<String>,
page: Int,
size: Int
): GetAudioContentCommentListResponse {
val target = resolveOwnedActiveContent(characterId, contentId)
validateCommentQuery(queryParameterNames, page, size)
return utcCommentDates(
audioContentCommentService.getCommentList(
audioContentId = contentId,
memberId = target.creatorMember.id ?: throw invalidRequest(),
timezone = UTC_TIMEZONE,
pageable = PageRequest.of(page, size)
),
contentId
)
}
@Transactional
fun createComment(characterId: Long, contentId: Long, requestString: String) {
val target = resolveOwnedActiveContent(characterId, contentId)
val request = readCommentCreateRequest(requestString, contentId)
request.parentId?.let { parentId ->
repository.findActiveRootCommentByIdAndContentId(parentId, contentId) ?: throw invalidRequest()
}
audioContentCommentService.registerComment(
member = target.creatorMember,
comment = request.comment,
audioContentId = contentId,
parentId = request.parentId,
isSecret = request.isSecret,
languageCode = request.languageCode
)
}
@Transactional
fun updateComment(characterId: Long, contentId: Long, commentId: Long, requestString: String) {
val target = resolveOwnedActiveContent(characterId, contentId)
val comment = repository.findCommentByIdAndContentId(commentId, contentId) ?: throw invalidRequest()
if (!comment.isActive || comment.member?.id != target.creatorMember.id) throw invalidRequest()
val request = readCommentUpdateRequest(requestString, commentId)
audioContentCommentService.modifyComment(request, target.creatorMember)
}
@Transactional
fun deleteComment(characterId: Long, contentId: Long, commentId: Long) {
val target = resolveOwnedActiveContent(characterId, contentId)
val comment = repository.findCommentByIdAndContentId(commentId, contentId) ?: throw invalidRequest()
if (!comment.isActive) return
audioContentCommentService.modifyComment(
ModifyCommentRequest(commentId = commentId, isActive = false),
target.creatorMember
)
}
@Transactional(readOnly = true)
fun replies(
characterId: Long,
contentId: Long,
commentId: Long,
queryParameterNames: Set<String>,
page: Int,
size: Int
): GetAudioContentCommentListResponse {
val target = resolveOwnedActiveContent(characterId, contentId)
validateCommentQuery(queryParameterNames, page, size)
repository.findCommentByIdAndContentId(commentId, contentId) ?: throw invalidRequest()
return utcCommentDates(
audioContentCommentService.getCommentReplyList(
commentId = commentId,
memberId = target.creatorMember.id ?: throw invalidRequest(),
timezone = UTC_TIMEZONE,
pageable = PageRequest.of(page, size)
),
contentId
)
}
@Transactional
fun create(
characterId: Long,
coverImage: MultipartFile,
contentFile: MultipartFile,
requestString: String
): AiCharacterAdminAudioContentCreateResponse {
val target = targetResolver.resolve(characterId)
if (coverImage.isEmpty || contentFile.isEmpty) throw invalidRequest()
val request = readRequest(requestString, AiCharacterAdminAudioContentCreateRequest::class.java)
val releaseDate = parseUtcReleaseDate(request.releaseDate) ?: LocalDateTime.now(ZoneOffset.UTC)
return audioContentService.createAudioContent(
contentFile = contentFile,
coverImage = coverImage,
request = request.toLegacyRequest(),
releaseDate = releaseDate,
member = target.creatorMember
)
}
@Transactional
fun update(
characterId: Long,
contentId: Long,
coverImage: MultipartFile?,
requestString: String
) {
val target = targetResolver.resolve(characterId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
repository.findByIdAndCreatorMemberId(contentId, creatorMemberId) ?: throw invalidRequest()
val normalizedCoverImage = coverImage?.takeUnless { it.isEmpty }
val request = readRequest(requestString, AiCharacterAdminAudioContentUpdateRequest::class.java)
creatorAdminContentService.updateAudioContent(
coverImage = normalizedCoverImage,
requestString = objectMapper.writeValueAsString(
UpdateCreatorAdminContentRequest(
id = contentId,
title = request.title,
detail = request.detail,
tags = request.tags,
price = request.price,
isAdult = request.isAdult,
isActive = request.isActive,
isPointAvailable = request.isPointAvailable,
isCommentAvailable = request.isCommentAvailable
)
),
member = target.creatorMember
)
}
private fun <T> readRequest(requestString: String, requestClass: Class<T>): T {
return try {
objectMapper.readerFor(requestClass)
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.with(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
.with(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES)
.readValue(requestString)
} catch (_: JsonProcessingException) {
throw invalidRequest()
}
}
private fun parseUtcReleaseDate(releaseDate: String?): LocalDateTime? {
if (releaseDate == null) return null
if (!releaseDate.endsWith("Z")) throw invalidRequest()
return try {
LocalDateTime.ofInstant(Instant.parse(releaseDate), ZoneOffset.UTC)
} catch (_: DateTimeException) {
throw invalidRequest()
}
}
private fun resolveOwnedActiveContent(characterId: Long, contentId: Long): AiCharacterAdminTarget {
val target = targetResolver.resolve(characterId)
if (!target.chatCharacter.isActive) throw invalidRequest()
repository.findActiveByIdAndCreatorMemberId(
contentId = contentId,
creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
) ?: throw invalidRequest()
return target
}
private fun validateCommentQuery(queryParameterNames: Set<String>, page: Int, size: Int) {
if (!COMMENT_QUERY_PARAMETERS.containsAll(queryParameterNames) || page < 0 || size < MINIMUM_PAGE_SIZE) {
throw invalidRequest()
}
}
private fun utcCommentDates(
response: GetAudioContentCommentListResponse,
contentId: Long
): GetAudioContentCommentListResponse {
return response.copy(items = response.items.map { it.withUtcDate(contentId) })
}
private fun GetAudioContentCommentListItem.withUtcDate(contentId: Long): GetAudioContentCommentListItem {
val createdAt = repository.findCommentByIdAndContentId(id, contentId)?.createdAt ?: throw invalidRequest()
return copy(date = createdAt.toUtcIso())
}
private fun readCommentCreateRequest(requestString: String, contentId: Long): RegisterCommentRequest {
val serializedRequest = injectCommentPathId(
requestString = requestString,
allowedFields = CREATE_COMMENT_FIELDS,
idField = "contentId",
id = contentId
)
return readRequest(serializedRequest, RegisterCommentRequest::class.java)
}
private fun readCommentUpdateRequest(requestString: String, commentId: Long): ModifyCommentRequest {
val serializedRequest = injectCommentPathId(
requestString = requestString,
allowedFields = UPDATE_COMMENT_FIELDS,
idField = "commentId",
id = commentId
)
val request = readRequest(serializedRequest, ModifyCommentRequest::class.java)
if (request.comment == null) throw invalidRequest()
return request
}
private fun injectCommentPathId(
requestString: String,
allowedFields: Set<String>,
idField: String,
id: Long
): String {
return try {
val request = objectMapper.readTree(requestString) as? com.fasterxml.jackson.databind.node.ObjectNode
?: throw invalidRequest()
if (request.fieldNames().asSequence().any { it !in allowedFields } || !request.hasNonNull("comment")) {
throw invalidRequest()
}
request.put(idField, id)
objectMapper.writeValueAsString(request)
} catch (_: JsonProcessingException) {
throw invalidRequest()
}
}
private fun invalidRequest(): AiCharacterAdminApiException {
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
companion object {
private const val MINIMUM_PAGE_SIZE = 1
private const val UTC_TIMEZONE = "UTC"
private val COMMENT_QUERY_PARAMETERS = setOf("page", "size")
private val CREATE_COMMENT_FIELDS = setOf("comment", "parentId", "isSecret", "languageCode")
private val UPDATE_COMMENT_FIELDS = setOf("comment")
}
}

View File

@@ -0,0 +1,90 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.AudioContentCreator
import kr.co.vividnext.sodalive.content.PurchaseOption
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import java.time.LocalDateTime
import java.time.ZoneOffset
@Component
class AiCharacterAdminAudioContentMapper(
private val audioContentCloudFront: AudioContentCloudFront,
@Value("\${cloud.aws.cloud-front.host}") private val imageHost: String
) {
fun toResponse(content: AudioContent): AiCharacterAdminAudioContentResponse {
val owner = content.member ?: throw invalidRequest()
return AiCharacterAdminAudioContentResponse(
contentId = content.id ?: throw invalidRequest(),
title = content.title,
detail = content.detail,
coverImageUrl = "$imageHost/${content.coverImage ?: "profile/default-profile.png"}",
contentUrl = signedUrl(content).orEmpty(),
languageCode = content.languageCode,
themeStr = content.theme?.theme ?: "",
tag = content.audioContentHashTags.filter { it.isActive }.mapNotNull { it.hashTag?.tag }.joinToString(" "),
price = content.price,
duration = content.duration ?: "",
isAdult = content.isAdult,
releaseDate = releaseDate(content),
totalContentCount = content.limited,
remainingContentCount = content.remaining,
orderSequence = null,
isActivePreview = content.isGeneratePreview,
isMosaic = false,
isOnlyRental = content.purchaseOption == PurchaseOption.RENT_ONLY || content.isOnlyRental,
existOrdered = false,
purchaseOption = if (content.isOnlyRental) PurchaseOption.RENT_ONLY else content.purchaseOption,
orderType = null,
remainingTime = null,
creatorOtherContentList = emptyList(),
sameThemeOtherContentList = emptyList(),
isCommentAvailable = content.isCommentAvailable,
isLike = false,
likeCount = 0,
commentList = emptyList(),
commentCount = 0,
isPin = false,
isAvailablePin = false,
creator = AudioContentCreator(
creatorId = owner.id ?: throw invalidRequest(),
nickname = owner.nickname,
profileImageUrl = if (owner.profileImage != null) {
"$imageHost/${owner.profileImage}"
} else {
"$imageHost/profile/default-profile.png"
},
isFollowing = false,
isFollow = false,
isNotify = false
),
previousContent = null,
nextContent = null,
buyerList = emptyList(),
isAvailableUsePoint = content.isPointAvailable,
translated = null
)
}
private fun signedUrl(content: AudioContent): String? {
val resourcePath = content.content ?: return null
val duration = content.duration ?: return null
val expirationTime = 1000 * 60 * 60 * (duration.split(":")[0].toLong() + 2)
return audioContentCloudFront.generateSignedURL(resourcePath, expirationTime)
}
private fun releaseDate(content: AudioContent): String? {
return content.releaseDate
?.takeIf { it > LocalDateTime.now(ZoneOffset.UTC) }
?.toUtcIso()
}
private fun invalidRequest(): AiCharacterAdminApiException {
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
}

View File

@@ -0,0 +1,56 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.content.comment.AudioContentComment
import kr.co.vividnext.sodalive.content.comment.QAudioContentComment.audioContentComment
import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.stereotype.Repository
@Repository
class AiCharacterAdminAudioContentRepository(
private val queryFactory: JPAQueryFactory
) {
fun findByIdAndCreatorMemberId(contentId: Long, creatorMemberId: Long): AudioContent? {
return queryFactory
.selectFrom(audioContent)
.innerJoin(audioContent.member, member)
.where(audioContent.id.eq(contentId).and(member.id.eq(creatorMemberId)))
.fetchOne()
}
fun findActiveByIdAndCreatorMemberId(contentId: Long, creatorMemberId: Long): AudioContent? {
return queryFactory
.selectFrom(audioContent)
.innerJoin(audioContent.member, member)
.where(
audioContent.id.eq(contentId)
.and(member.id.eq(creatorMemberId))
.and(audioContent.isActive.isTrue)
)
.fetchOne()
}
fun findCommentByIdAndContentId(commentId: Long, contentId: Long): AudioContentComment? {
return queryFactory
.selectFrom(audioContentComment)
.where(
audioContentComment.id.eq(commentId)
.and(audioContentComment.audioContent.id.eq(contentId))
)
.fetchOne()
}
fun findActiveRootCommentByIdAndContentId(commentId: Long, contentId: Long): AudioContentComment? {
return queryFactory
.selectFrom(audioContentComment)
.where(
audioContentComment.id.eq(commentId)
.and(audioContentComment.audioContent.id.eq(contentId))
.and(audioContentComment.parent.isNull)
.and(audioContentComment.isActive.isTrue)
)
.fetchOne()
}
}

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error
import org.springframework.http.HttpStatus
class AiCharacterAdminApiException(
val status: HttpStatus,
val messageKey: String
) : RuntimeException(messageKey)

View File

@@ -0,0 +1,35 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
class AiCharacterAdminErrorResponseWriter(
private val objectMapper: ObjectMapper,
private val messageSource: SodaMessageSource
) {
fun createResponse(request: HttpServletRequest, messageKey: String): ApiResponse<Any> {
val lang = Lang.fromAcceptLanguage(request.getHeader(HttpHeaders.ACCEPT_LANGUAGE))
val message = messageSource.getMessage(messageKey, lang)
?: messageSource.getMessage("common.error.unknown", lang)
return ApiResponse.error(message = message)
}
fun write(
request: HttpServletRequest,
response: HttpServletResponse,
status: HttpStatus,
messageKey: String
) {
response.status = status.value()
response.contentType = MediaType.APPLICATION_JSON_VALUE
response.characterEncoding = Charsets.UTF_8.name()
response.writer.write(objectMapper.writeValueAsString(createResponse(request, messageKey)))
}
}

View File

@@ -0,0 +1,104 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error
import kr.co.vividnext.sodalive.common.SodaException
import org.slf4j.LoggerFactory
import org.springframework.core.Ordered
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.security.access.AccessDeniedException
import org.springframework.security.web.util.matcher.RequestMatcher
import org.springframework.validation.BindException
import org.springframework.web.HttpMediaTypeNotAcceptableException
import org.springframework.web.HttpMediaTypeNotSupportedException
import org.springframework.web.HttpRequestMethodNotSupportedException
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.MissingPathVariableException
import org.springframework.web.bind.ServletRequestBindingException
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
import org.springframework.web.multipart.MultipartException
import org.springframework.web.multipart.support.MissingServletRequestPartException
import org.springframework.web.servlet.HandlerExceptionResolver
import org.springframework.web.servlet.ModelAndView
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
class AiCharacterAdminExceptionHandler(
private val responseWriter: AiCharacterAdminErrorResponseWriter,
private val requestMatcher: RequestMatcher
) : HandlerExceptionResolver, Ordered {
private val logger = LoggerFactory.getLogger(this::class.java)
override fun getOrder(): Int = Ordered.HIGHEST_PRECEDENCE
override fun resolveException(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any?,
exception: Exception
): ModelAndView? {
if (!requestMatcher.matches(request)) return null
val error = resolveError(exception)
if (error.status.is5xxServerError) {
logger.error("Unexpected AI character admin API error", exception)
} else {
logger.debug("AI character admin API request error", exception)
}
writeStandardHeaders(request, response, exception)
responseWriter.write(request, response, error.status, error.messageKey)
return ModelAndView()
}
private fun resolveError(exception: Exception): ResolvedError {
return when (exception) {
is AiCharacterAdminApiException -> ResolvedError(exception.status, exception.messageKey)
is SodaException -> ResolvedError(HttpStatus.BAD_REQUEST, exception.messageKey ?: "common.error.invalid_request")
is AccessDeniedException -> ResolvedError(HttpStatus.FORBIDDEN, "common.error.access_denied")
is HttpRequestMethodNotSupportedException -> invalidRequest(HttpStatus.METHOD_NOT_ALLOWED)
is HttpMediaTypeNotSupportedException -> invalidRequest(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
is HttpMediaTypeNotAcceptableException -> invalidRequest(HttpStatus.NOT_ACCEPTABLE)
is MissingPathVariableException -> ResolvedError(HttpStatus.INTERNAL_SERVER_ERROR, "common.error.unknown")
is BindException,
is HttpMessageNotReadableException,
is MethodArgumentNotValidException,
is MethodArgumentTypeMismatchException,
is ServletRequestBindingException,
is MultipartException,
is MissingServletRequestPartException -> invalidRequest(HttpStatus.BAD_REQUEST)
else -> ResolvedError(HttpStatus.INTERNAL_SERVER_ERROR, "common.error.unknown")
}
}
private fun writeStandardHeaders(
request: HttpServletRequest,
response: HttpServletResponse,
exception: Exception
) {
when (exception) {
is HttpRequestMethodNotSupportedException -> {
exception.supportedMethods?.let { response.setHeader(HttpHeaders.ALLOW, it.joinToString(", ")) }
}
is HttpMediaTypeNotSupportedException -> {
if (exception.supportedMediaTypes.isNotEmpty()) {
val supportedMediaTypes = MediaType.toString(exception.supportedMediaTypes)
response.setHeader(HttpHeaders.ACCEPT, supportedMediaTypes)
if (request.method == HttpMethod.PATCH.name) {
response.setHeader(HttpHeaders.ACCEPT_PATCH, supportedMediaTypes)
}
}
}
}
}
private fun invalidRequest(status: HttpStatus): ResolvedError {
return ResolvedError(status, "common.error.invalid_request")
}
private data class ResolvedError(
val status: HttpStatus,
val messageKey: String
)
}

View File

@@ -0,0 +1,58 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkTabResponse
import org.springframework.http.MediaType
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.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/admin/ai-characters/{characterId:[0-9]+}/fan-talks")
class AiCharacterAdminFanTalkController(
private val facade: AiCharacterAdminFanTalkFacade
) {
@GetMapping
fun list(
@PathVariable characterId: Long,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
): ApiResponse<CreatorChannelFanTalkTabResponse> {
return ApiResponse.ok(facade.list(characterId, page, size))
}
@PostMapping("/{fanTalkId:[0-9]+}/replies", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun createReply(
@PathVariable characterId: Long,
@PathVariable fanTalkId: Long,
@RequestBody request: String
): ApiResponse<AiCharacterAdminFanTalkReplyResponse> {
return ApiResponse.ok(facade.createReply(characterId, fanTalkId, request))
}
@PutMapping("/{fanTalkId:[0-9]+}/replies/{replyId:[0-9]+}", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun updateReply(
@PathVariable characterId: Long,
@PathVariable fanTalkId: Long,
@PathVariable replyId: Long,
@RequestBody request: String
): ApiResponse<CreatorChannelFanTalkResponse> {
return ApiResponse.ok(facade.updateReply(characterId, fanTalkId, replyId, request))
}
@DeleteMapping("/{fanTalkId:[0-9]+}")
fun delete(
@PathVariable characterId: Long,
@PathVariable fanTalkId: Long
): ApiResponse<Unit> {
facade.delete(characterId, fanTalkId)
return ApiResponse.ok(null)
}
}

View File

@@ -0,0 +1,39 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk
import java.time.LocalDateTime
data class AiCharacterAdminFanTalkRecord(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImagePath: String?,
val content: String,
val createdAt: LocalDateTime
)
data class AiCharacterAdminFanTalkReplyRecord(
val fanTalkId: Long,
val parentFanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImagePath: String?,
val content: String,
val createdAt: LocalDateTime
)
data class AiCharacterAdminFanTalkReplyRequest(
val content: String
)
data class AiCharacterAdminFanTalkReplyUpdateRequest(
val content: String? = null,
val isActive: Boolean? = null
)
data class AiCharacterAdminFanTalkReplyResponse(
val fanTalkId: Long,
val replyId: Long,
val creatorMemberId: Long,
val content: String,
val createdAtUtc: String
)

View File

@@ -0,0 +1,178 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository
import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTarget
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkReplyResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkTabResponse
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicy
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class AiCharacterAdminFanTalkFacade(
private val objectMapper: ObjectMapper,
private val targetResolver: AiCharacterAdminTargetResolver,
private val repository: AiCharacterAdminFanTalkRepository,
private val creatorCheersRepository: CreatorCheersRepository,
private val queryPolicy: CreatorChannelFanTalkQueryPolicy,
private val applicationEventPublisher: ApplicationEventPublisher,
@Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String
) {
@Transactional(readOnly = true)
fun list(characterId: Long, page: Int, size: Int): CreatorChannelFanTalkTabResponse {
val normalizedPage = queryPolicy.createPage(page, size)
val target = resolveActiveTarget(characterId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
val fetchedRoots = repository.findActiveRoots(
creatorMemberId = creatorMemberId,
offset = normalizedPage.page.toLong() * normalizedPage.size,
limit = normalizedPage.size + 1
)
val roots = queryPolicy.limitItems(fetchedRoots, normalizedPage)
val repliesByRootId = repository.findActiveCreatorReplies(
creatorMemberId = creatorMemberId,
parentFanTalkIds = roots.map { it.fanTalkId }
).groupBy(
keySelector = { it.parentFanTalkId },
valueTransform = { it.toResponse() }
)
return CreatorChannelFanTalkTabResponse(
fanTalkCount = repository.countActiveRoots(creatorMemberId),
fanTalks = roots.map { it.toResponse(repliesByRootId[it.fanTalkId].orEmpty()) },
page = normalizedPage.page,
size = normalizedPage.size,
hasNext = queryPolicy.hasNext(fetchedRoots, normalizedPage)
)
}
@Transactional
fun createReply(
characterId: Long,
fanTalkId: Long,
requestString: String
): AiCharacterAdminFanTalkReplyResponse {
val request = readRequest(requestString, AiCharacterAdminFanTalkReplyRequest::class.java)
if (request.content.isBlank()) throw invalidRequest()
val target = resolveActiveTarget(characterId)
val creator = target.creatorMember
val creatorMemberId = creator.id ?: throw invalidRequest()
val parent = repository.findActiveRoot(creatorMemberId, fanTalkId) ?: throw invalidRequest()
val savedReply = creatorCheersRepository.save(
CreatorCheers(cheers = request.content, languageCode = null).apply {
member = creator
this.creator = creator
this.parent = parent
}
)
val replyId = savedReply.id ?: throw invalidRequest()
val createdAtUtc = savedReply.createdAt?.toUtcIso() ?: throw invalidRequest()
applicationEventPublisher.publishEvent(
LanguageDetectEvent(
id = replyId,
query = request.content,
targetType = LanguageDetectTargetType.CREATOR_CHEERS
)
)
return AiCharacterAdminFanTalkReplyResponse(
fanTalkId = fanTalkId,
replyId = replyId,
creatorMemberId = creatorMemberId,
content = request.content,
createdAtUtc = createdAtUtc
)
}
@Transactional
fun delete(characterId: Long, fanTalkId: Long) {
val target = resolveActiveTarget(characterId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
val fanRoot = repository.findFanRoot(creatorMemberId, fanTalkId) ?: throw invalidRequest()
fanRoot.isActive = false
}
@Transactional
fun updateReply(
characterId: Long,
fanTalkId: Long,
replyId: Long,
requestString: String
): CreatorChannelFanTalkResponse {
val request = readRequest(requestString, AiCharacterAdminFanTalkReplyUpdateRequest::class.java)
val target = resolveActiveTarget(characterId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
val reply = repository.findUpdatableReply(creatorMemberId, fanTalkId, replyId) ?: throw invalidRequest()
request.content?.let { reply.cheers = it }
request.isActive?.let { reply.isActive = it }
return CreatorChannelFanTalkResponse.from(reply, cloudFrontHost)
}
private fun resolveActiveTarget(characterId: Long): AiCharacterAdminTarget {
val target = targetResolver.resolve(characterId)
if (!target.chatCharacter.isActive) throw invalidRequest()
return target
}
private fun AiCharacterAdminFanTalkRecord.toResponse(
creatorReplies: List<CreatorChannelFanTalkReplyResponse>
): CreatorChannelFanTalkResponse {
return CreatorChannelFanTalkResponse(
fanTalkId = fanTalkId,
writerId = writerId,
writerNickname = writerNickname.removeDeletedNicknamePrefix(),
writerProfileImageUrl = writerProfileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
content = content,
createdAtUtc = createdAt.toUtcIso(),
creatorReplies = creatorReplies
)
}
private fun AiCharacterAdminFanTalkReplyRecord.toResponse(): CreatorChannelFanTalkReplyResponse {
return CreatorChannelFanTalkReplyResponse(
fanTalkId = fanTalkId,
writerId = writerId,
writerNickname = writerNickname.removeDeletedNicknamePrefix(),
writerProfileImageUrl = writerProfileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
content = content,
createdAtUtc = createdAt.toUtcIso()
)
}
private fun defaultProfileImageUrl(): String = "$cloudFrontHost/profile/default-profile.png"
private fun <T> readRequest(requestString: String, requestClass: Class<T>): T {
return try {
objectMapper.readerFor(requestClass)
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.readValue(requestString)
} catch (_: JsonProcessingException) {
throw invalidRequest()
}
}
private fun invalidRequest(): AiCharacterAdminApiException {
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
}

View File

@@ -0,0 +1,127 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk
import com.querydsl.core.types.Projections
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers
import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers.creatorCheers
import org.springframework.stereotype.Repository
@Repository
class AiCharacterAdminFanTalkRepository(
private val queryFactory: JPAQueryFactory
) {
fun countActiveRoots(creatorMemberId: Long): Int {
return queryFactory
.select(creatorCheers.id.count())
.from(creatorCheers)
.where(
creatorCheers.creator.id.eq(creatorMemberId),
creatorCheers.isActive.isTrue,
creatorCheers.parent.isNull
)
.fetchOne()
?.toInt()
?: 0
}
fun findActiveRoots(
creatorMemberId: Long,
offset: Long,
limit: Int
): List<AiCharacterAdminFanTalkRecord> {
return queryFactory
.select(
Projections.constructor(
AiCharacterAdminFanTalkRecord::class.java,
creatorCheers.id,
creatorCheers.member.id,
creatorCheers.member.nickname,
creatorCheers.member.profileImage,
creatorCheers.cheers,
creatorCheers.createdAt
)
)
.from(creatorCheers)
.where(
creatorCheers.creator.id.eq(creatorMemberId),
creatorCheers.isActive.isTrue,
creatorCheers.parent.isNull
)
.orderBy(creatorCheers.createdAt.desc(), creatorCheers.id.desc())
.offset(offset)
.limit(limit.toLong())
.fetch()
}
fun findActiveCreatorReplies(
creatorMemberId: Long,
parentFanTalkIds: List<Long>
): List<AiCharacterAdminFanTalkReplyRecord> {
if (parentFanTalkIds.isEmpty()) return emptyList()
return queryFactory
.select(
Projections.constructor(
AiCharacterAdminFanTalkReplyRecord::class.java,
creatorCheers.id,
creatorCheers.parent.id,
creatorCheers.member.id,
creatorCheers.member.nickname,
creatorCheers.member.profileImage,
creatorCheers.cheers,
creatorCheers.createdAt
)
)
.from(creatorCheers)
.where(
creatorCheers.creator.id.eq(creatorMemberId),
creatorCheers.member.id.eq(creatorMemberId),
creatorCheers.isActive.isTrue,
creatorCheers.parent.id.`in`(parentFanTalkIds)
)
.orderBy(creatorCheers.createdAt.asc(), creatorCheers.id.asc())
.fetch()
}
fun findActiveRoot(creatorMemberId: Long, fanTalkId: Long): CreatorCheers? {
return queryFactory
.selectFrom(creatorCheers)
.where(
creatorCheers.id.eq(fanTalkId),
creatorCheers.creator.id.eq(creatorMemberId),
creatorCheers.isActive.isTrue,
creatorCheers.parent.isNull
)
.fetchOne()
}
fun findFanRoot(creatorMemberId: Long, fanTalkId: Long): CreatorCheers? {
return queryFactory
.selectFrom(creatorCheers)
.where(
creatorCheers.id.eq(fanTalkId),
creatorCheers.creator.id.eq(creatorMemberId),
creatorCheers.member.id.ne(creatorMemberId),
creatorCheers.parent.isNull
)
.fetchOne()
}
fun findUpdatableReply(creatorMemberId: Long, fanTalkId: Long, replyId: Long): CreatorCheers? {
val root = QCreatorCheers("root")
return queryFactory
.selectFrom(creatorCheers)
.join(creatorCheers.parent, root)
.where(
creatorCheers.id.eq(replyId),
creatorCheers.creator.id.eq(creatorMemberId),
creatorCheers.member.id.eq(creatorMemberId),
root.id.eq(fanTalkId),
root.creator.id.eq(creatorMemberId),
root.isActive.isTrue,
root.parent.isNull
)
.fetchOne()
}
}

View File

@@ -0,0 +1,83 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.security
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminErrorResponseWriter
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.security.access.AccessDeniedException
import org.springframework.security.core.AuthenticationException
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.security.web.access.AccessDeniedHandler
import org.springframework.security.web.firewall.DefaultRequestRejectedHandler
import org.springframework.security.web.firewall.RequestRejectedException
import org.springframework.security.web.firewall.RequestRejectedHandler
import org.springframework.security.web.util.matcher.RequestMatcher
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.DefaultCorsProcessor
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletRequestWrapper
import javax.servlet.http.HttpServletResponse
class AiCharacterAdminSecurityErrorHandler(
private val responseWriter: AiCharacterAdminErrorResponseWriter,
private val aiCharacterAdminRequestMatcher: RequestMatcher,
private val aiCharacterAdminCorsConfiguration: CorsConfiguration
) : AuthenticationEntryPoint, AccessDeniedHandler, RequestRejectedHandler {
private val defaultRequestRejectedHandler = DefaultRequestRejectedHandler()
private val corsProcessor = DefaultCorsProcessor()
override fun commence(
request: HttpServletRequest,
response: HttpServletResponse,
authException: AuthenticationException
) {
responseWriter.write(
request = request,
response = response,
status = HttpStatus.UNAUTHORIZED,
messageKey = "common.error.bad_credentials"
)
}
override fun handle(
request: HttpServletRequest,
response: HttpServletResponse,
accessDeniedException: AccessDeniedException
) {
responseWriter.write(
request = request,
response = response,
status = HttpStatus.FORBIDDEN,
messageKey = "common.error.access_denied"
)
}
override fun handle(
request: HttpServletRequest,
response: HttpServletResponse,
requestRejectedException: RequestRejectedException
) {
if (!aiCharacterAdminRequestMatcher.matches(request)) {
defaultRequestRejectedHandler.handle(request, response, requestRejectedException)
return
}
// Spring 5.3 CORS processor only recognizes HttpMethod enums, so use GET for nonstandard method checks.
val corsRequest = if (HttpMethod.resolve(request.method) == null) {
object : HttpServletRequestWrapper(request) {
override fun getMethod(): String = HttpMethod.GET.name
}
} else {
request
}
if (!corsProcessor.processRequest(aiCharacterAdminCorsConfiguration, corsRequest, response)) {
return
}
responseWriter.write(
request = request,
response = response,
status = HttpStatus.BAD_REQUEST,
messageKey = "common.error.invalid_request"
)
}
}

View File

@@ -0,0 +1,133 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.web.HttpMediaTypeNotSupportedException
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.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.multipart.MultipartHttpServletRequest
@RestController
@RequestMapping("/api/v2/admin/ai-characters/{characterId:[0-9]+}/series")
class AiCharacterAdminSeriesController(
private val facade: AiCharacterAdminSeriesFacade
) {
@GetMapping
fun list(
@PathVariable characterId: Long,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
): ApiResponse<AiCharacterAdminSeriesListResponse> {
return ApiResponse.ok(facade.list(characterId, page, size))
}
@GetMapping("/{seriesId:[0-9]+}")
fun detail(
@PathVariable characterId: Long,
@PathVariable seriesId: Long
): ApiResponse<AiCharacterAdminSeriesDetailResponse> {
return ApiResponse.ok(facade.detail(characterId, seriesId))
}
@GetMapping("/{seriesId:[0-9]+}/contents")
fun contents(
@PathVariable characterId: Long,
@PathVariable seriesId: Long,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
) = ApiResponse.ok(facade.contents(characterId, seriesId, page, size))
@GetMapping("/{seriesId:[0-9]+}/contents/search")
fun searchContents(
@PathVariable characterId: Long,
@PathVariable seriesId: Long,
@RequestParam("search_word") searchWord: String
) = ApiResponse.ok(facade.searchContents(characterId, seriesId, searchWord))
@PostMapping("/{seriesId:[0-9]+}/contents", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun addContents(
@PathVariable characterId: Long,
@PathVariable seriesId: Long,
@RequestBody request: String
): ApiResponse<Nothing> {
facade.addContents(characterId, seriesId, request)
return ApiResponse.ok(null)
}
@DeleteMapping("/{seriesId:[0-9]+}/contents/{contentId:[0-9]+}")
fun removeContent(
@PathVariable characterId: Long,
@PathVariable seriesId: Long,
@PathVariable contentId: Long
): ApiResponse<Nothing> {
facade.removeContent(characterId, seriesId, contentId)
return ApiResponse.ok(null)
}
@PutMapping("/orders", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun updateOrders(
@PathVariable characterId: Long,
@RequestBody request: String
): ApiResponse<Nothing> {
facade.updateOrders(characterId, request)
return ApiResponse.ok(null)
}
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun create(
@PathVariable characterId: Long,
@RequestPart("image") image: MultipartFile,
@RequestPart("request") request: String,
multipartRequest: MultipartHttpServletRequest
): ApiResponse<Nothing> {
requireAllowedMultipartParts(multipartRequest)
requireJsonRequestPart(multipartRequest)
facade.create(characterId, image, request)
return ApiResponse.ok(null)
}
@PutMapping("/{seriesId:[0-9]+}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun update(
@PathVariable characterId: Long,
@PathVariable seriesId: Long,
@RequestPart(value = "image", required = false) image: MultipartFile?,
@RequestPart("request") request: String,
multipartRequest: MultipartHttpServletRequest
): ApiResponse<Nothing> {
requireAllowedMultipartParts(multipartRequest)
requireJsonRequestPart(multipartRequest)
facade.update(characterId, seriesId, image, request)
return ApiResponse.ok(null)
}
private fun requireAllowedMultipartParts(multipartRequest: MultipartHttpServletRequest) {
if (multipartRequest.fileMap.keys.any { it !in SERIES_MULTIPART_PARTS } ||
multipartRequest.parts.any { it.name !in SERIES_MULTIPART_PARTS }
) {
throw AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
}
private fun requireJsonRequestPart(multipartRequest: MultipartHttpServletRequest) {
val contentType = multipartRequest.getMultipartHeaders("request")?.contentType
?: multipartRequest.getPart("request")?.contentType?.let(MediaType::parseMediaType)
if (contentType == null || !MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
throw HttpMediaTypeNotSupportedException(contentType, listOf(MediaType.APPLICATION_JSON))
}
}
companion object {
private val SERIES_MULTIPART_PARTS = setOf("image", "request")
}
}

View File

@@ -0,0 +1,28 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series
import kr.co.vividnext.sodalive.creator.admin.content.series.CreateSeriesRequest
import kr.co.vividnext.sodalive.creator.admin.content.series.GetCreatorAdminContentSeriesListItem
import kr.co.vividnext.sodalive.creator.admin.content.series.GetCreatorAdminContentSeriesListResponse
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
typealias AiCharacterAdminSeriesListResponse = GetCreatorAdminContentSeriesListResponse
typealias AiCharacterAdminSeriesListItem = GetCreatorAdminContentSeriesListItem
typealias AiCharacterAdminSeriesDetailResponse = GetCreatorAdminContentSeriesListItem
typealias AiCharacterAdminSeriesCreateRequest = CreateSeriesRequest
data class AiCharacterAdminSeriesUpdateRequest(
val title: String? = null,
val introduction: String? = null,
val publishedDaysOfWeek: Set<SeriesPublishedDaysOfWeek>? = null,
val genreId: Long? = null,
val isAdult: Boolean? = null,
val state: SeriesState? = null,
val isActive: Boolean? = null,
val writer: String? = null,
val studio: String? = null
)
data class AiCharacterAdminSeriesContentAddRequest(
val contentIdList: List<Long>?
)

View File

@@ -0,0 +1,220 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.content.series.genre.AdminContentSeriesGenreService
import kr.co.vividnext.sodalive.admin.content.series.genre.GetSeriesGenreListResponse
import kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesService
import kr.co.vividnext.sodalive.creator.admin.content.series.GetCreatorAdminContentSeriesContentResponse
import kr.co.vividnext.sodalive.creator.admin.content.series.GetCreatorAdminContentSeriesListItem
import kr.co.vividnext.sodalive.creator.admin.content.series.ModifySeriesRequest
import kr.co.vividnext.sodalive.creator.admin.content.series.UpdateOrdersRequest
import kr.co.vividnext.sodalive.creator.admin.content.series.content.AddingContentToTheSeriesRequest
import kr.co.vividnext.sodalive.creator.admin.content.series.content.RemoveContentToTheSeriesRequest
import kr.co.vividnext.sodalive.creator.admin.content.series.content.SearchContentNotInSeriesResponse
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTarget
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.PageRequest
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
@Service
class AiCharacterAdminSeriesFacade(
private val objectMapper: ObjectMapper,
private val targetResolver: AiCharacterAdminTargetResolver,
private val repository: AiCharacterAdminSeriesRepository,
private val legacyService: CreatorAdminContentSeriesService,
private val genreService: AdminContentSeriesGenreService,
@Value("\${cloud.aws.cloud-front.host}") private val coverImageHost: String
) {
@Transactional(readOnly = true)
fun genres(): List<GetSeriesGenreListResponse> {
return genreService.getSeriesGenreList()
}
@Transactional(readOnly = true)
fun list(characterId: Long, page: Int, size: Int): AiCharacterAdminSeriesListResponse {
val target = resolveActiveTarget(characterId)
if (page < 0 || size < 1) throw invalidRequest()
val pageable = PageRequest.of(page, size)
return legacyService.getSeriesList(
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
creatorId = target.creatorMember.id ?: throw invalidRequest()
)
}
@Transactional(readOnly = true)
fun detail(characterId: Long, seriesId: Long): AiCharacterAdminSeriesDetailResponse {
val target = resolveActiveTarget(characterId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
val series = repository.findActiveByIdAndCreatorMemberId(seriesId, creatorMemberId)
?: throw invalidRequest()
return GetCreatorAdminContentSeriesListItem(
seriesId = series.id!!,
title = series.title,
introduction = series.introduction,
coverImageUrl = "$coverImageHost/${series.coverImage!!}",
publishedDaysOfWeek = series.publishedDaysOfWeek.toList(),
genreId = series.genre!!.id!!,
isAdult = series.isAdult,
state = series.state,
isActive = series.isActive,
writer = series.writer,
studio = series.studio
)
}
@Transactional(readOnly = true)
fun contents(
characterId: Long,
seriesId: Long,
page: Int,
size: Int
): GetCreatorAdminContentSeriesContentResponse {
val target = resolveOwnedActiveSeries(characterId, seriesId)
if (page < 0 || size < 1) throw invalidRequest()
val pageable = PageRequest.of(page, size)
return legacyService.getSeriesContent(
seriesId = seriesId,
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
creatorId = target.creatorMember.id ?: throw invalidRequest()
)
}
@Transactional(readOnly = true)
fun searchContents(characterId: Long, seriesId: Long, searchWord: String): List<SearchContentNotInSeriesResponse> {
val target = resolveOwnedActiveSeries(characterId, seriesId)
return legacyService.searchContentNotInSeries(
seriesId = seriesId,
searchWord = searchWord,
memberId = target.creatorMember.id ?: throw invalidRequest()
)
}
@Transactional
fun addContents(characterId: Long, seriesId: Long, requestString: String) {
val request = readRequest(requestString, AiCharacterAdminSeriesContentAddRequest::class.java)
val target = resolveOwnedActiveSeries(characterId, seriesId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
val contentIds = request.contentIdList ?: throw invalidRequest()
if (contentIds.isEmpty()) {
legacyService.addingContentToTheSeries(
AddingContentToTheSeriesRequest(seriesId, contentIds),
creatorMemberId
)
return
}
if (contentIds.size != contentIds.toSet().size) throw invalidRequest()
val series = repository.findActiveByIdAndCreatorMemberId(seriesId, creatorMemberId) ?: throw invalidRequest()
val linkedContentIds = series.contentList.mapNotNull { it.content?.id }.toSet()
contentIds.forEach { contentId ->
if (contentId in linkedContentIds ||
repository.findEligibleContentByIdAndCreatorMemberId(contentId, creatorMemberId) == null
) {
throw invalidRequest()
}
}
legacyService.addingContentToTheSeries(AddingContentToTheSeriesRequest(seriesId, contentIds), creatorMemberId)
}
@Transactional
fun removeContent(characterId: Long, seriesId: Long, contentId: Long?) {
val target = resolveOwnedActiveSeries(characterId, seriesId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
val resolvedContentId = contentId ?: throw invalidRequest()
val series = repository.findActiveByIdAndCreatorMemberId(seriesId, creatorMemberId) ?: throw invalidRequest()
if (series.contentList.none { it.content?.id == resolvedContentId && it.content?.member?.id == creatorMemberId }) {
throw invalidRequest()
}
legacyService.removeContentInTheSeries(RemoveContentToTheSeriesRequest(seriesId, resolvedContentId), creatorMemberId)
}
@Transactional
fun updateOrders(characterId: Long, requestString: String) {
val request = readRequest(requestString, UpdateOrdersRequest::class.java)
val target = resolveActiveTarget(characterId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
val ids = request.ids
if (ids.isEmpty() || ids.size != ids.toSet().size) throw invalidRequest()
val lockedIds = repository.findActiveByCreatorMemberIdAndIdsForUpdate(creatorMemberId, ids).map { it.id }.toSet()
if (lockedIds != ids.toSet()) throw invalidRequest()
legacyService.updateSeriesOrders(ids)
}
@Transactional
fun create(characterId: Long, image: MultipartFile, requestString: String) {
val target = resolveActiveTarget(characterId)
if (image.isEmpty) throw invalidRequest()
val request = readRequest(requestString, AiCharacterAdminSeriesCreateRequest::class.java)
rejectMissingActiveGenre(request.genreId)
legacyService.createSeries(image, requestString, target.creatorMember)
}
@Transactional
fun update(characterId: Long, seriesId: Long, image: MultipartFile?, requestString: String) {
val target = resolveOwnedActiveSeries(characterId, seriesId)
val normalizedImage = image?.takeUnless { it.isEmpty }
val request = readRequest(requestString, AiCharacterAdminSeriesUpdateRequest::class.java)
request.genreId?.let(::rejectMissingActiveGenre)
legacyService.modifySeries(
coverImage = normalizedImage,
requestString = objectMapper.writeValueAsString(
ModifySeriesRequest(
seriesId = seriesId,
title = request.title,
introduction = request.introduction,
publishedDaysOfWeek = request.publishedDaysOfWeek,
genreId = request.genreId,
isAdult = request.isAdult,
state = request.state,
isActive = request.isActive,
writer = request.writer,
studio = request.studio
)
),
member = target.creatorMember
)
}
private fun resolveOwnedActiveSeries(characterId: Long, seriesId: Long): AiCharacterAdminTarget {
val target = resolveActiveTarget(characterId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
repository.findActiveByIdAndCreatorMemberId(seriesId, creatorMemberId) ?: throw invalidRequest()
return target
}
private fun <T> readRequest(requestString: String, requestClass: Class<T>): T {
return try {
objectMapper.readerFor(requestClass)
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.with(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
.readValue(requestString)
} catch (_: JsonProcessingException) {
throw invalidRequest()
}
}
private fun resolveActiveTarget(characterId: Long): AiCharacterAdminTarget {
val target = targetResolver.resolve(characterId)
if (!target.chatCharacter.isActive) throw invalidRequest()
return target
}
private fun rejectMissingActiveGenre(genreId: Long) {
if (genreId <= 0 || !repository.existsActiveGenre(genreId)) throw invalidRequest()
}
private fun invalidRequest(): AiCharacterAdminApiException {
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
}

View File

@@ -0,0 +1,18 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series
import kr.co.vividnext.sodalive.admin.content.series.genre.GetSeriesGenreListResponse
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/admin/ai-characters")
class AiCharacterAdminSeriesReferenceController(
private val facade: AiCharacterAdminSeriesFacade
) {
@GetMapping("/series-genres")
fun genres(): ApiResponse<List<GetSeriesGenreListResponse>> {
return ApiResponse.ok(facade.genres())
}
}

View File

@@ -0,0 +1,32 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.AudioContentRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.genre.CreatorAdminContentSeriesGenreRepository
import org.springframework.stereotype.Repository
@Repository
class AiCharacterAdminSeriesRepository(
private val repository: CreatorAdminContentSeriesRepository,
private val genreRepository: CreatorAdminContentSeriesGenreRepository,
private val audioContentRepository: AudioContentRepository
) {
fun findActiveByIdAndCreatorMemberId(seriesId: Long, creatorMemberId: Long): Series? {
return repository.findByIdAndCreatorId(seriesId, creatorMemberId)?.takeIf { it.isActive }
}
fun findActiveByCreatorMemberIdAndIdsForUpdate(creatorMemberId: Long, ids: List<Long>): List<Series> {
return repository.findActiveByCreatorIdAndIdInForUpdate(creatorMemberId, ids.sorted())
}
fun existsActiveGenre(genreId: Long): Boolean {
return genreRepository.findById(genreId).orElse(null)?.isActive == true
}
fun findEligibleContentByIdAndCreatorMemberId(contentId: Long, creatorMemberId: Long): AudioContent? {
return audioContentRepository.findByIdAndCreatorId(contentId, creatorMemberId)
?.takeIf { it.duration != null && (it.isActive || it.releaseDate != null) }
}
}

View File

@@ -1,34 +0,0 @@
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)
}
}
}

View File

@@ -1,54 +1,14 @@
package kr.co.vividnext.sodalive.admin.chat.character 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.character.service.AdminChatCharacterService
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
import kr.co.vividnext.sodalive.aws.s3.S3Uploader 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.ChatCharacterCreatorMemberService
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito import org.mockito.Mockito
import org.springframework.context.ApplicationEventPublisher 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 { class AdminChatCharacterControllerTest {
private val controller = AdminChatCharacterController( private val controller = AdminChatCharacterController(
@@ -75,523 +35,6 @@ class AdminChatCharacterControllerTest {
return method.invoke(controller, region, gender) as String 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<String>(), request.tags)
assertEquals(emptyList<String>(), 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<RecordedHttpRequest>()
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<RecordedHttpRequest>()
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 @Test
fun shouldMapFemaleToJapaneseWhenRegionIsJp() { fun shouldMapFemaleToJapaneseWhenRegionIsJp() {
val mappedGender = mapGender(region = "JP", gender = "여성") val mappedGender = mapGender(region = "JP", gender = "여성")
@@ -619,143 +62,4 @@ class AdminChatCharacterControllerTest {
assertEquals("여성", mappedGender) 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>): ChatCharacterUpdateRequest {
return captor.capture() ?: ChatCharacterUpdateRequest(id = 0L)
}
private fun externalCharacterApiServer(requests: MutableList<RecordedHttpRequest>): 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}"""
}
} }

View File

@@ -1,469 +0,0 @@
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>
): OriginalWorkUpdateRequest {
return captor.capture() ?: OriginalWorkUpdateRequest(id = 0L)
}
companion object {
private const val LEGACY_EMPTY_SUCCESS_RESPONSE =
"""{"success":true,"message":null,"data":null,"errorProperty":null}"""
}
}

View File

@@ -6,7 +6,6 @@ import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.login.LoginRequest 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 kr.co.vividnext.sodalive.member.token.MemberTokenRepository
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertThrows
@@ -19,22 +18,19 @@ import org.springframework.security.crypto.password.PasswordEncoder
class AdminMemberLoginServiceTest { class AdminMemberLoginServiceTest {
private lateinit var repository: AdminMemberRepository private lateinit var repository: AdminMemberRepository
private lateinit var memberRepository: MemberRepository
private lateinit var passwordEncoder: PasswordEncoder private lateinit var passwordEncoder: PasswordEncoder
private lateinit var tokenRepository: MemberTokenRepository private lateinit var tokenRepository: MemberTokenRepository
private lateinit var tokenProvider: TokenProvider
private lateinit var service: AdminMemberLoginService private lateinit var service: AdminMemberLoginService
@BeforeEach @BeforeEach
fun setup() { fun setup() {
repository = mock() repository = mock()
memberRepository = mock()
passwordEncoder = mock() passwordEncoder = mock()
tokenRepository = mock() tokenRepository = mock()
tokenProvider = TokenProvider( val tokenProvider = TokenProvider(
secret = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", secret = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
tokenValidityInSeconds = 3600, tokenValidityInSeconds = 3600,
repository = memberRepository, repository = mock<MemberRepository>(),
tokenRepository = tokenRepository tokenRepository = tokenRepository
) )
tokenProvider.afterPropertiesSet() tokenProvider.afterPropertiesSet()
@@ -58,27 +54,6 @@ class AdminMemberLoginServiceTest {
assertEquals(MemberRole.ADMIN, response.role) 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 @Test
@DisplayName("콘텐츠 관리자는 관리자 로그인 API로 token과 role을 받는다") @DisplayName("콘텐츠 관리자는 관리자 로그인 API로 token과 role을 받는다")
fun shouldLoginContentManager() { fun shouldLoginContentManager() {

View File

@@ -1,276 +0,0 @@
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
}
}

View File

@@ -1,162 +0,0 @@
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<Any, Any>()))
.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}"""
}
}

View File

@@ -0,0 +1,138 @@
package kr.co.vividnext.sodalive.jwt
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import io.jsonwebtoken.io.Decoders
import io.jsonwebtoken.security.Keys
import kr.co.vividnext.sodalive.common.SodaException
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 org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import org.mockito.Mockito
import java.util.Base64
import java.util.Date
import java.util.Optional
class TokenProviderTest {
private lateinit var tokenProvider: TokenProvider
private lateinit var memberRepository: MemberRepository
private lateinit var tokenRepository: MemberTokenRepository
@BeforeEach
fun setUp() {
memberRepository = Mockito.mock(MemberRepository::class.java)
tokenRepository = Mockito.mock(MemberTokenRepository::class.java)
tokenProvider = TokenProvider(
secret = SECRET,
tokenValidityInSeconds = 3600,
repository = memberRepository,
tokenRepository = tokenRepository
)
tokenProvider.afterPropertiesSet()
}
@Test
@DisplayName("subject가 없는 서명 JWT는 bad_credentials로 변환된다")
fun shouldRejectSignedJwtWithoutSubjectAsBadCredentials() {
val token = signedToken(subject = null)
val exception = assertThrows(SodaException::class.java) {
tokenProvider.getAuthentication(token)
}
assertEquals("common.error.bad_credentials", exception.messageKey)
}
@Test
@DisplayName("숫자가 아닌 subject의 서명 JWT는 bad_credentials로 변환된다")
fun shouldRejectSignedJwtWithNonNumericSubjectAsBadCredentials() {
val token = signedToken(subject = "not-a-number")
val exception = assertThrows(SodaException::class.java) {
tokenProvider.getAuthentication(token)
}
assertEquals("common.error.bad_credentials", exception.messageKey)
}
@Test
@DisplayName("auth claim이 없는 서명 JWT는 bad_credentials로 변환된다")
fun shouldRejectSignedJwtWithoutAuthoritiesClaimAsBadCredentials() {
val token = signedToken(subject = MEMBER_ID.toString(), authorities = null)
allowStoredToken(token)
val exception = assertThrows(SodaException::class.java) {
tokenProvider.getAuthentication(token)
}
assertEquals("common.error.bad_credentials", exception.messageKey)
}
@ParameterizedTest
@ValueSource(strings = ["", " ", "ROLE_ADMIN,", ",ROLE_ADMIN", "ROLE_ADMIN,,ROLE_USER"])
@DisplayName("비어 있는 authority를 포함한 서명 JWT는 bad_credentials로 변환된다")
fun shouldRejectSignedJwtWithBlankAuthorityAsBadCredentials(authorities: String) {
val token = signedToken(subject = MEMBER_ID.toString(), authorities = authorities)
allowStoredToken(token)
val exception = assertThrows(SodaException::class.java) {
tokenProvider.getAuthentication(token)
}
assertEquals("common.error.bad_credentials", exception.messageKey)
}
@Test
@DisplayName("문자열이 아닌 auth claim의 서명 JWT는 bad_credentials로 변환된다")
fun shouldRejectSignedJwtWithNonStringAuthoritiesClaimAsBadCredentials() {
val token = signedToken(subject = MEMBER_ID.toString(), authorities = 1)
allowStoredToken(token)
val exception = assertThrows(SodaException::class.java) {
tokenProvider.getAuthentication(token)
}
assertEquals("common.error.bad_credentials", exception.messageKey)
}
private fun allowStoredToken(token: String) {
val member = Member(
email = "admin@example.com",
password = "password",
nickname = "admin",
role = MemberRole.ADMIN
).apply { id = MEMBER_ID }
Mockito.`when`(tokenRepository.findById(MEMBER_ID))
.thenReturn(Optional.of(MemberToken(MEMBER_ID, mutableSetOf(token))))
Mockito.`when`(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member))
}
private fun signedToken(subject: String?, authorities: Any? = "ROLE_ADMIN"): String {
val builder = Jwts.builder()
.signWith(Keys.hmacShaKeyFor(Decoders.BASE64.decode(SECRET)), SignatureAlgorithm.HS512)
.setExpiration(Date(System.currentTimeMillis() + 60_000))
if (subject != null) {
builder.setSubject(subject)
}
if (authorities != null) {
builder.claim("auth", authorities)
}
return builder.compact()
}
companion object {
private const val MEMBER_ID = 1L
private val SECRET = Base64.getEncoder().encodeToString("a".repeat(64).toByteArray())
}
}

View File

@@ -1,210 +0,0 @@
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<Long>(), 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<Long>(), 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<Long> {
val pageable = characterService.createDefaultPageRequest(page = 0, size = 20)
return characterService.searchCharacters(searchTerm, pageable).content.map { it.id }
}
private fun searchOriginalWorkIds(searchTerm: String): List<Long> {
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
}
}

View File

@@ -1,119 +0,0 @@
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<String> {
throw SodaException(messageKey = "common.error.invalid_request")
}
@GetMapping("/conflict")
fun legacyWithHttpStatus(): ApiResponse<String> {
throw SodaException(messageKey = "common.error.invalid_request", httpStatus = HttpStatus.CONFLICT)
}
}
}

View File

@@ -29,6 +29,11 @@ class H2MysqlDateFunctions {
.replace("%Y", "yyyy") .replace("%Y", "yyyy")
.replace("%m", "MM") .replace("%m", "MM")
.replace("%d", "dd") .replace("%d", "dd")
.replace("%H", "HH")
.replace("%W", "EEEE")
.replace("%h", "hh")
.replace("%i", "mm")
.replace("%p", "a")
return value.toLocalDateTime().format(DateTimeFormatter.ofPattern(javaPattern)) return value.toLocalDateTime().format(DateTimeFormatter.ofPattern(javaPattern))
} }

View File

@@ -1,93 +0,0 @@
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)
}
}

View File

@@ -1,126 +0,0 @@
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) }
)
}
}

View File

@@ -1,165 +0,0 @@
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<String> = ApiResponse.ok("ok")
}
}

View File

@@ -1,215 +0,0 @@
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<String> = ApiResponse.ok("ok")
@PostMapping("/body")
fun body(@RequestBody request: AdminCommentUpdateRequest): ApiResponse<String> = ApiResponse.ok(request.content)
@GetMapping("/conflict")
fun conflict(): ApiResponse<String> {
throw SodaException(
messageKey = "common.error.invalid_request",
errorProperty = "characterId",
httpStatus = HttpStatus.CONFLICT
)
}
@GetMapping("/bad-request")
fun badRequest(): ApiResponse<String> = throwStatus(HttpStatus.BAD_REQUEST)
@GetMapping("/not-found")
fun notFound(): ApiResponse<String> = throwStatus(HttpStatus.NOT_FOUND)
@GetMapping("/server-error")
fun serverError(): ApiResponse<String> = throwStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@GetMapping("/bad-gateway")
fun badGateway(): ApiResponse<String> = throwStatus(HttpStatus.BAD_GATEWAY)
private fun throwStatus(status: HttpStatus): ApiResponse<String> {
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<String> = ApiResponse.ok("shadow")
}
}

View File

@@ -1,85 +0,0 @@
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<Int>(),
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)
}
}

View File

@@ -1,199 +0,0 @@
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))
}
}

View File

@@ -1,121 +0,0 @@
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<ChatCharacter>
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
)
}
}

View File

@@ -1,124 +0,0 @@
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
}
}
}

View File

@@ -0,0 +1,79 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter
import kr.co.vividnext.sodalive.common.ApiResponse
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.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
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.context.TestComponent
import org.springframework.context.annotation.Import
import org.springframework.security.access.AccessDeniedException
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
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.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.RestController
@SpringBootTest
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@Import(
AiCharacterAdminAccessDeniedErrorContractTest.AiCharacterAdminAccessDeniedSampleController::class
)
class AiCharacterAdminAccessDeniedErrorContractTest @Autowired constructor(
private val mockMvc: MockMvc
) {
@ParameterizedTest
@CsvSource(
"ko,권한이 없습니다.",
"en,You do not have permission.",
"ja,権限がありません。"
)
@DisplayName("신규 prefix controller AccessDeniedException은 403 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForControllerAccessDenied(language: String, message: String) {
mockMvc.perform(
get("/api/v2/admin/ai-characters/access-denied-contract")
.header("Accept-Language", language)
.with(authentication(createAuthentication()))
)
.andExpect(status().isForbidden)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
.andExpect(jsonPath("$.data").doesNotExist())
.andExpect(jsonPath("$.errorProperty").doesNotExist())
}
private fun createAuthentication() =
UsernamePasswordAuthenticationToken(
MemberAdapter(
Member(
email = "admin@example.com",
password = "password",
nickname = "admin",
role = MemberRole.ADMIN
)
),
"token",
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
)
@TestComponent
@RestController
class AiCharacterAdminAccessDeniedSampleController {
@GetMapping("/api/v2/admin/ai-characters/access-denied-contract")
fun accessDenied(): ApiResponse<String> {
throw AccessDeniedException("admin only")
}
}
}

View File

@@ -0,0 +1,260 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter
import kr.co.vividnext.sodalive.common.ApiResponse
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.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
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.context.TestComponent
import org.springframework.context.annotation.Import
import org.springframework.http.HttpMethod
import org.springframework.http.MediaType
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder
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.request.MockMvcRequestBuilders.put
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
@SpringBootTest
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@Import(
AiCharacterAdminAuthorizationTest.AiCharacterAdminAuthorizationSampleController::class,
AiCharacterAdminAuthorizationTest.AiCharacterAdminLegacySampleController::class
)
class AiCharacterAdminAuthorizationTest @Autowired constructor(
private val mockMvc: MockMvc
) {
@Test
@DisplayName("AI 캐릭터 관리자 API는 비회원 요청을 거부한다")
fun shouldRejectAnonymousUser() {
mockMvc.perform(
get("/api/v2/admin/ai-characters/sample")
.with(anonymous())
)
.andExpect(status().isUnauthorized)
}
@Test
@DisplayName("AI 캐릭터 관리자 API는 JWT role이 ADMIN이 아니면 현재 DB role이 ADMIN이어도 거부한다")
fun shouldRejectNonAdminJwtRole() {
mockMvc.perform(
get("/api/v2/admin/ai-characters/sample")
.with(authentication(createAuthentication(MemberRole.USER, MemberRole.ADMIN)))
)
.andExpect(status().isForbidden)
}
@Test
@DisplayName("AI 캐릭터 관리자 API는 JWT가 ADMIN이어도 현재 DB role이 강등되면 거부한다")
fun shouldRejectStaleAdminClaimAfterCurrentRoleDowngrade() {
mockMvc.perform(
get("/api/v2/admin/ai-characters/sample")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.USER)))
)
.andExpect(status().isForbidden)
}
@Test
@DisplayName("AI 캐릭터 관리자 API는 ADMIN authority가 있어도 MemberAdapter principal이 아니면 거부한다")
fun shouldRejectAdminAuthorityWithoutMemberAdapterPrincipal() {
mockMvc.perform(
get("/api/v2/admin/ai-characters/sample")
.with(user("admin").roles("ADMIN"))
)
.andExpect(status().isForbidden)
}
@Test
@DisplayName("AI 캐릭터 관리자 API는 JWT와 현재 DB role이 모두 ADMIN이면 허용한다")
fun shouldAllowCurrentAdminWithAdminJwtRole() {
mockMvc.perform(
get("/api/v2/admin/ai-characters/sample")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data").value("ok"))
}
@Test
@DisplayName("현재 DB role 추가 검증은 신규 AI 캐릭터 관리자 prefix 밖에 적용하지 않는다")
fun shouldKeepLegacyAuthenticatedRuleOutsideAiCharacterAdminPrefix() {
mockMvc.perform(
get("/phase1-legacy-sample")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.USER)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
}
@Test
@DisplayName("시리즈 endpoint는 JWT role과 현재 DB role이 모두 ADMIN이어야 접근할 수 있다")
fun shouldRequireAdminJwtAndCurrentAdminRoleForSeriesEndpoints() {
seriesEndpointRequests().forEach { endpoint ->
mockMvc.perform(
endpoint.request()
.with(authentication(createAuthentication(MemberRole.USER, MemberRole.ADMIN)))
).andExpect(status().isForbidden)
mockMvc.perform(
endpoint.request()
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.USER)))
).andExpect(status().isForbidden)
}
}
@Test
@DisplayName("커뮤니티 endpoint는 JWT role과 현재 DB role이 모두 ADMIN이어야 접근할 수 있다")
fun shouldRequireAdminJwtAndCurrentAdminRoleForCommunityEndpoints() {
communityEndpointRequests().forEach { endpoint ->
mockMvc.perform(
endpoint.request()
.with(authentication(createAuthentication(MemberRole.USER, MemberRole.ADMIN)))
).andExpect(status().isForbidden)
mockMvc.perform(
endpoint.request()
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.USER)))
).andExpect(status().isForbidden)
}
}
@Test
@DisplayName("FanTalk endpoint는 JWT role과 현재 DB role이 모두 ADMIN이어야 접근할 수 있다")
fun shouldRequireAdminJwtAndCurrentAdminRoleForFanTalkEndpoints() {
fanTalkEndpointRequests().forEach { endpoint ->
mockMvc.perform(
endpoint.request()
.with(authentication(createAuthentication(MemberRole.USER, MemberRole.ADMIN)))
).andExpect(status().isForbidden)
mockMvc.perform(
endpoint.request()
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.USER)))
).andExpect(status().isForbidden)
}
}
private fun createAuthentication(jwtRole: MemberRole, currentRole: MemberRole) =
UsernamePasswordAuthenticationToken(
MemberAdapter(
Member(
email = "admin@example.com",
password = "password",
nickname = "admin",
role = currentRole
)
),
"token",
listOf(SimpleGrantedAuthority("ROLE_${jwtRole.name}"))
)
private fun seriesEndpointRequests() = listOf(
SeriesEndpointRequest("series list") {
get("/api/v2/admin/ai-characters/1/series")
},
SeriesEndpointRequest("series detail") {
get("/api/v2/admin/ai-characters/1/series/2")
},
SeriesEndpointRequest("series contents") {
get("/api/v2/admin/ai-characters/1/series/2/contents")
},
SeriesEndpointRequest("series content search") {
get("/api/v2/admin/ai-characters/1/series/2/contents/search")
.param("search_word", "night")
},
SeriesEndpointRequest("series content add") {
post("/api/v2/admin/ai-characters/1/series/2/contents")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"contentIdList":[3]}""")
},
SeriesEndpointRequest("series content remove") {
delete("/api/v2/admin/ai-characters/1/series/2/contents/3")
},
SeriesEndpointRequest("series order") {
put("/api/v2/admin/ai-characters/1/series/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"ids":[2,3]}""")
},
SeriesEndpointRequest("series create") {
multipart("/api/v2/admin/ai-characters/1/series")
},
SeriesEndpointRequest("series update") {
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/1/series/2")
},
SeriesEndpointRequest("series delete") {
delete("/api/v2/admin/ai-characters/1/series/2")
}
)
private fun communityEndpointRequests() = listOf(
SeriesEndpointRequest("community list") {
get("/api/v2/admin/ai-characters/1/community-posts")
.param("timezone", "UTC")
},
SeriesEndpointRequest("community create") {
multipart("/api/v2/admin/ai-characters/1/community-posts")
},
SeriesEndpointRequest("community update") {
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/1/community-posts/2")
}
)
private fun fanTalkEndpointRequests() = listOf(
SeriesEndpointRequest("FanTalk list") {
get("/api/v2/admin/ai-characters/1/fan-talks")
},
SeriesEndpointRequest("FanTalk reply") {
post("/api/v2/admin/ai-characters/1/fan-talks/2/replies")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"content":"reply"}""")
},
SeriesEndpointRequest("FanTalk reply update") {
put("/api/v2/admin/ai-characters/1/fan-talks/2/replies/3")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"content":"reply","isActive":true}""")
},
SeriesEndpointRequest("FanTalk delete") {
delete("/api/v2/admin/ai-characters/1/fan-talks/2")
}
)
private data class SeriesEndpointRequest(
val name: String,
val request: () -> MockHttpServletRequestBuilder
)
@TestComponent
@RestController
@RequestMapping("/api/v2/admin/ai-characters/sample")
class AiCharacterAdminAuthorizationSampleController {
@GetMapping
fun sample(): ApiResponse<String> = ApiResponse.ok("ok")
}
@TestComponent
@RestController
class AiCharacterAdminLegacySampleController {
@GetMapping("/phase1-legacy-sample")
fun sample(): ApiResponse<String> = ApiResponse.ok("legacy-ok")
}
}

View File

@@ -0,0 +1,907 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter
import io.jsonwebtoken.Claims
import io.jsonwebtoken.ExpiredJwtException
import io.jsonwebtoken.Header
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
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.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.hamcrest.Matchers.nullValue
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.ValueSource
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.context.TestComponent
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.annotation.Import
import org.springframework.core.MethodParameter
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.security.authentication.InternalAuthenticationServiceException
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.ResultActions
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.options
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch
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.header
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.validation.BeanPropertyBindingResult
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping
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.RequestHeader
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MaxUploadSizeExceededException
import org.springframework.web.multipart.MultipartException
import org.springframework.web.multipart.support.MissingServletRequestPartException
@SpringBootTest
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@Import(
AiCharacterAdminErrorContractTest.AiCharacterAdminErrorContractSampleController::class,
AiCharacterAdminErrorContractTest.AiCharacterAdminLegacyErrorContractSampleController::class
)
class AiCharacterAdminErrorContractTest @Autowired constructor(
private val mockMvc: MockMvc
) {
companion object {
private const val CHARACTER_ADMIN_ORIGIN = "https://character-admin.sodalive.net"
private const val CREATOR_ORIGIN = "https://creator.sodalive.net"
}
@MockBean
private lateinit var tokenProvider: TokenProvider
@MockBean
private lateinit var chatCharacterRepository: ChatCharacterRepository
@ParameterizedTest
@CsvSource(
"ko,로그인 정보를 확인해주세요.",
"en,Please check your login information.",
"ja,ログイン情報を確認してください。"
)
@DisplayName("비회원 401 오류는 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForAnonymousRequest(language: String, message: String) {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/success")
.header("Accept-Language", language)
.with(anonymous())
)
result.andExpectApiError(401, message)
}
@ParameterizedTest
@CsvSource(
"ko,권한이 없습니다.",
"en,You do not have permission.",
"ja,権限がありません。"
)
@DisplayName("비ADMIN 403 오류는 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForNonAdminRequest(language: String, message: String) {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/success")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.USER, MemberRole.ADMIN)))
)
result.andExpectApiError(403, message)
}
@ParameterizedTest
@CsvSource(
"ko,권한이 없습니다.",
"en,You do not have permission.",
"ja,権限がありません。"
)
@DisplayName("stale ADMIN claim 403 오류는 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForStaleAdminClaim(language: String, message: String) {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/success")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.USER)))
)
result.andExpectApiError(403, message)
}
@ParameterizedTest
@CsvSource(
"ko,로그인 정보를 확인해주세요.",
"en,Please check your login information.",
"ja,ログイン情報を確認してください。"
)
@DisplayName("잘못된 JWT의 401 오류도 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForInvalidJwt(language: String, message: String) {
Mockito.`when`(tokenProvider.validateToken("invalid-token")).thenReturn(false)
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/success")
.header("Authorization", "Bearer invalid-token")
.header("Accept-Language", language)
)
result.andExpectApiError(401, message)
}
@ParameterizedTest
@CsvSource(
"ko,로그인 정보를 확인해주세요.",
"en,Please check your login information.",
"ja,ログイン情報を確認してください。"
)
@DisplayName("폐기된 JWT filter 예외도 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForRevokedJwt(language: String, message: String) {
Mockito.`when`(tokenProvider.validateToken("revoked-token")).thenReturn(true)
Mockito.`when`(tokenProvider.getAuthentication("revoked-token"))
.thenThrow(SodaException(messageKey = "common.error.bad_credentials"))
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/success")
.header("Authorization", "Bearer revoked-token")
.header("Accept-Language", language)
)
result.andExpectApiError(401, message)
}
@ParameterizedTest
@CsvSource(
"ko,로그인 정보를 확인해주세요.",
"en,Please check your login information.",
"ja,ログイン情報を確認してください。"
)
@DisplayName("JWT 검증 직후 만료된 token도 401 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseWhenJwtExpiresBetweenParses(language: String, message: String) {
Mockito.`when`(tokenProvider.validateToken("expired-between-parses")).thenReturn(true)
Mockito.`when`(tokenProvider.getAuthentication("expired-between-parses"))
.thenThrow(
ExpiredJwtException(
Mockito.mock(Header::class.java),
Mockito.mock(Claims::class.java),
"expired"
)
)
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/success")
.header("Authorization", "Bearer expired-between-parses")
.header("Accept-Language", language)
)
result.andExpectApiError(401, message)
}
@ParameterizedTest
@CsvSource(
"ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.",
"en,An unknown error occurred. try again.",
"ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。"
)
@DisplayName("인증 저장소 장애는 500 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForAuthenticationInfrastructureError(language: String, message: String) {
Mockito.`when`(tokenProvider.validateToken("authentication-service-error")).thenReturn(true)
Mockito.`when`(tokenProvider.getAuthentication("authentication-service-error"))
.thenThrow(InternalAuthenticationServiceException("authentication backend unavailable"))
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/success")
.header("Authorization", "Bearer authentication-service-error")
.header("Accept-Language", language)
)
result.andExpectApiError(500, message)
}
@ParameterizedTest
@CsvSource(
"ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.",
"en,An unknown error occurred. try again.",
"ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。"
)
@DisplayName("JWT filter의 예상하지 못한 오류는 500 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForUnexpectedJwtFilterError(language: String, message: String) {
Mockito.`when`(tokenProvider.validateToken("filter-error-token")).thenReturn(true)
Mockito.`when`(tokenProvider.getAuthentication("filter-error-token"))
.thenThrow(IllegalStateException("internal detail must not be exposed"))
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/success")
.header("Authorization", "Bearer filter-error-token")
.header("Accept-Language", language)
)
result.andExpectApiError(500, message)
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("잘못된 target 400 오류는 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForInvalidTarget(language: String, message: String) {
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(404L)).thenReturn(null)
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/invalid-target")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(400, message)
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("잘못된 request binding은 400 ApiResponse와 i18n 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForInvalidRequestBinding(language: String, message: String) {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/binding")
.param("value", "not-a-number")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(400, message)
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("읽을 수 없는 JSON body는 400 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForUnreadableJson(language: String, message: String) {
val result = mockMvc.perform(
post("/api/v2/admin/ai-characters/error-contract/body-binding")
.contentType(MediaType.APPLICATION_JSON)
.content("{")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(400, message)
assertEquals(HttpMessageNotReadableException::class.java, result.andReturn().resolvedException?.javaClass)
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("MethodArgumentNotValidException은 400 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForMethodArgumentNotValid(language: String, message: String) {
val result = mockMvc.perform(
post("/api/v2/admin/ai-characters/error-contract/method-argument-not-valid")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"value\":1}")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(400, message)
assertEquals(MethodArgumentNotValidException::class.java, result.andReturn().resolvedException?.javaClass)
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("필수 multipart part 누락은 400 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForMissingMultipartPart(language: String, message: String) {
val result = mockMvc.perform(
multipart("/api/v2/admin/ai-characters/error-contract/required-part")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(400, message)
assertEquals(MissingServletRequestPartException::class.java, result.andReturn().resolvedException?.javaClass)
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("필수 request header 누락은 400 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForMissingRequiredHeader(language: String, message: String) {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/required-header")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(400, message)
}
@ParameterizedTest
@CsvSource(
"ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.",
"en,An unknown error occurred. try again.",
"ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。"
)
@DisplayName("예상하지 못한 오류는 500 ApiResponse와 i18n 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForUnexpectedError(language: String, message: String) {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/unexpected")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(500, message)
}
@ParameterizedTest
@CsvSource(
"ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.",
"en,An unknown error occurred. try again.",
"ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。"
)
@DisplayName("등록되지 않은 message key는 status를 유지하고 unknown 메시지로 fallback한다")
fun shouldFallbackToLocalizedUnknownMessageForMissingMessageKey(language: String, message: String) {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/missing-message-key")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(400, message)
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("지원하지 않는 HTTP method는 405 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForUnsupportedMethod(language: String, message: String) {
val result = mockMvc.perform(
post("/api/v2/admin/ai-characters/error-contract/success")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(405, message)
result.andExpect(header().string(HttpHeaders.ALLOW, "GET"))
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("지원하지 않는 media type은 415 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForUnsupportedMediaType(language: String, message: String) {
val result = mockMvc.perform(
post("/api/v2/admin/ai-characters/error-contract/media-type")
.contentType(MediaType.TEXT_PLAIN)
.content("text")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(415, message)
result.andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
}
@Test
@DisplayName("PATCH의 지원하지 않는 media type은 Accept와 Accept-Patch header를 반환한다")
fun shouldReturnAcceptHeadersForUnsupportedPatchMediaType() {
val result = mockMvc.perform(
patch("/api/v2/admin/ai-characters/error-contract/media-type")
.contentType(MediaType.TEXT_PLAIN)
.content("text")
.header("Accept-Language", "en")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(415, "Invalid request.")
result.andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
result.andExpect(header().string(HttpHeaders.ACCEPT_PATCH, MediaType.APPLICATION_JSON_VALUE))
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("지원하지 않는 응답 media type은 406 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForNotAcceptableMediaType(language: String, message: String) {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/success")
.accept(MediaType.APPLICATION_XML)
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(406, message)
}
@Test
@DisplayName("허용된 캐릭터 관리자 Origin의 지원하지 않는 응답 media type 406에는 CORS header와 ApiResponse가 함께 반환된다")
fun shouldApplyCorsToNotAcceptableMediaTypeError() {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/success")
.accept(MediaType.APPLICATION_XML)
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header("Accept-Language", "en")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(406, "Invalid request.")
result.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
}
@ParameterizedTest
@CsvSource(
"ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.",
"en,An unknown error occurred. try again.",
"ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。"
)
@DisplayName("누락된 path variable은 500 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForMissingPathVariable(language: String, message: String) {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/missing-path-variable")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(500, message)
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("multipart 파싱 오류는 400 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForMultipartError(language: String, message: String) {
val result = mockMvc.perform(
post("/api/v2/admin/ai-characters/error-contract/multipart-error")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(400, message)
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("multipart 용량 초과 오류는 400 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForMaxUploadSizeError(language: String, message: String) {
val result = mockMvc.perform(
post("/api/v2/admin/ai-characters/error-contract/max-upload-size-error")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(400, message)
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("신규 prefix의 미매핑 경로는 404 ApiResponse와 요청 언어 메시지를 반환한다")
fun shouldReturnLocalizedApiResponseForUnmappedPath(language: String, message: String) {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/unmapped-path")
.header("Accept-Language", language)
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(404, message)
}
@Test
@DisplayName("허용된 캐릭터 관리자 Origin의 미매핑 경로 404에는 CORS header와 ApiResponse가 함께 반환된다")
fun shouldApplyCorsToUnmappedPathError() {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/unmapped-path")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header("Accept-Language", "en")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(404, "Invalid request.")
result.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
}
@Test
@DisplayName("허용된 캐릭터 관리자 Origin의 지원하지 않는 method 405에는 CORS header와 ApiResponse가 함께 반환된다")
fun shouldApplyCorsToUnsupportedMethodError() {
val result = mockMvc.perform(
post("/api/v2/admin/ai-characters/error-contract/success")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header("Accept-Language", "en")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(405, "Invalid request.")
result.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
}
@Test
@DisplayName("허용된 캐릭터 관리자 Origin의 지원하지 않는 media type 415에는 CORS header와 ApiResponse가 함께 반환된다")
fun shouldApplyCorsToUnsupportedMediaTypeError() {
val result = mockMvc.perform(
post("/api/v2/admin/ai-characters/error-contract/media-type")
.contentType(MediaType.TEXT_PLAIN)
.content("text")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header("Accept-Language", "en")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(415, "Invalid request.")
result.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
}
@Test
@DisplayName("허용된 캐릭터 관리자 Origin의 multipart 오류 400에는 CORS header와 ApiResponse가 함께 반환된다")
fun shouldApplyCorsToMultipartError() {
val result = mockMvc.perform(
post("/api/v2/admin/ai-characters/error-contract/multipart-error")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header("Accept-Language", "en")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(400, "Invalid request.")
result.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
}
@ParameterizedTest
@ValueSource(
strings = [
"http://localhost:8888",
"https://test-character-admin.sodalive.net",
"https://character-admin.sodalive.net"
]
)
@DisplayName("캐릭터 관리자 Origin의 신규 prefix preflight를 CORS filter가 허용한다")
fun shouldAllowCorsPreflightForAiCharacterAdminPrefix(origin: String) {
mockMvc.perform(
options("/api/v2/admin/ai-characters/unmapped-path")
.header(HttpHeaders.ORIGIN, origin)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, origin))
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"))
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "authorization, content-type"))
}
@ParameterizedTest
@ValueSource(strings = ["POST", "PUT", "PATCH", "DELETE"])
@DisplayName("캐릭터 관리자 Origin의 실제 mapped endpoint write preflight를 CORS filter가 허용한다")
fun shouldAllowCorsPreflightForMappedAiCharacterAdminWriteMethods(method: String) {
mockMvc.perform(
options("/api/v2/admin/ai-characters/error-contract/write-preflight")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, method))
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"))
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "authorization, content-type"))
}
@ParameterizedTest
@ValueSource(strings = ["POST", "PUT", "PATCH", "DELETE"])
@DisplayName("캐릭터 관리자 Origin의 fallback write preflight를 CORS filter가 허용한다")
fun shouldAllowCorsPreflightForFallbackAiCharacterAdminWriteMethods(method: String) {
mockMvc.perform(
options("/api/v2/admin/ai-characters/unmapped-path")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, method))
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"))
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "authorization, content-type"))
}
@ParameterizedTest
@ValueSource(
strings = [
"https://creator.sodalive.net",
"https://test-creator.sodalive.net",
"https://admin.sodalive.net",
"https://test-admin.sodalive.net"
]
)
@DisplayName("신규 prefix preflight는 범용 관리자와 creator Origin을 허용하지 않는다")
fun shouldRejectOtherAdminOriginPreflightForAiCharacterAdminPrefix(origin: String) {
mockMvc.perform(
options("/api/v2/admin/ai-characters/unmapped-path")
.header(HttpHeaders.ORIGIN, origin)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
)
.andExpect(status().isForbidden)
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
}
@Test
@DisplayName("신규 prefix 일반 요청도 creator Origin이면 CORS 단계에서 거부된다")
fun shouldNotApplyCreatorOriginCorsToAiCharacterAdminError() {
mockMvc.perform(
get("/api/v2/admin/ai-characters/unmapped-path")
.header(HttpHeaders.ORIGIN, CREATOR_ORIGIN)
.header("Accept-Language", "en")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
.andExpect(status().isForbidden)
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
}
@Test
@DisplayName("비ADMIN 요청은 request body 역직렬화 전에 403으로 거부된다")
fun shouldRejectNonAdminBeforeRequestBodyBinding() {
val result = mockMvc.perform(
post("/api/v2/admin/ai-characters/error-contract/body-binding")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"value\":\"not-a-number\"}")
.header("Accept-Language", "en")
.with(authentication(createAuthentication(MemberRole.USER, MemberRole.ADMIN)))
)
result.andExpectApiError(403, "You do not have permission.")
}
@Test
@DisplayName("인접 prefix에는 AI 캐릭터 관리자 전용 fallback handler가 적용되지 않는다")
fun shouldNotApplyAiCharacterAdminHandlerToAdjacentPrefix() {
mockMvc.perform(
delete("/api/v2/admin/ai-characters-shadow")
.header("Accept-Language", "en")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
.andExpect(status().isNotFound)
.andExpect(content().string(""))
}
@Test
@DisplayName("Accept-Language가 없으면 신규 security 오류 메시지는 한국어로 fallback한다")
fun shouldFallbackToKoreanForMissingAcceptLanguage() {
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/success")
.with(anonymous())
)
result.andExpectApiError(401, "로그인 정보를 확인해주세요.")
}
@Test
@DisplayName("지원하지 않는 Accept-Language는 신규 MVC 오류 메시지를 한국어로 fallback한다")
fun shouldFallbackToKoreanForUnsupportedAcceptLanguage() {
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(404L)).thenReturn(null)
val result = mockMvc.perform(
get("/api/v2/admin/ai-characters/error-contract/invalid-target")
.header("Accept-Language", "fr")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
result.andExpectApiError(400, "잘못된 요청입니다.")
}
@Test
@DisplayName("신규 prefix 밖 비회원 오류는 기존 빈 401 응답을 유지한다")
fun shouldKeepLegacyAnonymousErrorResponse() {
mockMvc.perform(
get("/phase1-legacy-error-contract")
.header("Accept-Language", "en")
.with(anonymous())
)
.andExpect(status().isUnauthorized)
.andExpect(content().string(""))
}
@Test
@DisplayName("신규 prefix 밖 JWT filter 예외는 기존 한국어 ApiResponse를 유지한다")
fun shouldKeepLegacyRevokedJwtErrorResponse() {
Mockito.`when`(tokenProvider.validateToken("legacy-revoked-token")).thenReturn(true)
Mockito.`when`(tokenProvider.getAuthentication("legacy-revoked-token"))
.thenThrow(SodaException(messageKey = "common.error.bad_credentials"))
mockMvc.perform(
get("/phase1-legacy-error-contract")
.header("Authorization", "Bearer legacy-revoked-token")
.header("Accept-Language", "en")
)
.andExpect(status().isUnauthorized)
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("로그인 정보를 확인해주세요."))
}
@Test
@DisplayName("신규 prefix 밖 지원하지 않는 HTTP method 오류는 기존 200 응답을 유지한다")
fun shouldKeepLegacyUnsupportedMethodErrorResponse() {
mockMvc.perform(
post("/phase1-legacy-error-contract")
.header("Accept-Language", "en")
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
)
.andExpect(status().isOk)
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."))
}
private fun ResultActions.andExpectApiError(httpStatus: Int, message: String) {
andExpect(status().`is`(httpStatus))
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
.andExpect(jsonPath("$.data").value(nullValue()))
.andExpect(jsonPath("$.errorProperty").value(nullValue()))
}
private fun createAuthentication(jwtRole: MemberRole, currentRole: MemberRole) =
UsernamePasswordAuthenticationToken(
MemberAdapter(
Member(
email = "admin@example.com",
password = "password",
nickname = "admin",
role = currentRole
)
),
"token",
listOf(SimpleGrantedAuthority("ROLE_${jwtRole.name}"))
)
@TestComponent
@RestController
@RequestMapping("/api/v2/admin/ai-characters/error-contract")
class AiCharacterAdminErrorContractSampleController(
private val targetResolver: AiCharacterAdminTargetResolver
) {
@GetMapping("/success")
fun success(): ApiResponse<String> = ApiResponse.ok("ok")
@GetMapping("/invalid-target")
fun invalidTarget(): ApiResponse<String> {
targetResolver.resolve(404L)
return ApiResponse.ok("unexpected")
}
@GetMapping("/binding")
fun binding(@RequestParam value: Long): ApiResponse<Long> = ApiResponse.ok(value)
@GetMapping("/required-header")
fun requiredHeader(@RequestHeader("X-Required") value: String): ApiResponse<String> = ApiResponse.ok(value)
@GetMapping("/unexpected")
fun unexpected(): ApiResponse<String> {
throw IllegalStateException("internal detail must not be exposed")
}
@GetMapping("/missing-message-key")
fun missingMessageKey(): ApiResponse<String> {
throw AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "missing.message.key")
}
@PostMapping("/media-type", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun mediaType(): ApiResponse<String> = ApiResponse.ok("ok")
@PatchMapping("/media-type", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun patchMediaType(): ApiResponse<String> = ApiResponse.ok("ok")
@PostMapping("/write-preflight")
fun postWritePreflight(): ApiResponse<String> = ApiResponse.ok("ok")
@PutMapping("/write-preflight")
fun putWritePreflight(): ApiResponse<String> = ApiResponse.ok("ok")
@PatchMapping("/write-preflight")
fun patchWritePreflight(): ApiResponse<String> = ApiResponse.ok("ok")
@DeleteMapping("/write-preflight")
fun deleteWritePreflight(): ApiResponse<String> = ApiResponse.ok("ok")
@GetMapping("/missing-path-variable")
fun missingPathVariable(@PathVariable("missing") value: Long): ApiResponse<Long> = ApiResponse.ok(value)
@PostMapping("/body-binding", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun bodyBinding(@RequestBody request: BodyBindingRequest): ApiResponse<Long> = ApiResponse.ok(request.value)
@PostMapping("/method-argument-not-valid", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun methodArgumentNotValid(@RequestBody request: BodyBindingRequest): ApiResponse<Long> {
val method = AiCharacterAdminErrorContractSampleController::class.java
.getDeclaredMethod("methodArgumentNotValid", BodyBindingRequest::class.java)
val bindingResult = BeanPropertyBindingResult(request, "request")
bindingResult.rejectValue("value", "invalid")
throw MethodArgumentNotValidException(MethodParameter(method, 0), bindingResult)
}
@PostMapping("/required-part", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun requiredPart(@RequestPart("file") value: String): ApiResponse<String> = ApiResponse.ok(value)
@PostMapping("/multipart-error")
fun multipartError(): ApiResponse<String> {
throw MultipartException("malformed multipart request")
}
@PostMapping("/max-upload-size-error")
fun maxUploadSizeError(): ApiResponse<String> {
throw MaxUploadSizeExceededException(1L)
}
}
@TestComponent
@RestController
class AiCharacterAdminLegacyErrorContractSampleController {
@GetMapping("/phase1-legacy-error-contract")
fun sample(): ApiResponse<String> = ApiResponse.ok("legacy-ok")
}
}
data class BodyBindingRequest(
val value: Long
)

View File

@@ -0,0 +1,298 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter
import com.jayway.jsonpath.JsonPath
import kr.co.vividnext.sodalive.common.ApiResponse
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.MemberTokenRepository
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
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.context.TestComponent
import org.springframework.context.annotation.Import
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
import org.springframework.security.web.firewall.RequestRejectedException
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.options
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.net.URI
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
@Import(AiCharacterAdminLoginJwtIntegrationTest.AiCharacterAdminLoginJwtSampleController::class)
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class AiCharacterAdminLoginJwtIntegrationTest @Autowired constructor(
private val mockMvc: MockMvc,
private val memberRepository: MemberRepository,
private val memberTokenRepository: MemberTokenRepository,
private val passwordEncoder: PasswordEncoder
) {
companion object {
private const val CHARACTER_ADMIN_ORIGIN = "https://character-admin.sodalive.net"
private const val GENERAL_ADMIN_ORIGIN = "https://admin.sodalive.net"
private const val UNTRUSTED_ORIGIN = "https://not-allowed.example.com"
}
@AfterEach
fun cleanUpMemberTokens() {
memberTokenRepository.deleteAll()
}
@Test
@DisplayName("관리자 로그인 JWT는 신규 AI 캐릭터 관리자 prefix를 통과하고 현재 DB role 강등 시 거부된다")
fun shouldAuthorizeAiCharacterAdminPrefixWithActualAdminLoginJwtAndCurrentRole() {
val admin = memberRepository.saveAndFlush(createMember(MemberRole.ADMIN))
val token = loginAdmin()
mockMvc.perform(
get("/api/v2/admin/ai-characters/login-jwt/success")
.header(HttpHeaders.AUTHORIZATION, "Bearer $token")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data").value("ok"))
admin.role = MemberRole.USER
memberRepository.saveAndFlush(admin)
mockMvc.perform(
get("/api/v2/admin/ai-characters/login-jwt/success")
.header(HttpHeaders.AUTHORIZATION, "Bearer $token")
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
)
.andExpect(status().isForbidden)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("You do not have permission."))
}
@Test
@DisplayName("실제 Spring context에서 신규 prefix 비회원 오류는 localized ApiResponse 401이다")
fun shouldReturnLocalizedUnauthorizedApiResponseInSpringContext() {
mockMvc.perform(
get("/api/v2/admin/ai-characters/login-jwt/success")
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.with(anonymous())
)
.andExpect(status().isUnauthorized)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("Please check your login information."))
}
@Test
@DisplayName("실제 Spring context에서 신규 prefix는 캐릭터 관리자 Origin preflight만 허용한다")
fun shouldAllowOnlyCharacterAdminOriginPreflightInSpringContext() {
mockMvc.perform(
options("/api/v2/admin/ai-characters/login-jwt/success")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
mockMvc.perform(
options("/api/v2/admin/ai-characters/login-jwt/success")
.header(HttpHeaders.ORIGIN, GENERAL_ADMIN_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
)
.andExpect(status().isForbidden)
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
}
@ParameterizedTest
@ValueSource(
strings = [
"http://localhost:8888",
"https://creator.sodalive.net",
"https://test-creator.sodalive.net",
"https://test-admin.sodalive.net",
GENERAL_ADMIN_ORIGIN,
"https://test-character-admin.sodalive.net",
CHARACTER_ADMIN_ORIGIN
]
)
@DisplayName("공유 관리자 로그인과 로그아웃은 기존 관리자와 캐릭터 관리자 Origin을 허용한다")
fun shouldAllowAdminOriginsForSharedAuthenticationEndpoints(origin: String) {
mockMvc.perform(
options("/admin/member/login")
.header(HttpHeaders.ORIGIN, origin)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "content-type")
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, origin))
mockMvc.perform(
options("/member/logout")
.header(HttpHeaders.ORIGIN, origin)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization")
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, origin))
}
@Test
@DisplayName("공유 관리자 로그인과 로그아웃은 미등록 Origin을 거부한다")
fun shouldRejectUntrustedOriginForSharedAuthenticationEndpoints() {
listOf("/admin/member/login", "/member/logout").forEach { path ->
mockMvc.perform(
options(path)
.header(HttpHeaders.ORIGIN, UNTRUSTED_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")
)
.andExpect(status().isForbidden)
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
}
}
@Test
@DisplayName("캐릭터 관리자 Origin은 공유 인증 외 legacy/public 경로로 확산되지 않는다")
fun shouldNotApplyCharacterAdminCorsToOtherEndpoints() {
listOf("/api/v2/home/recommendations/ai-characters", "/api/v2/admin/ai-characters-shadow").forEach { path ->
mockMvc.perform(
options(path)
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
)
.andExpect(status().isForbidden)
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
}
}
@Test
@DisplayName("캐릭터 관리자 Origin에서 기존 관리자 로그인과 공통 로그아웃을 호출한다")
fun shouldLoginAndLogoutFromCharacterAdminOrigin() {
memberRepository.saveAndFlush(createMember(MemberRole.ADMIN))
val token = loginAdmin()
mockMvc.perform(
post("/member/logout")
.header(HttpHeaders.AUTHORIZATION, "Bearer $token")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
mockMvc.perform(
get("/api/v2/admin/ai-characters/login-jwt/success")
.header(HttpHeaders.AUTHORIZATION, "Bearer $token")
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
)
.andExpect(status().isUnauthorized)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("Please check your login information."))
}
@Test
@DisplayName("허용된 캐릭터 관리자 Origin의 비표준 method는 localized 400 ApiResponse와 CORS를 반환한다")
fun shouldReturnLocalizedBadRequestForRejectedMethodFromCharacterAdminOrigin() {
mockMvc.perform(
request("PROPFIND", URI.create("/api/v2/admin/ai-characters/login-jwt/success"))
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
)
.andExpect(status().isBadRequest)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("Invalid request."))
}
@Test
@DisplayName("허용된 캐릭터 관리자 Origin의 위험 URL은 400 ApiResponse와 CORS를 반환한다")
fun shouldReturnBadRequestForRejectedDangerousUrlFromCharacterAdminOrigin() {
mockMvc.perform(
request("GET", URI.create("/api/v2/admin/ai-characters//login-jwt/success"))
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
)
.andExpect(status().isBadRequest)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.success").value(false))
}
@Test
@DisplayName("미허용 Origin의 신규 prefix 비표준 method는 CORS 정책 403으로 종료한다")
fun shouldRejectRejectedMethodFromUntrustedOriginWithoutCorsHeader() {
mockMvc.perform(
request("PROPFIND", URI.create("/api/v2/admin/ai-characters/login-jwt/success"))
.header(HttpHeaders.ORIGIN, UNTRUSTED_ORIGIN)
)
.andExpect(status().isForbidden)
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
}
@Test
@DisplayName("legacy 경로의 비표준 method는 기존 firewall fallback을 유지한다")
fun shouldKeepLegacyFirewallFallbackForRejectedMethod() {
assertThrows<RequestRejectedException> {
mockMvc.perform(request("PROPFIND", URI.create("/api/v2/home/recommendations")))
}
}
private fun loginAdmin(): String {
val result = mockMvc.perform(
post("/admin/member/login")
.contentType(MediaType.APPLICATION_JSON)
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.content("""{"email":"admin-jwt@test.com","password":"password"}""")
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
.andExpect(jsonPath("$.success").value(true))
.andReturn()
return JsonPath.read(result.response.contentAsString, "$.data.token")
}
private fun createMember(role: MemberRole): Member {
return Member(
email = "admin-jwt@test.com",
password = passwordEncoder.encode("password"),
nickname = "admin-jwt",
role = role
)
}
@TestComponent
@RestController
@RequestMapping("/api/v2/admin/ai-characters/login-jwt")
class AiCharacterAdminLoginJwtSampleController {
@GetMapping("/success")
fun success(): ApiResponse<String> = ApiResponse.ok("ok")
}
}

View File

@@ -0,0 +1,152 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.hibernate.Hibernate
import org.hibernate.SessionFactory
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.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.HttpStatus
import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.annotation.Transactional
import javax.persistence.EntityManager
import javax.persistence.EntityManagerFactory
@SpringBootTest(properties = ["spring.jpa.properties.hibernate.generate_statistics=true"])
@Transactional
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class AiCharacterAdminTargetResolverIntegrationTest @Autowired constructor(
private val resolver: AiCharacterAdminTargetResolver,
private val memberRepository: MemberRepository,
private val chatCharacterRepository: ChatCharacterRepository,
private val entityManager: EntityManager,
private val entityManagerFactory: EntityManagerFactory
) {
@Test
@DisplayName("요청한 캐릭터와 연결된 AI 캐릭터 Member를 실제 DB에서 해석한다")
fun shouldResolveRequestedCharacterCreatorMemberFromDatabase() {
val requestedMember = memberRepository.save(createMember("requested"))
val otherMember = memberRepository.save(createMember("other"))
val requestedCharacter = chatCharacterRepository.save(createCharacter("requested", requestedMember))
val otherCharacter = chatCharacterRepository.save(createCharacter("other", otherMember))
entityManager.flush()
entityManager.clear()
val target = resolver.resolve(requestedCharacter.id!!)
assertEquals(requestedCharacter.id, target.characterId)
assertEquals(requestedCharacter.id, target.chatCharacter.id)
assertEquals(requestedMember.id, target.creatorMember.id)
assertEquals(otherCharacter.id, chatCharacterRepository.findByCreatorMemberId(otherMember.id!!)?.id)
}
@Test
@DisplayName("캐릭터 조회 시 creatorMember를 함께 조회한다")
fun shouldFetchCreatorMemberWithCharacter() {
val member = memberRepository.save(createMember("fetch"))
val character = chatCharacterRepository.save(createCharacter("fetch", member))
entityManager.flush()
entityManager.clear()
val found = chatCharacterRepository.findByIdWithCreatorMember(character.id!!)
assertEquals(character.id, found?.id)
assertTrue(found != null && Hibernate.isInitialized(found.creatorMember))
assertEquals(member.id, found?.creatorMember?.id)
}
@Test
@DisplayName("유효하지 않은 creator를 거부하고 DB insert/update/delete를 만들지 않는다")
fun shouldRejectInvalidCreatorWithoutDatabaseMutation() {
val humanMember = memberRepository.save(createMember("human", memberKind = MemberKind.HUMAN))
val character = chatCharacterRepository.save(createCharacter("human", humanMember))
entityManager.flush()
entityManager.clear()
val memberCount = memberRepository.count()
val characterCount = chatCharacterRepository.count()
val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics
statistics.clear()
val exception = assertThrows(AiCharacterAdminApiException::class.java) {
resolver.resolve(character.id!!)
}
entityManager.flush()
entityManager.clear()
assertEquals(HttpStatus.BAD_REQUEST, exception.status)
assertEquals("common.error.invalid_request", exception.messageKey)
assertEquals(0L, statistics.entityInsertCount)
assertEquals(0L, statistics.entityUpdateCount)
assertEquals(0L, statistics.entityDeleteCount)
assertEquals(memberCount, memberRepository.count())
assertEquals(characterCount, chatCharacterRepository.count())
assertEquals(MemberKind.HUMAN, memberRepository.findById(humanMember.id!!).orElseThrow().memberKind)
}
@Test
@DisplayName("AI 캐릭터 Member라도 CREATOR role이 아니면 거부하고 DB를 변경하지 않는다")
fun shouldRejectWrongRoleAiCharacterMemberWithoutDatabaseMutation() {
val userMember = memberRepository.save(createMember("user", role = MemberRole.USER))
val character = chatCharacterRepository.save(createCharacter("user", userMember))
entityManager.flush()
entityManager.clear()
val memberCount = memberRepository.count()
val characterCount = chatCharacterRepository.count()
val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics
statistics.clear()
val exception = assertThrows(AiCharacterAdminApiException::class.java) {
resolver.resolve(character.id!!)
}
entityManager.flush()
entityManager.clear()
val savedMember = memberRepository.findById(userMember.id!!).orElseThrow()
assertEquals(HttpStatus.BAD_REQUEST, exception.status)
assertEquals("common.error.invalid_request", exception.messageKey)
assertEquals(0L, statistics.entityInsertCount)
assertEquals(0L, statistics.entityUpdateCount)
assertEquals(0L, statistics.entityDeleteCount)
assertEquals(memberCount, memberRepository.count())
assertEquals(characterCount, chatCharacterRepository.count())
assertEquals(MemberRole.USER, savedMember.role)
assertEquals(MemberKind.AI_CHARACTER, savedMember.memberKind)
}
private fun createCharacter(name: String, creatorMember: Member): ChatCharacter {
return ChatCharacter(
characterUUID = "character-$name-${System.nanoTime()}",
name = "character-$name",
description = "description-$name",
systemPrompt = "system-prompt-$name"
).apply {
this.creatorMember = creatorMember
}
}
private fun createMember(
name: String,
role: MemberRole = MemberRole.CREATOR,
memberKind: MemberKind = MemberKind.AI_CHARACTER
): Member {
return Member(
email = if (memberKind == MemberKind.HUMAN) "$name@example.com" else null,
password = if (memberKind == MemberKind.HUMAN) "password" else "",
nickname = "member-$name",
role = role,
memberKind = memberKind
)
}
}

View File

@@ -0,0 +1,145 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame
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.springframework.http.HttpStatus
class AiCharacterAdminTargetResolverTest {
private val chatCharacterRepository = Mockito.mock(ChatCharacterRepository::class.java)
private val resolver = AiCharacterAdminTargetResolver(chatCharacterRepository)
@Test
@DisplayName("유효한 AI 캐릭터 target에서 creatorMember를 해석한다")
fun shouldResolveAiCharacterCreatorMemberForAdminTarget() {
val creatorMember = createMember(id = 9001L)
val character = createCharacter(id = 101L, creatorMember = creatorMember)
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(101L)).thenReturn(character)
val target = resolver.resolve(101L)
assertEquals(101L, target.characterId)
assertSame(character, target.chatCharacter)
assertSame(creatorMember, target.creatorMember)
}
@Test
@DisplayName("존재하지 않는 캐릭터를 부작용 없이 거부한다")
fun shouldRejectMissingCharacterWithoutSideEffect() {
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(404L)).thenReturn(null)
val exception = assertThrows(AiCharacterAdminApiException::class.java) {
resolver.resolve(404L)
}
assertEquals(HttpStatus.BAD_REQUEST, exception.status)
assertEquals("common.error.invalid_request", exception.messageKey)
Mockito.verify(chatCharacterRepository).findByIdWithCreatorMember(404L)
Mockito.verifyNoMoreInteractions(chatCharacterRepository)
}
@Test
@DisplayName("CREATOR가 아닌 creatorMember를 부작용 없이 거부한다")
fun shouldRejectWrongRoleWithoutSideEffect() {
val member = createMember(id = 9001L, role = MemberRole.USER)
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(101L))
.thenReturn(createCharacter(id = 101L, creatorMember = member))
val exception = assertThrows(AiCharacterAdminApiException::class.java) {
resolver.resolve(101L)
}
assertEquals(HttpStatus.BAD_REQUEST, exception.status)
assertEquals("common.error.invalid_request", exception.messageKey)
Mockito.verify(chatCharacterRepository).findByIdWithCreatorMember(101L)
Mockito.verifyNoMoreInteractions(chatCharacterRepository)
}
@Test
@DisplayName("HUMAN creatorMember를 부작용 없이 거부한다")
fun shouldRejectHumanCreatorMemberWithoutSideEffect() {
val member = createMember(id = 9001L, memberKind = MemberKind.HUMAN)
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(101L))
.thenReturn(createCharacter(id = 101L, creatorMember = member))
val exception = assertThrows(AiCharacterAdminApiException::class.java) {
resolver.resolve(101L)
}
assertEquals(HttpStatus.BAD_REQUEST, exception.status)
assertEquals("common.error.invalid_request", exception.messageKey)
Mockito.verify(chatCharacterRepository).findByIdWithCreatorMember(101L)
Mockito.verifyNoMoreInteractions(chatCharacterRepository)
}
@Test
@DisplayName("creatorMember가 없는 캐릭터를 부작용 없이 거부한다")
fun shouldRejectMissingCreatorMemberWithoutSideEffect() {
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(101L))
.thenReturn(createCharacter(id = 101L, creatorMember = null))
val exception = assertThrows(AiCharacterAdminApiException::class.java) {
resolver.resolve(101L)
}
assertEquals(HttpStatus.BAD_REQUEST, exception.status)
assertEquals("common.error.invalid_request", exception.messageKey)
Mockito.verify(chatCharacterRepository).findByIdWithCreatorMember(101L)
Mockito.verifyNoMoreInteractions(chatCharacterRepository)
}
@Test
@DisplayName("여러 캐릭터 fixture 중 요청한 캐릭터만 해석한다")
fun shouldResolveOnlyRequestedCharacterFixture() {
val creatorMember = createMember(id = 9001L)
val otherCreatorMember = createMember(id = 9002L)
val requestedCharacter = createCharacter(id = 101L, creatorMember = creatorMember)
val otherCharacter = createCharacter(id = 202L, creatorMember = otherCreatorMember)
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(101L)).thenReturn(requestedCharacter)
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(202L)).thenReturn(otherCharacter)
val target = resolver.resolve(101L)
assertEquals(101L, target.characterId)
assertEquals(9001L, target.creatorMember.id)
assertSame(requestedCharacter, target.chatCharacter)
Mockito.verify(chatCharacterRepository).findByIdWithCreatorMember(101L)
Mockito.verifyNoMoreInteractions(chatCharacterRepository)
}
private fun createCharacter(id: Long, creatorMember: Member?): ChatCharacter {
return ChatCharacter(
characterUUID = "character-$id",
name = "character-$id",
description = "description-$id",
systemPrompt = "system-prompt-$id"
).apply {
this.id = id
this.creatorMember = creatorMember
}
}
private fun createMember(
id: Long,
role: MemberRole = MemberRole.CREATOR,
memberKind: MemberKind = MemberKind.AI_CHARACTER
): Member {
return Member(
email = if (memberKind == MemberKind.HUMAN) "human-$id@example.com" else null,
password = "password",
nickname = "member-$id",
role = role,
memberKind = memberKind
).apply { this.id = id }
}
}

View File

@@ -0,0 +1,480 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
import com.fasterxml.jackson.databind.ObjectMapper
import com.sun.net.httpserver.HttpServer
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRelationshipRequest
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.chat.original.OriginalWork
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
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.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.annotation.Transactional
import java.net.InetSocketAddress
import java.nio.charset.StandardCharsets
import javax.persistence.EntityManager
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class AiCharacterAdminCharacterControllerTest @Autowired constructor(
private val mockMvc: MockMvc,
private val chatCharacterService: ChatCharacterService,
private val objectMapper: ObjectMapper,
private val entityManager: EntityManager
) {
@Test
@DisplayName("목록은 레거시 totalCount와 content의 exact JSON 필드를 반환한다")
fun shouldReturnLegacyListContract() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-list-character",
name = "v2-list-character",
description = "description",
systemPrompt = "prompt",
age = 21,
gender = "여성",
mbti = "INFP",
speechPattern = "polite",
speechStyle = "soft",
tags = listOf("list-tag")
)
entityManager.flush()
val response = mockMvc.perform(
get("/api/v2/admin/ai-characters")
.param("page", "0")
.param("size", "1")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.totalCount").value(1))
.andReturn()
val data = objectMapper.readTree(response.response.contentAsString).path("data")
val item = data.path("content").path(0)
assertEquals(setOf("totalCount", "content"), data.fieldNames().asSequence().toSet())
assertEquals(
setOf(
"id",
"name",
"imageUrl",
"description",
"gender",
"age",
"mbti",
"speechStyle",
"speechPattern",
"region",
"tags",
"createdAt",
"updatedAt"
),
item.fieldNames().asSequence().toSet()
)
assertEquals(character.id, item.path("id").asLong())
assertEquals("v2-list-character", item.path("name").asText())
assertEquals(listOf("list-tag"), item.path("tags").map { it.asText() })
}
@Test
@DisplayName("목록은 OpenAPI minimum인 size 1을 그대로 적용한다")
fun shouldHonorLegacyMinimumListSize() {
repeat(2) { index ->
chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-list-size-$index",
name = "v2-list-size-$index",
description = "description",
systemPrompt = "prompt"
)
}
entityManager.flush()
mockMvc.perform(
get("/api/v2/admin/ai-characters")
.param("size", "1")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.totalCount").value(2))
.andExpect(jsonPath("$.data.content.length()").value(1))
}
@Test
@DisplayName("검색은 레거시 searchTerm query를 사용한다")
fun shouldSearchByLegacySearchTerm() {
val matchingCharacter = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-search-character",
name = "v2-search-character",
description = "find this character",
systemPrompt = "prompt"
)
chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-non-matching-character",
name = "v2-non-matching-character",
description = "different description",
systemPrompt = "prompt"
)
entityManager.flush()
mockMvc.perform(
get("/api/v2/admin/ai-characters")
.param("searchTerm", "find this")
.param("size", "100")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.content[0].id").value(matchingCharacter.id))
.andExpect(jsonPath("$.data.content[0].name").value("v2-search-character"))
}
@Test
@DisplayName("상세는 레거시 전체 필드와 nested 배열을 exact JSON으로 반환한다")
fun shouldReturnLegacyDetailContract() {
val originalWork = OriginalWork(
title = "detail original work",
contentType = "webtoon",
category = "fantasy",
isAdult = false,
description = "original work description"
)
entityManager.persist(originalWork)
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-detail-character",
name = "v2-detail-character",
description = "detail description",
systemPrompt = "detail prompt",
age = 22,
gender = "여성",
mbti = "ENFP",
speechPattern = "bright",
speechStyle = "friendly",
appearance = "silver hair",
region = "JP",
tags = listOf("detail-tag"),
hobbies = listOf("reading"),
values = listOf("honesty"),
goals = listOf("friendship"),
memories = listOf(Triple("memory title", "memory content", "happy")),
personalities = listOf(Pair("kind", "kind description")),
backgrounds = listOf(Pair("hometown", "moon city")),
relationships = listOf(
ChatCharacterRelationshipRequest(
personName = "Mina",
relationshipName = "friend",
description = "best friend",
importance = 10,
relationshipType = "ALLY",
currentStatus = "ACTIVE"
)
)
)
character.imagePath = "characters/${character.id}/detail.png"
character.originalWork = originalWork
entityManager.flush()
val response = mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andReturn()
val data = objectMapper.readTree(response.response.contentAsString).path("data")
assertEquals(
setOf(
"id",
"characterUUID",
"name",
"imageUrl",
"description",
"systemPrompt",
"characterType",
"age",
"gender",
"mbti",
"speechPattern",
"speechStyle",
"appearance",
"region",
"isActive",
"tags",
"hobbies",
"values",
"goals",
"relationships",
"personalities",
"backgrounds",
"memories",
"originalWork"
),
data.fieldNames().asSequence().toSet()
)
assertEquals(character.id, data.path("id").asLong())
assertEquals("v2-detail-character", data.path("characterUUID").asText())
assertEquals("detail prompt", data.path("systemPrompt").asText())
assertEquals("Character", data.path("characterType").asText())
assertEquals(listOf("detail-tag"), data.path("tags").map { it.asText() })
assertEquals(
setOf("personName", "relationshipName", "description", "importance", "relationshipType", "currentStatus"),
data.path("relationships").path(0).fieldNames().asSequence().toSet()
)
assertEquals("Mina", data.path("relationships").path(0).path("personName").asText())
assertEquals(setOf("trait", "description"), data.path("personalities").path(0).fieldNames().asSequence().toSet())
assertEquals(setOf("topic", "description"), data.path("backgrounds").path(0).fieldNames().asSequence().toSet())
assertEquals(setOf("title", "content", "emotion"), data.path("memories").path(0).fieldNames().asSequence().toSet())
assertEquals(setOf("id", "imageUrl", "title"), data.path("originalWork").fieldNames().asSequence().toSet())
assertEquals(originalWork.id, data.path("originalWork").path("id").asLong())
assertTrue(data.path("imageUrl").asText().endsWith("characters/${character.id}/detail.png"))
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("상세는 AI creatorMember가 아닌 target을 요청 언어의 invalid request로 거부한다")
fun shouldReturnLocalizedErrorForInvalidDetailTarget(language: String, message: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-invalid-target",
name = "v2-invalid-target",
description = "description",
systemPrompt = "prompt"
)
character.creatorMember!!.memberKind = MemberKind.HUMAN
entityManager.flush()
entityManager.clear()
mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}")
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("목록 binding 오류는 요청 언어의 invalid request를 반환한다")
fun shouldReturnLocalizedErrorForInvalidListBinding(language: String, message: String) {
mockMvc.perform(
get("/api/v2/admin/ai-characters")
.param("page", "not-a-number")
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
}
@Test
@DisplayName("목록·상세·생성·수정 endpoint는 non-ADMIN JWT를 binding 전에 거부한다")
fun shouldRejectNonAdminJwtRoleForEveryCharacterEndpoint() {
actualCharacterEndpointRequests().forEach { request ->
mockMvc.perform(
request
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.with(nonAdminAuthentication())
)
.andExpect(status().isForbidden)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("You do not have permission."))
}
}
@Test
@DisplayName("목록·상세·생성·수정 endpoint는 stale ADMIN claim을 binding 전에 거부한다")
fun shouldRejectStaleAdminClaimForEveryCharacterEndpoint() {
actualCharacterEndpointRequests().forEach { request ->
mockMvc.perform(
request
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.with(staleAdminAuthentication())
)
.andExpect(status().isForbidden)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("You do not have permission."))
}
}
@Test
@DisplayName("실제 상세 endpoint preflight는 캐릭터 관리자 Origin을 허용한다")
fun shouldApplyCorsToDetailEndpointPreflight() {
mockMvc.perform(
options("/api/v2/admin/ai-characters/1")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
}
@ParameterizedTest
@CsvSource(
"/api/v2/admin/ai-characters,GET",
"/api/v2/admin/ai-characters/1,GET",
"/api/v2/admin/ai-characters,POST",
"/api/v2/admin/ai-characters/1,PUT"
)
@DisplayName("실제 character endpoint preflight는 캐릭터 관리자 Origin만 허용한다")
fun shouldApplyCorsPreflightToEveryCharacterEndpoint(path: String, method: String) {
mockMvc.perform(
options(path)
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
mockMvc.perform(
options(path)
.header(HttpHeaders.ORIGIN, CREATOR_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
)
.andExpect(status().isForbidden)
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
}
@Test
@DisplayName("목록 endpoint는 ADMIN 인증을 상속한다")
fun shouldInheritAdminAuthorizationForListEndpoint() {
mockMvc.perform(
get("/api/v2/admin/ai-characters")
.with(anonymous())
)
.andExpect(status().isUnauthorized)
mockMvc.perform(
get("/api/v2/admin/ai-characters")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
}
private fun adminAuthentication() = authentication(
UsernamePasswordAuthenticationToken(
MemberAdapter(
Member(
email = "admin@example.com",
password = "password",
nickname = "admin",
role = MemberRole.ADMIN
)
),
"token",
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
)
)
private fun nonAdminAuthentication() = authentication(
UsernamePasswordAuthenticationToken(
MemberAdapter(
Member(
email = "user@example.com",
password = "password",
nickname = "user",
role = MemberRole.ADMIN
)
),
"token",
listOf(SimpleGrantedAuthority("ROLE_USER"))
)
)
private fun staleAdminAuthentication() = authentication(
UsernamePasswordAuthenticationToken(
MemberAdapter(
Member(
email = "stale-admin@example.com",
password = "password",
nickname = "stale-admin",
role = MemberRole.USER
)
),
"token",
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
)
)
private fun actualCharacterEndpointRequests(): List<MockHttpServletRequestBuilder> {
return listOf(
get("/api/v2/admin/ai-characters"),
get("/api/v2/admin/ai-characters/1"),
post("/api/v2/admin/ai-characters"),
put("/api/v2/admin/ai-characters/1")
)
}
companion object {
private const val CHARACTER_ADMIN_ORIGIN = "https://character-admin.sodalive.net"
private const val CREATOR_ORIGIN = "https://creator.sodalive.net"
private val externalCharacterApi = HttpServer.create(InetSocketAddress(0), 0).apply {
createContext("/api/characters") { exchange ->
val response = """{"success":true,"data":{"id":"external-v2-created-character"}}"""
.toByteArray(StandardCharsets.UTF_8)
exchange.responseHeaders.add("Content-Type", MediaType.APPLICATION_JSON_VALUE)
exchange.sendResponseHeaders(200, response.size.toLong())
exchange.responseBody.use { it.write(response) }
}
start()
}
@JvmStatic
@DynamicPropertySource
fun externalCharacterApiProperties(registry: DynamicPropertyRegistry) {
registry.add("weraser.api-url") { "http://localhost:${externalCharacterApi.address.port}" }
}
@JvmStatic
@AfterAll
fun stopExternalCharacterApi() {
externalCharacterApi.stop(0)
}
}
}

View File

@@ -0,0 +1,205 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
import com.fasterxml.jackson.databind.ObjectMapper
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.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
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.http.HttpHeaders
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
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.options
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.annotation.Transactional
import javax.persistence.EntityManager
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class AiCharacterAdminOriginalWorkSearchTest @Autowired constructor(
private val mockMvc: MockMvc,
private val objectMapper: ObjectMapper,
private val entityManager: EntityManager
) {
@Test
@DisplayName("원작 검색은 제목·콘텐츠 타입·카테고리 부분 검색과 레거시 13개 필드 직접 배열을 반환한다")
fun shouldSearchOriginalWorksByLegacyFieldsAndReturnDirectArray() {
val titleMatch = saveOriginalWork("p2-r9 moon title", "webtoon", "romance")
val typeMatch = saveOriginalWork("another title", "p2-r9 moon audio", "fantasy")
val categoryMatch = saveOriginalWork("third title", "novel", "p2-r9 moon category")
saveOriginalWork("deleted p2-r9 moon title", "webtoon", "romance", isDeleted = true)
saveOriginalWork("not matched", "novel", "drama")
entityManager.flush()
entityManager.clear()
val response = mockMvc.perform(
get("/api/v2/admin/ai-characters/original-works/search")
.param("searchTerm", "p2-r9 moon")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.length()").value(3))
.andReturn()
val data = objectMapper.readTree(response.response.contentAsString).path("data")
val ids = data.map { it.path("id").asLong() }.toSet()
assertEquals(setOf(titleMatch.id, typeMatch.id, categoryMatch.id), ids)
assertEquals(
setOf(
"id",
"title",
"contentType",
"category",
"isAdult",
"description",
"originalWork",
"originalLink",
"writer",
"studio",
"originalLinks",
"tags",
"imageUrl"
),
data.first().fieldNames().asSequence().toSet()
)
val titleItem = data.first { it.path("id").asLong() == titleMatch.id }
assertEquals(listOf("https://example.com/p2-r9-moon-title"), titleItem.path("originalLinks").map { it.asText() })
assertEquals(listOf("p2-r9-moon-title-tag"), titleItem.path("tags").map { it.asText() })
assertEquals("original p2-r9 moon title", titleItem.path("originalWork").asText())
assertEquals("https://source.example.com/p2-r9 moon title", titleItem.path("originalLink").asText())
assertEquals("writer p2-r9 moon title", titleItem.path("writer").asText())
assertEquals("studio p2-r9 moon title", titleItem.path("studio").asText())
}
@Test
@DisplayName("원작 검색은 결과가 없으면 빈 직접 배열을 반환한다")
fun shouldReturnEmptyArrayWhenNoOriginalWorkMatches() {
saveOriginalWork("unrelated original", "novel", "drama")
entityManager.flush()
mockMvc.perform(
get("/api/v2/admin/ai-characters/original-works/search")
.param("searchTerm", "missing-p2-r9")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.length()").value(0))
}
@Test
@DisplayName("원작 검색은 필수 searchTerm 누락을 신규 prefix 오류 envelope로 반환한다")
fun shouldRejectMissingSearchTermWithLocalizedError() {
mockMvc.perform(
get("/api/v2/admin/ai-characters/original-works/search")
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("Invalid request."))
}
@Test
@DisplayName("원작 검색은 characterId 상세 route와 충돌하지 않고 ADMIN 이중 인가와 CORS를 상속한다")
fun shouldKeepRouteAuthorizationAndCorsContract() {
mockMvc.perform(
get("/api/v2/admin/ai-characters/original-works/search")
.param("searchTerm", "p2-r9")
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.with(nonAdminAuthentication())
)
.andExpect(status().isForbidden)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("You do not have permission."))
mockMvc.perform(
options("/api/v2/admin/ai-characters/original-works/search")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
}
private fun saveOriginalWork(
title: String,
contentType: String,
category: String,
isDeleted: Boolean = false
): OriginalWork {
val slug = title.replace(" ", "-")
val originalWork = OriginalWork(
title = title,
contentType = contentType,
category = category,
isAdult = true,
description = "description $title",
originalWork = "original $title",
originalLink = "https://source.example.com/$title",
writer = "writer $title",
studio = "studio $title"
)
originalWork.imagePath = "originals/$slug.png"
originalWork.isDeleted = isDeleted
originalWork.originalLinks.add(OriginalWorkLink("https://example.com/$slug", originalWork))
val tag = OriginalWorkTag("$slug-tag")
entityManager.persist(tag)
originalWork.tagMappings.add(OriginalWorkTagMapping(originalWork, tag))
entityManager.persist(originalWork)
return originalWork
}
private fun adminAuthentication() = authentication(
UsernamePasswordAuthenticationToken(
MemberAdapter(
Member(
email = "admin@example.com",
password = "password",
nickname = "admin",
role = MemberRole.ADMIN
)
),
"token",
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
)
)
private fun nonAdminAuthentication() = authentication(
UsernamePasswordAuthenticationToken(
MemberAdapter(
Member(
email = "user@example.com",
password = "password",
nickname = "user",
role = MemberRole.ADMIN
)
),
"token",
listOf(SimpleGrantedAuthority("ROLE_USER"))
)
)
private companion object {
private const val CHARACTER_ADMIN_ORIGIN = "https://character-admin.sodalive.net"
}
}

Some files were not shown because too many files have changed in this diff Show More