Compare commits
16 Commits
a0bf71be47
...
5a90fbf98e
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a90fbf98e | |||
| d86a9fb059 | |||
| 1bfb387e78 | |||
| c45ad98db8 | |||
| 79b4436812 | |||
| 7b652400b7 | |||
| 4479c192c9 | |||
| 2f93e2c9c3 | |||
| dc1816aea7 | |||
| ec4e4a5768 | |||
| 90c0182ae1 | |||
| 54bbfc75cf | |||
| 8197f90b24 | |||
| f713313315 | |||
| 0d2742756f | |||
| d3564f8c0d |
@@ -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
296
docs/20260724_AI캐릭터_관리자_API/api-contract.md
Normal file
296
docs/20260724_AI캐릭터_관리자_API/api-contract.md
Normal 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: "..." })`로 반드시
|
||||||
|
지정한다.
|
||||||
1695
docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json
Normal file
1695
docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
7763
docs/20260724_AI캐릭터_관리자_API/plan-task.md
Normal file
7763
docs/20260724_AI캐릭터_관리자_API/plan-task.md
Normal file
File diff suppressed because it is too large
Load Diff
422
docs/20260724_AI캐릭터_관리자_API/prd.md
Normal file
422
docs/20260724_AI캐릭터_관리자_API/prd.md
Normal 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
|
||||||
|
- 없음.
|
||||||
@@ -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 요구사항 충족, 추가 수정 없음.
|
||||||
|
|
||||||
|
**남은 항목:** 없음.
|
||||||
1013
docs/20260724_AI캐릭터_관리자_API/reviews/phase2-character-review.md
Normal file
1013
docs/20260724_AI캐릭터_관리자_API/reviews/phase2-character-review.md
Normal file
File diff suppressed because it is too large
Load Diff
1347
docs/20260724_AI캐릭터_관리자_API/reviews/phase3-audio-content-review.md
Normal file
1347
docs/20260724_AI캐릭터_관리자_API/reviews/phase3-audio-content-review.md
Normal file
File diff suppressed because it is too large
Load Diff
605
docs/20260724_AI캐릭터_관리자_API/reviews/phase4-series-review.md
Normal file
605
docs/20260724_AI캐릭터_관리자_API/reviews/phase4-series-review.md
Normal 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 요구사항 충족, 추가 수정 없음.
|
||||||
|
|
||||||
|
**남은 항목:** 없음.
|
||||||
641
docs/20260724_AI캐릭터_관리자_API/reviews/phase5-community-review.md
Normal file
641
docs/20260724_AI캐릭터_관리자_API/reviews/phase5-community-review.md
Normal 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 요구사항 충족, 추가 수정 없음.
|
||||||
|
|
||||||
|
**남은 항목:** 없음.
|
||||||
488
docs/20260724_AI캐릭터_관리자_API/reviews/phase6-fantalk-review.md
Normal file
488
docs/20260724_AI캐릭터_관리자_API/reviews/phase6-fantalk-review.md
Normal 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 요구사항 충족, 추가 수정 없음.
|
||||||
|
|
||||||
|
**남은 항목:** 없음.
|
||||||
759
docs/20260724_AI캐릭터_관리자_API/reviews/phase7-integration-review.md
Normal file
759
docs/20260724_AI캐릭터_관리자_API/reviews/phase7-integration-review.md
Normal 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:** 없음.
|
||||||
@@ -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` 변경 시 포맷 규칙 섹션을 동기화한다.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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에 걸친 회귀 검증, 전체 빌드/포맷 검증, 문서 변경 범위 확인처럼 전체에 해당하는 검증은 계획 문서 하단의 검증 기록에 누적한다.
|
||||||
|
|||||||
@@ -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?
|
|
||||||
317
docs/sample/sample-plan-task.md
Normal file
317
docs/sample/sample-plan-task.md
Normal 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
271
docs/sample/sample-prd.md
Normal 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·검증 기록을 삭제하거나 덮어쓰지 않았다.
|
||||||
187
docs/sample/sample-review.md
Normal file
187
docs/sample/sample-review.md
Normal 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
7
openapitools.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
|
||||||
|
"spaces": 2,
|
||||||
|
"generator-cli": {
|
||||||
|
"version": "7.24.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/**"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
.allowedMethods("*")
|
||||||
|
.allowCredentials(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val ALLOWED_ORIGINS = listOf(
|
||||||
"http://localhost:8888",
|
"http://localhost:8888",
|
||||||
"https://creator.sodalive.net",
|
"https://creator.sodalive.net",
|
||||||
"https://test-creator.sodalive.net",
|
"https://test-creator.sodalive.net",
|
||||||
"https://test-admin.sodalive.net",
|
"https://test-admin.sodalive.net",
|
||||||
"https://admin.sodalive.net"
|
"https://admin.sodalive.net"
|
||||||
)
|
)
|
||||||
.allowedMethods("*")
|
|
||||||
.allowCredentials(true)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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("권한이 없습니다.")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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("로그인 정보를 확인해주세요.")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -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?
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>?
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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}"""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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}"""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user