feat(ai-character): 관리자 기능 기반을 추가한다

This commit is contained in:
2026-07-22 01:43:08 +09:00
parent 5b700892c3
commit 3f4d7b237f
39 changed files with 5657 additions and 114 deletions

View File

@@ -1506,7 +1506,8 @@ Content-Type: application/json
}
```
- 위 Method, Path, 인증, Request와 Response 계약은 기존 AWS 연동 계약이며 이번 범위에서 변경하지 않는다.
- 위 Method, Path, Request와 `ADMIN`/`BOT`의 성공 Response 계약은 기존 AWS 연동 계약이며 이번 범위에서 변경하지 않는다.
- 인증 정보가 없거나 JWT가 유효하지 않으면 `401`, 인증됐지만 `ADMIN`/`BOT`이 아니면 `403`을 반환한다. 이 인가 status는 유지해야 하는 기존 보안 계약이다.
- 이 Endpoint는 기존 API이므로 신규 Operation ID를 부여하거나 12.1의 신규 Endpoint 수에 포함하지 않는다.
- v2 콘텐츠 생성도 기존과 같은 `content` 테이블 필드, S3 bucket의 `input/{contentId}/{contentId}-content-...` 경로와 object metadata 계약을 사용한다. object basename은 기존 `generateFileName(prefix = "${contentId}-content")` 규칙을 따르며 metadata는 기존 `generate_preview`, 선택 `preview_start_time`, `preview_end_time`만 전달한다.
- v2 생성 여부를 저장하는 DB 컬럼이나 worker metadata를 추가하지 않는다. 기존 callback Controller·Request·Response·Service 호출 흐름에도 V1/V2 dispatcher를 추가하지 않는다.
@@ -1970,7 +1971,7 @@ Content-Type: multipart/form-data
- `content`는 trim 후 빈 값일 수 없고 `price`는 0 이상이다.
- `price > 0`인 유료 게시글은 `postImage`가 필수다.
- `audioFile`을 보내는 게시글은 가격과 관계없이 `postImage`가 필수다.
- 업로드 이미지의 실제 MIME type은 `image/*`여야 하며 GIF는 유료 게시글에서만 허용한다.
- 업로드 이미지의 실제 MIME type은 `image/jpeg`, `image/png`, `image/gif` 중 하나여야 하며 GIF는 유료 게시글에서만 허용한다.
- `audioFile`은 빈 파일일 수 없다. v2 community web adapter가 파일명 확장자가 아니라 실제 bytes를 검사해 M4A/AAC 계열 MIME type인 `audio/mp4`, `audio/x-m4a`, `audio/aac`만 허용하고, 다른 codec이나 MIME type은 400으로 거부한다.
#### Response Data
@@ -2004,7 +2005,7 @@ Content-Type: multipart/form-data
`postId``isActive`는 body에서 받지 않는다.
`price``audioFile`은 등록 후 변경하거나 제거할 수 없다. 선택 `postImage`를 보내면 이미지를 교체하고, 생략하면 기존 이미지를 유지한다. 이미지 제거는 지원하지 않는다.
수정 시에도 `content`는 trim 후 빈 값일 수 없다. 교체 이미지는 실제 MIME type이 `image/*`여야 하며, 기존 게시글의 `price=0`이면 GIF를 허용하지 않는다. 기존 유료 또는 오디오 게시글은 이미지가 없는 상태로 변경할 수 없다.
수정 시에도 `content`는 trim 후 빈 값일 수 없다. 교체 이미지는 실제 MIME type이 `image/jpeg`, `image/png`, `image/gif` 중 하나여야 하며, 기존 게시글의 `price=0`이면 GIF를 허용하지 않는다. 기존 유료 또는 오디오 게시글은 이미지가 없는 상태로 변경할 수 없다.
#### Response Data
@@ -2658,7 +2659,7 @@ ADMIN JWT
이미 삭제된 원작의 반복 DELETE는 과거 불일치 연결이 남아 있어도 성공 200이므로 위 “연결 캐릭터가 남은 원작 삭제” 409보다 먼저 판정한다. 원작 이미지 보상 삭제가 실패해도 이미 발생한 로컬 transaction 실패의 500을 다른 성공이나 502로 바꾸지 않고 orphan 운영 로그를 남긴다.
기존 `/audio-content/upload-complete`의 인증·오류 응답 계약은 이번 범위에서 변경하지 않는다. 관리자 콘텐츠 조회에서 수행하는 canonical output key 검증은 기존 callback의 Request·Response 또는 오류 envelope를 변경하지 않는다.
기존 `/audio-content/upload-complete`는 인증 정보가 없거나 JWT가 유효하지 않으면 `401`, 인증됐지만 `ADMIN`/`BOT`이 아니면 `403`을 반환한다. 기존 Request와 `ADMIN`/`BOT` 성공 Response는 이번 범위에서 변경하지 않으며, 관리자 콘텐츠 조회에서 수행하는 canonical output key 검증 callback의 Request·성공 Response를 변경하지 않는다.
## 21. Technical Requirements
@@ -2792,7 +2793,7 @@ legacy Service를 호출하지 않더라도 다음 외부 관찰 가능 동작
- 외부 작업이나 보상 결과와 관계없이 DB 변경까지 완료되지 않은 요청은 성공으로 응답하지 않는다.
- 파일 업로드 실패 시 부분 DB 리소스를 성공으로 반환하지 않는다.
- v2 업로드 요청은 기존 worker가 이미 처리하는 S3 bucket, `input/{contentId}/{contentId}-content-...` key와 metadata 계약을 그대로 사용하며 callback URL, pipeline version 또는 새 분기 정보를 worker에 전달하지 않는다.
- 기존 `/audio-content/upload-complete`의 Method, Path, 인증과 Request/Response가 바뀌지 않는지 contract test로 검증한다.
- 기존 `/audio-content/upload-complete`의 Method, Path, Request`ADMIN`/`BOT` 성공 Response가 바뀌지 않고, 인증 정보 없음·유효하지 않은 JWT는 `401`, 인증됐지만 `ADMIN`/`BOT`이 아니면 `403`지 contract test로 검증한다.
- v2에서 생성한 기존 형식의 content row도 현재 `AudioContentService.uploadComplete`와 예약 공개 흐름이 처리하는지 통합 검증한다.
- callback은 `releaseDate=null` 또는 연결 creator 비활성인 콘텐츠를 공개하지 않고, 기존 예약 공개 query는 활성 creator만 선택하도록 최소 안전 조건을 보강한다. `AudioContentReleaseScheduledTask`의 cron·lock과 worker 코드는 수정하지 않는다.
- 캐릭터 삭제 시 raw `content.isActive=false`가 되므로 일반 사용자용 목록·검색·추천은 기존 공개 조건으로 이를 제외한다. 직접 상세 조회는 비활성 creator 또는 비활성 콘텐츠를 미구매 사용자에게 반환하지 않되, 기존 주문을 확인한 `KEEP`·`RENTAL` 구매자의 재생 경로는 유지한다.
@@ -2812,23 +2813,24 @@ legacy Service를 호출하지 않더라도 다음 외부 관찰 가능 동작
|---|---|
| `adminMemberId` | 실제 인증된 사람 관리자 |
| `characterId` | 선택 AI 캐릭터. global 원작 CRUD에서는 `null`, 배정·해제에서는 캐릭터별 로그에 값 기록 |
| `creatorMemberId` | 연결 크리에이터 Member. global 원작 CRUD에서는 `null`, 배정에서는 캐릭터별 값, legacy 불일치 캐릭터 해제에서는 `null` 가능 |
| `creatorMemberId` | 연결 크리에이터 Member. global 원작 CRUD에서는 `null`, 배정에서는 캐릭터별 값, 해제에서는 해당 Member ID를 확인할 수 있으면 값이고 과거 불일치로 연결 정보가 없거나 해석할 수 없을 때만 `null` 가능 |
| `action` | CREATE, UPDATE, DELETE, ASSIGN, UNASSIGN, PIN 등 |
| `resourceType` | CHARACTER, ORIGINAL_WORK, ORIGINAL_WORK_CHARACTER, CONTENT, CONTENT_COMMENT, CONTENT_CATEGORY, SERIES, COMMUNITY_POST, COMMUNITY_COMMENT, FAN_TALK, FAN_TALK_REPLY, CHANNEL_NOTICE, CHANNEL_PROFILE |
| `resourceId` | 변경 대상 ID |
| `result` | SUCCESS 또는 FAILURE |
원작 배정·해제는 변경 캐릭터마다 `resourceType=ORIGINAL_WORK_CHARACTER`, `resourceId=originalWorkId`, 해당 `characterId`와 가능한 경우 `creatorMemberId`를 남긴다. 원작 생성·수정·삭제는 `resourceType=ORIGINAL_WORK`이며 두 캐릭터 field가 `null`이다.
원작 배정·해제는 변경 캐릭터마다 `resourceType=ORIGINAL_WORK_CHARACTER`, `resourceId=originalWorkId`, 해당 `characterId`와 가능한 경우 `creatorMemberId`를 남긴다. 원작 생성·수정·삭제는 `resourceType=ORIGINAL_WORK`이며 두 캐릭터 field가 `null`이다. 정상 해제와 과거 불일치 해제를 별도 command나 audit factory로 구분하지 않는다. 해제 호출자는 캐릭터에서 `creatorMemberId`를 확인할 수 있으면 반드시 전달하고, 연결 정보가 없거나 해석할 수 없는 과거 불일치 상태에서만 `null`을 전달한다.
영속 audit table 도입은 이번 범위가 아니며 구조화 application log를 요구한다. 비밀번호, JWT, system prompt 전체, 댓글 본문 또는 업로드 파일 내용은 로그에 기록하지 않는다.
### 21.7 Security
- `/admin/ai-characters/**` Controller는 class level에서 `hasRole('ADMIN')`을 선언한다.
- 기존 `/audio-content/upload-complete``hasAnyRole('BOT', 'ADMIN')`과 외부 응답 계약은 변경하지 않는다.
- 기존 `/audio-content/upload-complete``hasAnyRole('BOT', 'ADMIN')`을 유지한다. 인증 정보가 없거나 JWT가 유효하지 않으면 `401`, 인증됐지만 `ADMIN`/`BOT`이 아니면 `403`이며, `ADMIN`/`BOT`의 기존 성공 응답 계약은 변경하지 않는다.
- `/admin/ai-characters/**` RequestMatcher에만 적용되는 authentication entry point, access-denied handler 및 exception response 경계를 두어 legacy API의 HTTP status를 변경하지 않고 20장의 status와 `ApiResponse` 계약을 보장한다.
- 클라이언트 메뉴·route guard 테스트와 Backend API 인가 테스트를 별도로 작성한다.
- Multipart 요청은 애플리케이션의 `max-file-size=1024MB`, `max-request-size=1024MB` 상한을 적용한다. 이미지 part는 공통 이미지 검증기를 v2 web adapter에서 사용해 실제 MIME type을 검증한다. GIF는 API에 별도 허용 조건이 있는 유료 커뮤니티 게시글 이미지만 허용하고, 원작·캐릭터·콘텐츠 커버·시리즈 이미지를 포함한 나머지 image part에서는 거부한다.
- Multipart JSON string part는 단일 JSON root만 허용하고 뒤에 이어진 추가 root나 garbage를 400으로 거부한다.
- Multipart 요청은 애플리케이션의 `max-file-size=1024MB`, `max-request-size=1024MB` 상한을 적용한다. 이미지 part는 공통 이미지 검증기를 v2 web adapter에서 사용해 bytes 적재 전에 10MB 초과를 거부하고, 실제 MIME type이 `image/jpeg`, `image/png`, `image/gif` 중 하나인지 검증한다. 실제 format 확인 decode는 출력 영역을 1x1로 제한하되 내부 decoder row/loop 때문에 한 변은 최대 20,000px, 총 픽셀은 최대 40,000,000 pixels로 제한한다. PNG는 ancillary payload 합계 1MB와 chunk 4,096개를 상한으로 두고 ImageIO 입력을 `ignoreMetadata=true`로 설정해 metadata를 읽지 않는다. GIF는 최대 500 frame, extension 1,024개, extension당 sub-block 64개, extension payload 합계 1MB, 모든 frame의 누적 40,000,000 pixels를 상한으로 둔다. GIF logical canvas와 모든 frame header에도 같은 dimension 상한을 적용하고 각 frame의 LZW 출력 pixel 수가 선언된 width와 height의 곱과 정확히 일치해야 한다. 조기 EOI·연속 clear·EOI 뒤 data가 있는 LZW와 첫 frame이 정상이지만 후속 frame decode가 손상된 입력은 거부한다. 첫 frame뿐 아니라 모든 frame을 각각 1x1 출력 영역으로 decode해 malformed header/frame을 400으로 거부한다. GIF는 API에 별도 허용 조건이 있는 유료 커뮤니티 게시글 이미지만 허용하고, 원작·캐릭터·콘텐츠 커버·시리즈 이미지를 포함한 나머지 image part에서는 거부한다.
- 콘텐츠 원본 오디오는 빈 파일을 거부하고 worker에 전달한다. 지원 codec과 재생 가능 여부는 worker가 검증하며 실패한 콘텐츠를 `PUBLISHED`로 전환하지 않는다.
- 관리자 응답에서 system prompt는 캐릭터 상세에만 포함하며 목록에는 포함하지 않는다.
- `CONTENT-01`, `CONTENT-02`는 Signed URL이 포함될 수 있으므로 `Cache-Control: private, no-store`를 반환한다.
@@ -2911,7 +2913,7 @@ legacy Service를 호출하지 않더라도 다음 외부 관찰 가능 동작
- [ ] 실제 관리자와 대행 AI 캐릭터를 구분하는 구조화 로그가 남는다.
- [ ] v2 비즈니스 로직과 persistence adapter가 legacy Controller, Service, Repository 또는 web DTO를 호출하지 않는다.
- [ ] 신규 upload-complete Endpoint를 만들지 않고 기존 AWS S3 Trigger worker의 코드·스케줄·설정을 변경하지 않는다.
- [ ] 기존 `/audio-content/upload-complete`의 Method, Path, 인증과 Request/Response가 그대로 유지된다.
- [ ] 기존 `/audio-content/upload-complete`의 Method, Path, Request`ADMIN`/`BOT` 성공 Response가 유지되고 인증 정보 없음·유효하지 않은 JWT는 `401`, 인증됐지만 `ADMIN`/`BOT`이 아니면 `403`을 반환한다.
- [ ] v2 콘텐츠 생성이 기존 `content` row와 S3 key·metadata 계약을 따르고 별도 V1/V2 callback 분기 없이 기존 callback과 예약 공개 흐름으로 처리된다.
- [ ] CONTENT-03 원본 object가 `input/{contentId}/{contentId}-content-...` key를 사용하고 worker 결과 basename이 기존 callback의 content ID 검증을 통과한다.
- [ ] staging에서 v2 S3 input 저장부터 기존 callback 완료까지 E2E가 통과하고 DB commit 전 callback 도착 시 worker retry 동작이 확인된다.