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

@@ -18,6 +18,7 @@
- 메뉴는 클라이언트의 typed static configuration이 소유한다. Backend의 기존 `GET /menu` 및 메뉴 코드는 수정하지 않는다.
- Kotlin package는 v2지만 HTTP base path는 `/admin/ai-characters`다. `/v2`, `/admin/v2`, `/v2/admin` prefix를 추가하지 않는다.
- 신규 웹 Operation은 PRD에 명시된 66개다. 기존 `PUT /audio-content/upload-complete`는 호환 계약이므로 신규 Operation 수에 포함하지 않는다.
- 기존 `PUT /audio-content/upload-complete`는 인증 정보 없음·유효하지 않은 JWT에 `401`, 인증됐지만 `ADMIN`/`BOT`이 아닌 역할에 `403`을 반환한다. 기존 Request와 `ADMIN`/`BOT` 성공 Response는 유지한다.
- `ORIGINAL-WORK-01`~`ORIGINAL-WORK-08``/admin/ai-characters/original-works`의 global 원작 CRUD·검색·캐릭터 배정 계약이다. legacy `/admin/chat/original/**`의 Method·Path·Request·성공 Response와 일반 사용자용 `/api/chat/original/**`는 호환을 위해 유지한다.
- `CONTENT-08`이라는 신규 Endpoint, V1/V2 dispatcher와 v2 completion use case를 만들지 않는다. v2 콘텐츠도 기존 row·S3 계약을 따라 현재 callback이 동일하게 처리한다.
- AWS S3 Trigger worker 코드, worker 스케줄, metadata 계약 및 AWS Trigger 설정은 생성·수정하지 않는다.
@@ -212,24 +213,25 @@ Phase 0 기존 callback·소비자 계약 고정
### Phase 0: 기존 callback·소비자 계약 고정
- [ ] **Task 0.1: 변경 전 callback·소비자 API 계약을 회귀 테스트로 고정**
- [x] **Task 0.1: 변경 전 callback·소비자 API 계약을 회귀 테스트로 고정**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentUploadCompletionContractTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterControllerTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkControllerContractTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkControllerContractTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/legacy/LegacyAdminSearchQueryContractTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt`
- RED: TDD 예외 사유: 변경 대상이 아닌 기존 callback·소비자 계약을 characterization test로 고정하는 작업이므로 의도적인 production 결함을 먼저 만들지 않는다.
- 대체 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.chat.original.controller.OriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest`를 실행해 기존 `PUT /audio-content/upload-complete`, legacy 캐릭터·원작 관리자, 일반 사용자용 원작 API, BOT/ADMIN 인가, Request/Response 소비자 API 계약 중 현재 구현과 어긋난 지점이 있으면 먼저 조사하고, 모두 일치하면 최초 통과 결과를 baseline으로 기록한다. legacy 원작 mutation은 이후 같은 v2 정책으로 수렴하므로 이 Task에서는 Method·Path·Request·성공 Response를 고정하고 잘못된 mutation을 성공시키는 내부 동작을 호환 계약으로 고정하지 않는다.
- 대체 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.chat.original.controller.OriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.legacy.LegacyAdminSearchQueryContractTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest`를 실행해 callback의 Method·Path·Request·`ADMIN`/`BOT` 성공 Response·인가 status, legacy 캐릭터·원작 관리자의 Method·Path·Request·성공 Response, 일반 사용자용 원작 API와 소비자 API 계약 중 현재 구현과 어긋난 지점이 있으면 먼저 조사하고, 모두 일치하면 최초 통과 결과를 baseline으로 기록한다. callback은 인증 정보 없음·유효하지 않은 JWT `401`, 인증됐지만 `ADMIN`/`BOT`이 아닌 역할 `403`을 고정한다. legacy 검색은 실제 repository/service로 검색 field, 활성·삭제 제외, 정렬과 pagination을 고정한다. legacy 원작 mutation은 이후 같은 v2 정책으로 수렴하므로 이 Task에서는 Method·Path·Request·성공 Response를 고정하고 잘못된 mutation을 성공시키는 내부 동작을 호환 계약으로 고정하지 않는다.
- GREEN: 이 Task에서는 신규 route를 구현하지 않는다. callback과 기존 소비자 API가 현재 상태에서 통과하는지 baseline을 기록한다.
- REFACTOR: 테스트 fixture는 실제 Spring mapping과 응답 surface를 검증하며 운영 코드를 위한 범용 endpoint registry를 만들지 않는다.
- 기대 결과: 기존 callback, legacy 캐릭터·원작 관리자와 소비자 API 계약이 초록색 baseline으로 고정된다.
### Phase 1: 공통 관리자 계약, 보안, 대상 해석
- [ ] **Task 1.1: 공통 page/응답/multipart JSON 계약 구현**
- [x] **Task 1.1: 공통 page/응답/multipart JSON 계약 구현**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminCommonDtos.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicy.kt`
@@ -238,30 +240,34 @@ Phase 0 기존 callback·소비자 계약 고정
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicyTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminJsonRequestParserTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminImagePartValidatorTest.kt`
- RED: page `null/-1/0`, size `null/0/1/20/50/51`, page response `hasNext`, multipart JSON과 일반 JSON body의 필수 key 누락/명시적 `null` 구분, 이미지 bytes의 실제 MIME과 `allowGif` 조건 테스트한다.
- RED: page `null/-1/0`, size `null/0/1/20/50/51`, page response `hasNext`, multipart JSON과 일반 JSON body의 필수 key 누락/명시적 `null` 구분, 빈 multipart JSON과 단일 root 뒤의 추가 root·garbage 거부, 이미지 bytes의 실제 MIME `image/jpeg`, `image/png`, `image/gif`, 10MB·한 변 20,000px·총 40,000,000 pixels 초과 거부를 테스트한다. PNG는 ancillary payload 합계 1MB·chunk 4,096개 상한과 `ignoreMetadata=true`를 검증한다. GIF는 최대 500 frame, extension 1,024개, extension당 sub-block 64개, extension payload 합계 1MB, 전체 frame 누적 40,000,000 pixels 상한을 검증하고, logical canvas·모든 frame header의 동일 상한, 선언 pixel 수와 정확히 일치하는 LZW 출력, 조기 EOI·연속 clear·EOI 뒤 data와 첫 frame이 정상이지만 후속 frame decode가 손상된 입력 거부를 고정한다. 실제 format 확인 시 모든 GIF frame을 각각 1x1 출력 영역으로 decode하는지와 `allowGif` 조건 테스트한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AdminPagePolicyTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest`
- 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다.
- GREEN: `AdminPageResponse`, 공통 mutation/comment request와 PRD의 정규화 규칙만 구현한다. `AdminJsonRequestParser`는 multipart JSON string과 `JsonNode` 모두에서 required nullable key를 검증하고, `AdminImagePartValidator`는 v2 web adapter에서 실제 MIME을 검사한다. domain port에는 admin DTO나 Spring `Pageable`을 넘기지 않고 정규화된 offset/limit을 전달한다.
- GREEN: `AdminPageResponse`, 공통 mutation/comment request와 PRD의 정규화 규칙만 구현한다. `AdminJsonRequestParser`는 multipart JSON string과 `JsonNode` 모두에서 required nullable key를 검증하고, multipart JSON string은 parser 전용 strict reader로 단일 root만 허용한다. `AdminImagePartValidator`는 v2 web adapter에서 실제 MIME을 검사한다. PNG ImageIO 입력은 `ignoreMetadata=true`로 metadata를 읽지 않고, container preflight에서 ancillary payload 합계 1MB와 chunk 4,096개를 제한한다. GIF preflight는 최대 500 frame, extension 1,024개, extension당 sub-block 64개, extension payload 합계 1MB, 전체 frame 누적 40,000,000 pixels를 제한하고 각 frame LZW 출력이 선언 pixel 수와 정확히 일치하는지 bounded code parser로 확인한다. 실제 format 확인 decode는 모든 frame의 1x1 출력 영역으로 제한하고, 입력 유래 decoder 예외를 잘못된 이미지로 처리한다. domain port에는 admin DTO나 Spring `Pageable`을 넘기지 않고 정규화된 offset/limit을 전달한다.
- REFACTOR: 기존 `CreatorChannel*QueryPolicy`는 size 1~19 처리 계약이 다르므로 수정하거나 재사용하지 않는다.
- 기대 결과: 모든 관리자 목록과 multipart update가 하나의 명시적 계약을 사용한다.
- 기대 결과: 모든 관리자 목록과 multipart update가 하나의 명시적 계약을 사용하고, image part는 bytes·container 구조·metadata·frame·LZW 작업량의 bounded 안전 경계를 공유한다.
- [ ] **Task 1.2: `/admin/ai-characters/**` 전용 오류·인증 응답 경계 구현**
- [x] **Task 1.2: `/admin/ai-characters/**` 전용 오류·인증 응답 경계 구현**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/common/SodaException.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandler.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAuthenticationEntryPoint.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAccessDeniedHandler.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminSecurityIntegrationTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminLoginJwtIntegrationTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandlerTest.kt`
- RED: 기존 `POST /admin/member/login` 응답 token으로 신규 API 호출 성공, 무JWT 401, 잘못된 JWT 401, `USER/CREATOR/AGENT/CONTENT_MANAGER` 403, ADMIN 통과 및 400/404/409/500/502별 `ApiResponse` body와 `errorProperty`를 검증한다. 같은 예외가 legacy route에서는 기존 HTTP 200 관례를 유지하는 회귀 케이스도 추가한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminSecurityIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminExceptionHandlerTest`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/legacy/LegacySodaExceptionHttpStatusContractTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberLoginServiceTest.kt`
- RED: 기존 `POST /admin/member/login` 응답 token으로 신규 API 호출 성공, 무JWT 401, 잘못된 JWT 401, `USER/CREATOR/AGENT/CONTENT_MANAGER` 403, ADMIN 통과 및 400/404/409/500/502별 `ApiResponse` body와 `errorProperty`를 검증한다. 실제 Spring Boot context에서 두 production advice와 `SecurityConfig`를 함께 로드하고, DispatcherServlet에 연결된 test `MultipartResolver`가 handler 선택 전에 실패할 때 admin 경로는 400, legacy 경로는 기존 HTTP 200과 unknown 메시지를 유지하는지도 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminSecurityIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminLoginJwtIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminExceptionHandlerTest --tests kr.co.vividnext.sodalive.legacy.LegacySodaExceptionHttpStatusContractTest --tests kr.co.vividnext.sodalive.admin.member.AdminMemberLoginServiceTest`
- 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다.
- GREEN: `SodaException` 생성자 끝에 legacy 기본 동작을 보존하는 선택 HTTP status를 추가하고, 우선순위가 높은 admin Controller 범위 advice와 path-specific security handler만 그 status를 응답에 사용한다.
- REFACTOR: 기존 `SodaExceptionHandler`, `JwtAuthenticationEntryPoint`, `JwtAccessDeniedHandler`의 응답을 변경하지 않는다.
- 기대 결과: 신규 관리자 API만 PRD 20장의 status/envelope를 사용하고 legacy API는 영향받지 않는다.
- [ ] **Task 1.3: AI 캐릭터 관리자 대상 해석과 owner 입력 차단 구현**
- [x] **Task 1.3: AI 캐릭터 관리자 대상 해석과 owner 입력 차단 구현**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterAdminTarget.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterAdminTargetResolver.kt`
@@ -276,17 +282,17 @@ Phase 0 기존 callback·소비자 계약 고정
- REFACTOR: JPA `ChatCharacter`, `Member`, Q type은 persistence adapter 밖으로 노출하지 않는다.
- 기대 결과: 모든 character-scoped facade가 동일한 대상 해석 결과를 사용한다.
- [ ] **Task 1.4: direct after-commit 실행과 구조화 관리자 audit 기반 구현**
- [x] **Task 1.4: direct after-commit 실행과 구조화 관리자 audit 기반 구현**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutor.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLogger.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutorTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitEventBoundaryIntegrationTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLoggerTest.kt`
- RED: direct callback의 commit 후 1회 실행, rollback/동일 command 재시도에서 미실행·비중복, transaction 안에서 publish한 기존 FCM/언어 event listener가 commit 후 실행되고 유실되지 않는지, mutation 성공/실패 audit field와 민감 본문 미기록을 검증한다. global 원작 CRUD는 nullable character field를 허용하고 원작 배정·해제는 캐릭터별 context를 요구하는지도 고정한다.
- RED: direct callback의 commit 후 1회 실행과 rollback 미실행을 검증하고, 실제 `TransactionTemplate`에서 첫 attempt가 rollback동일 command 재시도해 commit하면 callback이 두 attempt 합계 1회 실행되는 기존 동작을 characterization으로 고정한다. `AfterCommitExecutor`에는 command identity가 없으므로 attempt 간 dedup은 요구하지 않는다. 기존 `FcmEvent`는 commit 후, `LanguageDetectEvent`는 commit 후·rollback 시, `LanguageTranslationEvent(waitTransactionCommit=true)`는 commit 후 listener 동작을 각각 검증하고 세 listener의 `AFTER_COMMIT` 선언도 고정한다. mutation 성공/실패 audit field와 민감 본문 미기록을 검증한다. global 원작 context는 `ORIGINAL_WORK``CREATE/UPDATE/DELETE`만, 원작 배정·해제는 캐릭터별 `ORIGINAL_WORK_CHARACTER` context만 허용한다. character-scoped context의 `creatorMemberId``UNASSIGN`에서만 nullable로 두되, factory가 정상/과거 불일치 해제 타입을 구분하지 않는다. factory 검증을 우회하는 public `copy`도 노출하지 않는다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest`
- 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다.
- GREEN: `AfterCommitExecutor`는 home news 같은 direct callback만 Spring transaction synchronization에 등록한다. 기존 `FcmEvent`, `LanguageDetectEvent`, `LanguageTranslationEvent(waitTransactionCommit=true)`는 transaction 안에서 publish하고 각 listener의 AFTER_COMMIT 경계를 유지한다. structured audit logger는 global/character-scoped context를 명시적으로 구분하는 정도로만 추가하며 audit table이나 AOP framework 만들지 않는다.
- GREEN: `AfterCommitExecutor`는 home news 같은 direct callback만 Spring transaction synchronization에 등록한다. 기존 `FcmEvent`, `LanguageDetectEvent`, `LanguageTranslationEvent(waitTransactionCommit=true)`는 transaction 안에서 publish하고 각 listener의 AFTER_COMMIT 경계를 유지한다. structured audit logger는 private factory로 global/character-scoped context와 action/resource/creator 조합을 강제하되 정상/과거 불일치 해제용 별도 command나 factory를 만들지 않으며, audit table이나 AOP framework 만들지 않는다.
- REFACTOR: 비밀번호, JWT, system prompt 전체, 댓글/게시글 본문, 업로드 파일 내용이 logger argument에 들어갈 수 없도록 audit context를 ID와 enum 중심으로 제한한다.
- 기대 결과: 이후 facade와 event adapter가 같은 commit/audit 원칙을 반복 구현하지 않는다.
@@ -402,7 +408,7 @@ Phase 0 기존 callback·소비자 계약 고정
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkControllerContractTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/osiv/OsivLazyLoadingRegressionTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt`
- RED: PRD 11.7의 8개 exact method/path, Query, 전체 envelope JSON field·nullable·UTC, 생성/수정 multipart part와 11개 key, 이미지 실제 MIME/GIF 거부, DELETE JSON body와 proxy 통과, ADMIN 인가와 정확한 400/404/409/500/502를 MockMvc로 고정한다. assignment response의 요청 순서 전체 ID와 작업 후 전체 `characterCount`, 같은 원작 멱등 ID, nullable `creatorId`, global CRUD audit의 nullable character field와 배정·해제 캐릭터별 audit도 검증한다. legacy 원작 5개 mutation route는 Method·Path·Request·성공 `data=null`을 유지하면서 같은 v2 원작 input port를 호출하고, legacy update의 null은 잠금 transaction 안에서 현재 값 유지로 병합되며 연결 삭제·부분 배정·오귀속 해제는 더 이상 성공하지 않는지 확인한다.
- RED: PRD 11.7의 8개 exact method/path, Query, 전체 envelope JSON field·nullable·UTC, 생성/수정 multipart part와 11개 key, 이미지 실제 MIME/GIF 거부, DELETE JSON body와 proxy 통과, ADMIN 인가와 정확한 400/404/409/500/502를 MockMvc로 고정한다. assignment response의 요청 순서 전체 ID와 작업 후 전체 `characterCount`, 같은 원작 멱등 ID, nullable `creatorId`, global CRUD audit의 nullable character field와 배정·해제 캐릭터별 audit도 검증한다. 해제 audit은 캐릭터의 `creatorMemberId`를 확인할 수 있으면 실제 값을 기록하고, 연결 정보가 없거나 해석할 수 없는 과거 불일치 상태에서만 `null`을 기록한다. legacy 원작 5개 mutation route는 Method·Path·Request·성공 `data=null`을 유지하면서 같은 v2 원작 input port를 호출하고, legacy update의 null은 잠금 transaction 안에서 현재 값 유지로 병합되며 연결 삭제·부분 배정·오귀속 해제는 더 이상 성공하지 않는지 확인한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminOriginalWorkControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest --tests kr.co.vividnext.sodalive.admin.chat.original.LegacyOriginalWorkMutationAdapterTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.osiv.OsivLazyLoadingRegressionTest`
- 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다.
- GREEN: 신규 Controller는 `AdminJsonRequestParser`, `AdminImagePartValidator`와 실제 `adminMemberId`만 사용하고 facade는 원작 use case 호출, Response 변환과 audit만 담당한다. `ORIGINAL-WORK-08``Content-Type: application/json` DELETE body를 명시적으로 매핑한다. legacy 원작 compatibility adapter는 legacy DTO와 multipart의 non-null update field만 v2 호환 patch command로 변환한다. 현재 값 병합은 adapter 선조회가 아니라 v2 application이 원작 row를 잠근 transaction 안에서 수행한다. 원작 조회는 기존 service를 유지하고 legacy 원작 5개 direct mutation만 v2 input으로 수렴한다. legacy 캐릭터가 아직 호출하는 `assignOneCharacter`는 Phase 10의 전체 캐릭터 호환 전환 전까지만 남기고, 나머지 미사용 direct mutation 메서드와 그로 인해 불필요해진 dependency만 제거해 OSIV 회귀 fixture의 constructor를 맞춘다.
@@ -433,10 +439,10 @@ Phase 0 기존 callback·소비자 계약 고정
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentUploadCompletionContractTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentReleaseQueryTest.kt`
- RED: 기존 callback의 method/path/BOT·ADMIN/Request/`data={}`는 그대로인 상태에서 v2 생성과 동일한 기존 row가 정상 완료되는지 검증한다. 처리 중 삭제된 `releaseDate=null` row와 비활성 creator row는 callback이 output path·duration을 기록해도 raw `content.isActive=false`와 공개 FCM/home news 0회를 유지해야 한다. 비활성 creator의 과거 불일치 row가 raw `content.isActive=true`이면 callback 후 `false`로 보정한다. 활성 creator의 즉시 공개는 최초 false→true에서만 공개 side effect를 내며 동일 callback 재시도는 이를 중복하지 않아야 한다. 예약 공개 query는 `isActive=false`, non-null due releaseDate, non-null duration, 활성 creator를 모두 만족하는 row만 반환하는지 검증한다.
- RED: 기존 callback의 method/path/Request와 `ADMIN`/`BOT` 성공 `data={}`는 그대로이고, 인증 정보 없음·유효하지 않은 JWT는 `401`, 인증됐지만 `ADMIN`/`BOT`이 아니면 `403`인 상태에서 v2 생성과 동일한 기존 row가 정상 완료되는지 검증한다. 처리 중 삭제된 `releaseDate=null` row와 비활성 creator row는 callback이 output path·duration을 기록해도 raw `content.isActive=false`와 공개 FCM/home news 0회를 유지해야 한다. 비활성 creator의 과거 불일치 row가 raw `content.isActive=true`이면 callback 후 `false`로 보정한다. 활성 creator의 즉시 공개는 최초 false→true에서만 공개 side effect를 내며 동일 callback 재시도는 이를 중복하지 않아야 한다. 예약 공개 query는 `isActive=false`, non-null due releaseDate, non-null duration, 활성 creator를 모두 만족하는 row만 반환하는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests kr.co.vividnext.sodalive.content.AudioContentReleaseQueryTest`
- 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다.
- GREEN: 기존 `AudioContentService.uploadComplete`의 공개 조건에 non-null releaseDate, 활성 creator와 최초 활성 전이를 추가하고 비활성 creator의 raw `content.isActive``false`로 유지·보정한다. 기존 release query에는 활성 creator 조건을 추가한다. Controller, Request/Response, scheduler component의 cron·lock은 변경하지 않는다.
- GREEN: 기존 `AudioContentService.uploadComplete`의 공개 조건에 non-null releaseDate, 활성 creator와 최초 활성 전이를 추가하고 비활성 creator의 raw `content.isActive``false`로 유지·보정한다. 기존 release query에는 활성 creator 조건을 추가한다. Controller의 Method·Path·Request와 `ADMIN`/`BOT` 성공 Response, scheduler component의 cron·lock은 변경하지 않는다.
- REFACTOR: 신규 callback Controller/DTO/use case, V1/V2 dispatcher, pipeline metadata와 v2 scheduler가 생기지 않았는지 diff를 확인한다.
- 기대 결과: 기존 callback과 scheduler를 모든 콘텐츠가 공용하면서 삭제 콘텐츠와 비활성 creator를 다시 공개하지 않는다.
@@ -860,7 +866,7 @@ Phase 0 기존 callback·소비자 계약 고정
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminInactiveStateEndToEndTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminAuditEndToEndTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt`
- RED: route inventory가 PRD의 method/path 66개와 중복·누락·추가 없이 정확히 일치하는지 먼저 검증한다. 이어서 리소스군마다 다른 character 소유 Path ID는 404, Request body의 잘못된 parent/root 귀속은 400과 해당 field, inactive character의 GET·DELETE 허용과 그 외 mutation 409, inactive parent mutation 409, 논리 리소스 DELETE retry 200, 실제 ADMIN과 대행 creator 구분, 모든 mutation success/failure audit를 parameterized E2E로 작성한다. global 원작은 character 선택 없이 접근되고, 0 이하·미존재·삭제 ID의 정확한 400/404/409, 연결 존재 삭제 409, 이미 삭제된 상태 우선의 반복 DELETE 200, 배정·해제 전건 검증·원자성, 다른 원작 이동과 audit nullable/캐릭터별 규칙도 포함한다. 같은 원작 재배정은 요청 순서 ID 전체를 반환하는 멱등 성공이고 이미 해제된 관계의 ORIGINAL-WORK-08 재시도는 귀속 불일치 400인지 구분한다. legacy 원작 mutation 5개와 캐릭터 mutation 2개 전체도 각각 같은 v2 원작·캐릭터 command를 사용하며 동시 삭제·배정에서 삭제 원작 참조를 만들지 않는지 포함한다.
- RED: route inventory가 PRD의 method/path 66개와 중복·누락·추가 없이 정확히 일치하는지 먼저 검증한다. 이어서 리소스군마다 다른 character 소유 Path ID는 404, Request body의 잘못된 parent/root 귀속은 400과 해당 field, inactive character의 GET·DELETE 허용과 그 외 mutation 409, inactive parent mutation 409, 논리 리소스 DELETE retry 200, 실제 ADMIN과 대행 creator 구분, 모든 mutation success/failure audit를 parameterized E2E로 작성한다. global 원작은 character 선택 없이 접근되고, 0 이하·미존재·삭제 ID의 정확한 400/404/409, 연결 존재 삭제 409, 이미 삭제된 상태 우선의 반복 DELETE 200, 배정·해제 전건 검증·원자성, 다른 원작 이동과 audit nullable/캐릭터별 규칙도 포함한다. 정상/과거 불일치 해제를 별도 command나 factory로 나누지 않되, 해제 audit은 확인 가능한 실제 `creatorMemberId`를 기록하고 연결 정보가 없거나 해석할 수 없을 때만 `null`을 기록한다. 같은 원작 재배정은 요청 순서 ID 전체를 반환하는 멱등 성공이고 이미 해제된 관계의 ORIGINAL-WORK-08 재시도는 귀속 불일치 400인지 구분한다. legacy 원작 mutation 5개와 캐릭터 mutation 2개 전체도 각각 같은 v2 원작·캐릭터 command를 사용하며 동시 삭제·배정에서 삭제 원작 참조를 만들지 않는지 포함한다.
- 실패 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdmin*EndToEndTest' --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest`
- 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다.
- GREEN: 누락된 owner/status/audit 연결만 각 facade/use case에 보완한다.
@@ -929,7 +935,7 @@ Phase 0 기존 callback·소비자 계약 고정
13. `awk '$0=="### 27.8 Copy-Paste Frontend Development Prompt"{section=1; next} section && $0=="```text"{capture=1} capture{print} capture && $0=="```"{exit}' docs/20260720_AI캐릭터_관리자기능/prd.md | shasum -a 256` 결과가 `5956ddc152c026937728381d625859bdea65b9a2f16a39b200f0d6b3660a73e1`인지 확인한다.
14. `find docs/20260720_AI캐릭터_관리자기능 -type f -name '*.sql' -print` 출력이 0건인지 확인한다.
15. `rg -o '^### Phase [0-9]+' docs/20260720_AI캐릭터_관리자기능/plan-task.md | wc -l` 결과가 12인지 확인한다.
16. `rg -o '^- \[ \] \*\*Task [0-9]+\.[0-9]+:' docs/20260720_AI캐릭터_관리자기능/plan-task.md | wc -l` 결과가 37인지 확인한다.
16. `rg -o '^- \[[ x]\] \*\*Task [0-9]+\.[0-9]+:' docs/20260720_AI캐릭터_관리자기능/plan-task.md | wc -l` 결과가 37인지 확인한다.
17. `rg -c '^ - 실패 확인:' docs/20260720_AI캐릭터_관리자기능/plan-task.md``rg -c '^ - 통과 확인:' docs/20260720_AI캐릭터_관리자기능/plan-task.md` 결과가 각각 35인지 확인한다.
18. `rg -c '^ - RED: TDD 예외' docs/20260720_AI캐릭터_관리자기능/plan-task.md` 결과가 2인지 확인한다.
19. `rg -n $'\t| +$' docs/20260720_AI캐릭터_관리자기능/prd.md docs/20260720_AI캐릭터_관리자기능/plan-task.md docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md` 출력이 0건인지 확인하고, 각 문서의 code fence 개수가 짝수인지 확인한다. untracked 문서는 `git diff --check`만으로 검사되지 않으므로 파일 자체 검사도 수행한다.
@@ -959,12 +965,36 @@ Phase 0 기존 callback·소비자 계약 고정
## 5. 구현 시 검증 기록
이 문서를 생성한 현재는 구현 전이므로 Task checkbox를 모두 미체크로 유지한다. 구현 에이전트는 각 Task에서 실제로 실행한 명령, RED 실패 원인, GREEN 성공 결과와 미실행 외부 Gate를 이 절에 누적한다.
구현 에이전트는 각 Task에서 실제로 실행한 명령, RED 실패 원인, GREEN 성공 결과와 미실행 외부 Gate를 이 절에 누적한다. Phase 0/1은 완료했고 Phase 2 이후 구현 Task는 미착수 상태로 유지한다.
- 문서 생성 검증: 아래 “문서 자체 검증 기록”에만 기록한다.
- 코드 구현 검증: 아직 실행하지 않음.
- 코드 구현 검증: Phase 0/1 구현 중 아래 명령을 실행해 `BUILD SUCCESSFUL`을 확인했다.
- RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AdminPagePolicyTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterAdminTargetResolverTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest`가 미구현 클래스 참조로 실패했다.
- 리뷰 4차 RED 확인: parser trailing root·garbage 2건, 이미지 전체 decode·dimension·pixel 3건, audit invalid 조합 3건과 public `copy` 1건이 각각 기존 구현에서 예상한 이유로 실패했다. Phase 0 legacy fixed-wire 보강과 rollback→retry는 기존 동작을 고정하는 characterization test라 production 변경 없이 통과했다.
- 리뷰 5차 RED 확인: malformed GIF의 `IndexOutOfBoundsException`·입력 유래 `IllegalArgumentException`, 20,001px logical canvas, 40MP 초과 후속 frame 4건과 character-scoped `ORIGINAL_WORK`, `UNASSIGN``creatorMemberId=null` audit 2건이 기존 구현에서 예상한 이유로 실패했다. 빈 multipart JSON, 실제 legacy 검색 query, multipart downstream 전달 보강은 기존 동작을 고정하는 characterization test로 추가했다.
- Phase 0 baseline: `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.chat.original.controller.OriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest` 통과.
- Phase 1 GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AdminPagePolicyTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminSecurityIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminLoginJwtIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminExceptionHandlerTest --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterAdminTargetResolverTest --tests kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.persistence.DefaultAiCharacterPersistenceAdapterTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest` 통과. 이 명령의 `AdminImagePartValidatorTest`는 10MB 초과 거부와 JPEG/PNG/GIF 실제 MIME·GIF 허용 조건을 고정한다.
- Phase 0+1 통합 targeted: 위 Phase 0/1 대상 테스트 전체를 한 Gradle 명령으로 실행해 통과.
- 리뷰 보강 targeted: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminExceptionHandlerTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminSecurityIntegrationTest --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterAdminTargetResolverTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest` 통과. 이 보강은 legacy 캐릭터·원작 관리자 mutation 성공 `ApiResponse` surface, `/admin/ai-characters` segment 경계, exception handler status 행렬, target resolver 404/409 행렬, 기존 event listener AFTER_COMMIT 경계, audit `PIN` action을 포함한다.
- 리뷰 2차 보강 targeted: `./gradlew test --tests 'kr.co.vividnext.sodalive.admin.member.AdminMemberLoginServiceTest' --tests 'kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest' --tests 'kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest' --tests 'kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest'` 통과. 이 보강은 `/admin/member/login`이 생성한 token의 기존 `TokenProvider` 검증·인증 복원, legacy 캐릭터·원작 mutation의 `data` 부재 계약, 일반 event publish의 commit/rollback transactional listener 경계를 포함한다.
- 리뷰 3차 보강 targeted: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest`, `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest`, `./gradlew test --tests kr.co.vividnext.sodalive.chat.original.controller.OriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminLoginJwtIntegrationTest` 통과. 이 보강은 실제 FCM event listener의 commit, 언어 감지 listener의 commit/rollback, `waitTransactionCommit=true` 언어 번역 listener의 commit과 세 listener의 `AFTER_COMMIT` 선언, upload-complete production `SecurityConfig`의 ADMIN/BOT JWT 허용·USER 403·익명 401, 일반 원작·legacy 원작·legacy 캐릭터 응답 field, CONTENT_MANAGER 실제 로그인 JWT의 신규 관리자 API 403을 포함한다.
- Task 1.4 retry characterization targeted: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest` 통과. 실제 `TransactionTemplate`에서 첫 attempt rollback 후 동일 command를 재시도해 commit하면 direct callback은 두 attempt 합계 1회 실행됐고, 새 테스트가 production `AfterCommitExecutor` 변경 없이 통과해 현 구현의 회귀 계약으로 기록했다.
- 리뷰 4차 수정 targeted: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.chat.original.controller.OriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest` 통과. production 원작 공개 matcher, legacy 캐릭터·원작 검색과 캐릭터 multipart 전체 wire, trailing JSON 거부, 이미지 decode·dimension·pixel 경계, audit action/resource/creator 조합과 public `copy` 부재, rollback 후 direct callback 재시도를 함께 검증했다.
- 리뷰 5차 수정 targeted: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.chat.original.controller.OriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.legacy.LegacyAdminSearchQueryContractTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest` 통과. GIF logical canvas·모든 frame header와 malformed decoder 경계, audit global/character creator 불변식, 실제 JPA legacy 검색 field·상태·정렬·pagination, 캐릭터 외부 API exact method/path/JSON 및 원작 update DTO 전달을 함께 검증했다.
- 리뷰 6차 RED 확인: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest`에서 첫 frame은 정상이지만 LZW 데이터가 손상된 두 번째 frame을 기존 구현이 decode하지 않아 예외가 발생하지 않았고, 16건 중 신규 테스트 1건이 `AssertionFailedError`로 실패했다.
- 리뷰 6차 수정 targeted: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest` 통과. 33건의 실패·오류가 0건이며, GIF 모든 frame의 1x1 decode, callback의 `ADMIN`/`BOT` 성공·`USER` 403·유효하지 않은 JWT와 익명 401, 해제 audit factory의 nullable 경계를 함께 검증했다.
- Full regression: `./gradlew --no-daemon test` 통과. 리뷰 6차 GIF 구현 후 재실행도 `BUILD SUCCESSFUL in 4m 26s`로 통과했다.
- Lint: `./gradlew --no-daemon ktlintCheck` 통과. 리뷰 6차 최종 Kotlin 변경 후 재실행도 `BUILD SUCCESSFUL in 26s`로 통과했다.
- 리뷰 7차 RED 확인: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest`에서 PNG ancillary chunk와 GIF extension payload가 ImageIO custom provider까지 도달해 신규 preflight 테스트 2건이 실패했다. `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.legacy.LegacySodaExceptionHttpStatusContractTest`는 legacy malformed multipart 메시지 계약 변경을 신규 테스트로 고정했다. Phase 1 누락 테스트는 hard-coded ID 전달, missing character, `creatorMember=null`, JsonNode missing-key 경계를 보강했다.
- 리뷰 7차 수정 targeted: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest`, `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.legacy.LegacySodaExceptionHttpStatusContractTest`, `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterAdminTargetResolverTest --tests kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.persistence.DefaultAiCharacterPersistenceAdapterTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest`가 각각 통과했다. 이어서 위 5개 테스트 class를 한 Gradle 명령으로 묶어 재실행해 `BUILD SUCCESSFUL in 1m 2s`를 확인했다.
- 리뷰 7차 최종 검증: reviewer gate의 PNG length overflow와 정상 metadata 오탐 지적을 반영한 뒤 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest``BUILD SUCCESSFUL in 30s`, `./gradlew --no-daemon ktlintCheck``BUILD SUCCESSFUL in 23s`, `./gradlew --no-daemon test``BUILD SUCCESSFUL in 7m 14s`로 통과했다. LSP는 기존과 동일하게 현재 환경에 `kotlin-ls`가 설치되어 있지 않아 실행하지 못했다.
- 리뷰 8차 RED 확인: PNG metadata reader flag, GIF extension 개수·sub-block 개수·누적 frame pixels·초과 LZW 출력 5건이 기존 구현에서 ImageIO 전에 거부되지 않아 `AdminImagePartValidatorTest`의 신규 assertion으로 실패했다.
- 리뷰 8차 수정 targeted: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminSecurityIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminLoginJwtIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminExceptionHandlerTest --tests kr.co.vividnext.sodalive.legacy.LegacySodaExceptionHttpStatusContractTest --tests kr.co.vividnext.sodalive.admin.member.AdminMemberLoginServiceTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AdminPagePolicyTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterAdminTargetResolverTest --tests kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.persistence.DefaultAiCharacterPersistenceAdapterTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest``BUILD SUCCESSFUL in 4m 51s`로 통과했다. 정확한 PNG ancillary/chunk, GIF frame/extension/sub-block/cumulative pixel 및 LZW 조기 종료·연속 clear·EOI 뒤 data 경계와 실제 Spring Boot DispatcherServlet multipart pre-handler admin 400/legacy 200 계약을 포함한다.
- 리뷰 8차 lint: `./gradlew --no-daemon ktlintCheck``BUILD SUCCESSFUL in 1m 3s`로 통과했다.
- 리뷰 8차 full regression: `./gradlew --no-daemon test``BUILD SUCCESSFUL in 14m 7s`로 통과했다.
- LSP: 현재 환경에 `kotlin-ls`가 설치되어 있지 않아 `lsp_diagnostics`는 실행하지 못했다.
- DB schema migration: 없음.
- 구현 기준 commit/기존 dirty·untracked manifest: 구현 시작 시 기록.
- 구현 기준 commit/기존 dirty·untracked manifest: 시작 기준 commit `5b700892`, 기존 dirty/untracked 없음.
- staging worker/Trigger E2E: 아직 실행하지 않음.
- production 배포: 이 계획의 코드 작성 단계만으로 완료 처리하지 않음.
@@ -986,4 +1016,6 @@ Phase 0 기존 callback·소비자 계약 고정
- 원작 보강 후 계약 검증: PRD의 JSON code block 51개와 Frontend delta의 label JSON 값 20개(Operation Request/Response 12개, 공통 오류 3개, nullable 값 5개)가 모두 유효하다. PRD와 Frontend delta의 원작 `(Operation ID, Method, Path)`는 8/8, Operation Request/Response JSON의 key·value·배열·null 구조는 12/12 exact equality다. 원작 DTO nullable·배정 응답 의미, legacy mutation 5개와 캐릭터 mutation 2개의 v2 수렴, 잠금·보상·오류 경계는 PRD와 plan에서 일치한다.
- 원작 보강 후 Markdown/DB 검증: 세 문서의 code fence 수가 각각 146개, 2개, 2개로 짝이 맞고 trailing whitespace·tab과 SQL 파일은 0건이다.
- 원작 보강 후 Gradle 구성 검증: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`이다.
- 리뷰 6차 문서·Gradle 구성 검증: `./gradlew --no-daemon tasks --all``BUILD SUCCESSFUL in 7s`, `git diff --check`와 세 문서의 trailing whitespace·tab 검사는 출력 0건이다. code fence 수는 각각 146개, 2개, 2개로 짝이 맞고 callback 전체 Response를 호환 대상으로 표현하는 잔여 문구도 0건이다.
- 리뷰 8차 문서 검증: Task 1.1/1.2 checkbox를 완료로 갱신했고 `git diff --check`, 세 문서의 trailing whitespace·tab 및 code fence 짝 검사를 실행해 이상 없음을 확인했다. `./gradlew tasks --all`은 기존 검증 결과를 유지하며, 이번 재실행은 Gradle wrapper lock 파일 권한 오류로 실행하지 못했다.
- 미실행 외부 Gate: 구현 전 문서 검증 단계이므로 실제 운영 데이터의 삭제 원작 연결 건수, MySQL 8 동시성, staging worker/Trigger E2E와 production 배포는 실행하지 않았다.

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 동작이 확인된다.