diff --git a/docs/20260724_AI캐릭터_관리자_API/api-contract.md b/docs/20260724_AI캐릭터_관리자_API/api-contract.md index 389d4f54..31aebd53 100644 --- a/docs/20260724_AI캐릭터_관리자_API/api-contract.md +++ b/docs/20260724_AI캐릭터_관리자_API/api-contract.md @@ -6,58 +6,88 @@ OpenAPI 3.1 JSON으로 고정한다. - 정식 계약: `api-contract.openapi.json` -- endpoint: 23개 -- 현재 route 구현: 9개 -- 현재 구현 중 계약 정합화 필요: 9개 -- 구현 예정: 14개 +- endpoint: 37개 +- 현재 route 구현: 37개 +- 현재 계약과 일치하는 구현 완료: 37개 +- 계약 정합화 필요: 0개 +- 구현 예정: 0개 - 공통 envelope: `ApiResponse` - 인증: JWT `ROLE_ADMIN`과 현재 DB `Member.role == ADMIN` 동시 충족 `x-implementation-status`의 의미는 다음과 같다. -- `implemented-contract-alignment-required`: route는 구현되어 있지만 2026-07-28 확정 레거시 JSON 계약과 runtime DTO가 - 아직 달라 `P23-CONTRACT-2`, `P23-CONTRACT-3`을 완료하기 전 호출 호환을 보장하지 않는다. -- `planned`: 계약만 고정되었고 route는 아직 구현되지 않았다. +- `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`만 request body에서 중복 제거한다. +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/{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` | -| 정합화 필요 | GET | `/api/v2/admin/ai-characters/{characterId}/audio-contents` | `search_word?`, `page`, `size` | `GetCreatorAdminContentListResponse` | -| 정합화 필요 | POST | `/api/v2/admin/ai-characters/{characterId}/audio-contents` | `CreateAudioContentRequest`, `contentFile`, `coverImage` | `CreateAudioContentResponse` | -| 정합화 필요 | GET | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` | 필수 `timezone` | `GetAudioContentDetailResponse` | -| 정합화 필요 | PUT | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` | `UpdateCreatorAdminContentRequest`에서 `id` 제외 | `null` | -| 계획 | 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 | `GetCreatorAdminContentSeriesDetailResponse` | -| 계획 | 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` | -| 계획 | DELETE | `/api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents/{contentId}` | body 없음 | `null` | -| 계획 | GET | `/api/v2/admin/ai-characters/{characterId}/community-posts` | 필수 `timezone`, `page`, `size` | `List` | -| 계획 | 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}/fan-talks` | 공개 v2 `page?`, `size?` | `CreatorChannelFanTalkTabResponse` | -| 계획 | POST | `/api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies` | `content` | 신규 축약 응답 | +| 구현 완료 | 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` | +| 구현 완료 | 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` | +| 구현 완료 | 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` | +| 구현 완료 | 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` | +| 구현 완료 | 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 해석 @@ -68,7 +98,8 @@ OpenAPI 3.1 JSON으로 고정한다. - request의 optional nullable 필드는 key 생략과 명시적 `null`을 모두 허용한다. - request는 `additionalProperties: false`이므로 정의되지 않은 이름은 계약 위반이다. - 모든 DB ID는 JSON integer, OpenAPI `int64`다. -- 레거시 날짜 문자열은 원래 format을 유지한다. UTC ISO-8601이 보장되는 FanTalk `createdAtUtc`만 `date-time`이다. +- 승인된 UTC 예외인 오디오 생성·상세 `releaseDate`, 오디오·커뮤니티 댓글 `date`, FanTalk `createdAtUtc`는 + OpenAPI `date-time`이다. 그 밖의 레거시 날짜 문자열은 기존 format을 유지한다. - enum은 대소문자를 구분하며 Kotlin enum 이름을 그대로 사용한다. - `Accept-Language`는 `ko`, `en`, `ja` 외 문자열도 전송할 수 있고 서버가 KO로 fallback하므로 enum으로 제한하지 않는다. @@ -99,17 +130,20 @@ multipart API의 `request` part는 schema상 JSON 객체다. `ChatCharacterListResponse`, `ChatCharacterListPageResponse`, `ChatCharacterSearchListPageResponse`, `ChatCharacterDetailResponse`, `RelationshipResponse`, `PersonalityResponse`, `BackgroundResponse`, - `MemoryResponse`, `OriginalWorkBriefResponse` + `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: - `CreateAudioContentRequest`, `UpdateCreatorAdminContentRequest` + `AudioContentCreateRequest`, `UpdateCreatorAdminContentRequest`, + `RegisterCommentRequest`, `ModifyCommentRequest` - response: `GetAudioContentThemeResponse`, `GetCreatorAdminContentListResponse`, `GetCreatorAdminContentListItem`, `CreateAudioContentResponse`, @@ -120,6 +154,16 @@ multipart API의 `request` part는 schema상 JSON 객체다. `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 @@ -129,27 +173,41 @@ multipart API의 `request` part는 schema상 JSON 객체다. `UpdateOrdersRequest` - response: `GetCreatorAdminContentSeriesListResponse`, - `GetCreatorAdminContentSeriesDetailResponse`, `GetCreatorAdminContentSeriesContentResponse`, - `SearchContentNotInSeriesResponse` + `SearchContentNotInSeriesResponse`, `GetSeriesGenreListResponse` - `publishedDaysOfWeek` enum은 `SUN..SAT`, `RANDOM`이며 state는 `PROCEEDING`, `SUSPEND`, `COMPLETE`다. -- 상세의 `publishedDaysOfWeek`, `genre`, `keywords`, `state`는 레거시처럼 문자열이다. -- 상세 `state`의 현재 관찰 값은 `연재중`, `휴재중`, `완결`이지만 DTO 타입이 `String`이므로 enum으로 제한하지 않는다. +- 상세 `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` + `UpdateCommunityPostFixedRequest`, `CreateCommunityPostCommentRequest`, + `ModifyCommunityPostCommentRequest` - response: - `GetCommunityPostListResponse`, `GetCommunityPostCommentListItem` -- 목록 `data`는 pagination wrapper가 아니라 직접 배열이다. + `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 @@ -159,8 +217,18 @@ multipart API의 `request` part는 schema상 JSON 객체다. - 공개 v2 endpoint를 직접 사용하지 않는다. 공개 v2는 `creatorId`, viewer 인증과 block filter, 다른 CORS 경계를 사용하기 때문이다. - 신규 관리자 목록은 `characterId`로 creator를 해석하고 공개 v2 response field만 유지한다. -- 답변 request는 path로 이동한 `creatorId`, `parentId`를 제외하고 `content`만 받는다. -- 답변 response는 사용자 승인 예외인 신규 축약 형태다. +- 답변 작성 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. 공통 응답과 오류 @@ -221,8 +289,7 @@ tsc --noEmit --target ES2020 --module commonjs --lib ES2020,DOM \ ``` `info-license`만 제외하는 이유는 저장소 라이선스 값을 추측해 계약에 추가하지 않기 위해서다. -생성 클라이언트에는 23개 operation이 모두 포함된다. `implemented-contract-alignment-required` operation은 해당 runtime -정합화 Gate가 끝나기 전 production 호출 대상으로 간주하지 않는다. +생성 클라이언트에는 계약의 37개 operation이 모두 포함된다. 37개 모두 실제 route가 구현되어 있고 `implemented` 상태다. OpenAPI의 `/` server URL은 현재 host를 의미하지만 OpenAPI Generator 7.24.0의 `typescript-fetch` runtime 기본값은 `http://localhost`다. 실제 클라이언트는 배포 환경의 API origin을 `new Configuration({ basePath: "..." })`로 반드시 diff --git a/docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json b/docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json index 46385e04..dcd25519 100644 --- a/docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json +++ b/docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json @@ -2,17 +2,17 @@ "openapi": "3.1.0", "info": { "title": "AI 캐릭터 관리자 API", - "version": "2.0.0", - "description": "클라이언트 개발용 정식 계약. 신규 관리자 endpoint와 path target을 사용하되 JSON 필드명, 타입, optional/nullable, 기본값과 성공 data 형태는 레거시 API를 유지한다. path로 이동한 ID만 body에서 제거한다." + "version": "2.3.0", + "description": "클라이언트 개발용 정식 계약. 신규 관리자 endpoint와 path target을 사용하되 JSON 필드명, 타입, optional/nullable, 기본값과 성공 data 형태는 승인된 예외 외에는 레거시 API를 유지한다. path로 이동한 ID만 body에서 제거한다. AI 캐릭터 관리자 API의 날짜·시간 예외 계약은 timezone query/body 없이 ISO-8601 UTC(Z)를 사용한다." }, "servers": [{"url": "/", "description": "현재 호스트"}], "security": [{"bearerAuth": []}], "tags": [ - {"name": "Character", "description": "AI 캐릭터 조회·생성·수정"}, - {"name": "AudioContent", "description": "오디오 콘텐츠 테마·조회·생성·수정"}, - {"name": "Series", "description": "시리즈와 연결 콘텐츠 관리"}, - {"name": "Community", "description": "커뮤니티 게시글 관리"}, - {"name": "FanTalk", "description": "FanTalk 관리자 목록과 creator reply"} + {"name": "Character", "description": "AI 캐릭터 조회·생성·수정과 등록용 원작 검색"}, + {"name": "AudioContent", "description": "오디오 콘텐츠 테마·조회·생성·수정과 댓글 관리"}, + {"name": "Series", "description": "시리즈·등록용 장르·연결 콘텐츠 관리"}, + {"name": "Community", "description": "커뮤니티 게시글과 댓글 관리"}, + {"name": "FanTalk", "description": "FanTalk 관리자 목록·creator reply 작성·수정·팬 원글 삭제"} ], "paths": { "/api/v2/admin/ai-characters": { @@ -21,7 +21,7 @@ "tags": ["Character"], "summary": "AI 캐릭터 목록/검색", "operationId": "listAiCharacters", - "x-implementation-status": "implemented-contract-alignment-required", + "x-implementation-status": "implemented", "x-legacy-sources": ["AdminChatCharacterController.getCharacterList", "AdminChatCharacterController.searchCharacters", "ChatCharacterListPageResponse", "ChatCharacterSearchListPageResponse"], "parameters": [ {"name": "searchTerm", "in": "query", "required": false, "description": "생략하면 활성 목록, 지정하면 레거시 검색을 수행한다.", "schema": {"type": "string"}}, @@ -43,7 +43,7 @@ "tags": ["Character"], "summary": "AI 캐릭터 생성", "operationId": "createAiCharacter", - "x-implementation-status": "implemented-contract-alignment-required", + "x-implementation-status": "implemented", "x-legacy-sources": ["AdminChatCharacterController.registerCharacter", "ChatCharacterRegisterRequest"], "requestBody": {"$ref": "#/components/requestBodies/CharacterCreateMultipart"}, "responses": { @@ -59,13 +59,36 @@ } } }, + "/api/v2/admin/ai-characters/original-works/search": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}], + "get": { + "tags": ["Character"], + "summary": "캐릭터 등록용 원작 검색", + "operationId": "searchAiCharacterOriginalWorks", + "x-implementation-status": "implemented", + "x-legacy-sources": ["AdminOriginalWorkController.search", "AdminOriginalWorkService.searchOriginalWorksAll", "OriginalWorkResponse"], + "parameters": [ + {"name": "searchTerm", "in": "query", "required": true, "description": "제목·콘텐츠 타입·카테고리 부분 검색어", "schema": {"type": "string"}} + ], + "responses": { + "200": {"$ref": "#/components/responses/OriginalWorkSearchSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, "/api/v2/admin/ai-characters/{characterId}": { "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}], "get": { "tags": ["Character"], "summary": "AI 캐릭터 상세", "operationId": "getAiCharacter", - "x-implementation-status": "implemented-contract-alignment-required", + "x-implementation-status": "implemented", "x-legacy-sources": ["AdminChatCharacterController.getCharacterDetail", "ChatCharacterDetailResponse"], "responses": { "200": {"$ref": "#/components/responses/CharacterDetailSuccess"}, @@ -83,7 +106,7 @@ "summary": "AI 캐릭터 수정/soft delete", "description": "레거시 ChatCharacterUpdateRequest의 id만 path characterId로 이동한다.", "operationId": "updateAiCharacter", - "x-implementation-status": "implemented-contract-alignment-required", + "x-implementation-status": "implemented", "x-legacy-sources": ["AdminChatCharacterController.updateCharacter", "ChatCharacterUpdateRequest"], "requestBody": {"$ref": "#/components/requestBodies/CharacterUpdateMultipart"}, "responses": { @@ -105,7 +128,7 @@ "tags": ["AudioContent"], "summary": "활성 오디오 콘텐츠 테마 목록", "operationId": "listAiCharacterAudioContentThemes", - "x-implementation-status": "implemented-contract-alignment-required", + "x-implementation-status": "implemented", "x-legacy-sources": ["GetAudioContentThemeResponse"], "responses": { "200": {"$ref": "#/components/responses/AudioThemeListSuccess"}, @@ -119,13 +142,13 @@ } } }, - "/api/v2/admin/ai-characters/{characterId}/audio-contents": { + "/api/v2/admin/ai-characters/{characterId}/audio-contents": { "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}], "get": { "tags": ["AudioContent"], "summary": "오디오 콘텐츠 목록/검색", "operationId": "listAiCharacterAudioContents", - "x-implementation-status": "implemented-contract-alignment-required", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorAdminContentController.getAudioContentList", "CreatorAdminContentController.searchAudioContent", "GetCreatorAdminContentListResponse"], "parameters": [ {"name": "search_word", "in": "query", "required": false, "description": "지정하면 레거시 검색을 수행하며 2자 이상이어야 한다.", "schema": {"type": "string"}}, @@ -143,11 +166,11 @@ "500": {"$ref": "#/components/responses/InternalServerError"} } }, - "post": { + "post": { "tags": ["AudioContent"], "summary": "오디오 콘텐츠 생성", "operationId": "createAiCharacterAudioContent", - "x-implementation-status": "implemented-contract-alignment-required", + "x-implementation-status": "implemented", "x-legacy-sources": ["AudioContentController.createAudioContent", "CreateAudioContentRequest", "CreateAudioContentResponse"], "requestBody": {"$ref": "#/components/requestBodies/AudioContentCreateMultipart"}, "responses": { @@ -163,15 +186,14 @@ } } }, - "/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}": { + "/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}": { "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}, {"$ref": "#/components/parameters/ContentId"}], - "get": { + "get": { "tags": ["AudioContent"], "summary": "오디오 콘텐츠 상세", "operationId": "getAiCharacterAudioContent", - "x-implementation-status": "implemented-contract-alignment-required", + "x-implementation-status": "implemented", "x-legacy-sources": ["AudioContentController.getDetail", "GetAudioContentDetailResponse"], - "parameters": [{"$ref": "#/components/parameters/Timezone"}], "responses": { "200": {"$ref": "#/components/responses/AudioContentDetailSuccess"}, "400": {"$ref": "#/components/responses/BadRequest"}, @@ -188,7 +210,7 @@ "summary": "오디오 콘텐츠 수정/soft delete", "description": "레거시 UpdateCreatorAdminContentRequest의 id만 path contentId로 이동한다.", "operationId": "updateAiCharacterAudioContent", - "x-implementation-status": "implemented-contract-alignment-required", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorAdminContentController.modifyAudioContent", "UpdateCreatorAdminContentRequest"], "requestBody": {"$ref": "#/components/requestBodies/AudioContentUpdateMultipart"}, "responses": { @@ -204,13 +226,161 @@ } } }, + "/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments": { + "parameters": [ + {"$ref": "#/components/parameters/AcceptLanguage"}, + {"$ref": "#/components/parameters/CharacterId"}, + {"$ref": "#/components/parameters/ContentId"} + ], + "get": { + "tags": ["AudioContent"], + "summary": "오디오 콘텐츠 댓글 목록", + "operationId": "listAiCharacterAudioContentComments", + "x-implementation-status": "implemented", + "x-legacy-sources": ["AudioContentCommentController.getCommentList", "GetAudioContentCommentListResponse"], + "parameters": [ + {"$ref": "#/components/parameters/Page"}, + {"$ref": "#/components/parameters/Size"} + ], + "responses": { + "200": {"$ref": "#/components/responses/AudioContentCommentListSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + }, + "post": { + "tags": ["AudioContent"], + "summary": "오디오 콘텐츠 댓글 또는 답글 작성", + "description": "parentId가 없으면 원댓글, 있으면 같은 콘텐츠의 활성 원댓글에 대한 답글을 target AI 캐릭터 명의로 작성한다.", + "operationId": "createAiCharacterAudioContentComment", + "x-implementation-status": "implemented", + "x-legacy-sources": ["AudioContentCommentController.registerComment", "RegisterCommentRequest"], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AudioContentCommentCreateRequest"}}} + }, + "responses": { + "200": {"$ref": "#/components/responses/NullSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, + "/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments/{commentId}": { + "parameters": [ + {"$ref": "#/components/parameters/AcceptLanguage"}, + {"$ref": "#/components/parameters/CharacterId"}, + {"$ref": "#/components/parameters/ContentId"}, + {"$ref": "#/components/parameters/CommentId"} + ], + "put": { + "tags": ["AudioContent"], + "summary": "AI 캐릭터 작성 오디오 콘텐츠 댓글 수정", + "operationId": "updateAiCharacterAudioContentComment", + "x-implementation-status": "implemented", + "x-legacy-sources": ["AudioContentCommentController.modifyComment", "ModifyCommentRequest"], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommentUpdateRequest"}}} + }, + "responses": { + "200": {"$ref": "#/components/responses/NullSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + }, + "delete": { + "tags": ["AudioContent"], + "summary": "오디오 콘텐츠 댓글 soft delete", + "description": "target 소유 콘텐츠의 댓글 또는 답글을 작성자와 관계없이 해당 row만 isActive=false로 변경한다. 이미 비활성이면 성공 no-op이며 하위 답글은 변경하지 않는다.", + "operationId": "deleteAiCharacterAudioContentComment", + "x-implementation-status": "implemented", + "x-legacy-sources": ["AudioContentCommentService.modifyComment", "ModifyCommentRequest.isActive"], + "responses": { + "200": {"$ref": "#/components/responses/NullSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, + "/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments/{commentId}/replies": { + "parameters": [ + {"$ref": "#/components/parameters/AcceptLanguage"}, + {"$ref": "#/components/parameters/CharacterId"}, + {"$ref": "#/components/parameters/ContentId"}, + {"$ref": "#/components/parameters/CommentId"} + ], + "get": { + "tags": ["AudioContent"], + "summary": "오디오 콘텐츠 댓글 답글 목록", + "operationId": "listAiCharacterAudioContentCommentReplies", + "x-implementation-status": "implemented", + "x-legacy-sources": ["AudioContentCommentController.getCommentReplyList", "GetAudioContentCommentListResponse"], + "parameters": [ + {"$ref": "#/components/parameters/Page"}, + {"$ref": "#/components/parameters/Size"} + ], + "responses": { + "200": {"$ref": "#/components/responses/AudioContentCommentListSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, + "/api/v2/admin/ai-characters/series-genres": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}], + "get": { + "tags": ["Series"], + "summary": "시리즈 등록용 활성 장르 목록", + "operationId": "listAiCharacterSeriesGenres", + "x-implementation-status": "implemented", + "x-legacy-sources": ["AdminContentSeriesGenreController.getSeriesGenreList", "GetSeriesGenreListResponse"], + "responses": { + "200": {"$ref": "#/components/responses/SeriesGenreListSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, "/api/v2/admin/ai-characters/{characterId}/series": { "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}], "get": { "tags": ["Series"], "summary": "시리즈 목록", "operationId": "listAiCharacterSeries", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorAdminContentSeriesController.getSeriesList", "GetCreatorAdminContentSeriesListResponse"], "parameters": [{"$ref": "#/components/parameters/Page"}, {"$ref": "#/components/parameters/Size"}], "responses": { @@ -228,7 +398,7 @@ "tags": ["Series"], "summary": "시리즈 생성", "operationId": "createAiCharacterSeries", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorAdminContentSeriesController.createSeries", "CreateSeriesRequest"], "requestBody": {"$ref": "#/components/requestBodies/SeriesCreateMultipart"}, "responses": { @@ -250,7 +420,7 @@ "tags": ["Series"], "summary": "시리즈 순서 변경", "operationId": "reorderAiCharacterSeries", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorAdminContentSeriesController.updateSeriesOrders", "UpdateOrdersRequest"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SeriesOrderUpdateRequest"}}}}, "responses": { @@ -271,9 +441,10 @@ "get": { "tags": ["Series"], "summary": "시리즈 상세", + "description": "성공 data는 시리즈 목록 items의 단일 항목과 동일한 schema를 사용한다.", "operationId": "getAiCharacterSeries", - "x-implementation-status": "planned", - "x-legacy-sources": ["CreatorAdminContentSeriesController.getDetail", "GetCreatorAdminContentSeriesDetailResponse"], + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorAdminContentSeriesController.getSeriesList", "GetCreatorAdminContentSeriesListItem"], "responses": { "200": {"$ref": "#/components/responses/SeriesDetailSuccess"}, "400": {"$ref": "#/components/responses/BadRequest"}, @@ -290,7 +461,7 @@ "summary": "시리즈 수정/soft delete", "description": "레거시 ModifySeriesRequest의 seriesId만 path로 이동한다.", "operationId": "updateAiCharacterSeries", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorAdminContentSeriesController.modifySeries", "ModifySeriesRequest"], "requestBody": {"$ref": "#/components/requestBodies/SeriesUpdateMultipart"}, "responses": { @@ -312,7 +483,7 @@ "tags": ["Series"], "summary": "시리즈 연결 콘텐츠 목록", "operationId": "listAiCharacterSeriesContents", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorAdminContentSeriesController.getSeriesContent", "GetCreatorAdminContentSeriesContentResponse"], "parameters": [{"$ref": "#/components/parameters/Page"}, {"$ref": "#/components/parameters/Size"}], "responses": { @@ -331,7 +502,7 @@ "summary": "시리즈 콘텐츠 연결", "description": "레거시 AddingContentToTheSeriesRequest의 seriesId만 path로 이동한다.", "operationId": "addAiCharacterSeriesContents", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorAdminContentSeriesController.addingContentToTheSeries", "AddingContentToTheSeriesRequest"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SeriesContentAddRequest"}}}}, "responses": { @@ -353,7 +524,7 @@ "tags": ["Series"], "summary": "시리즈 미연결 콘텐츠 검색", "operationId": "searchAiCharacterContentsNotInSeries", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorAdminContentSeriesController.searchContentNotInSeries", "SearchContentNotInSeriesResponse"], "parameters": [{"name": "search_word", "in": "query", "required": true, "schema": {"type": "string"}}], "responses": { @@ -375,7 +546,7 @@ "summary": "시리즈 콘텐츠 연결 해제", "description": "레거시 RemoveContentToTheSeriesRequest의 seriesId와 contentId를 path로 이동해 body가 없다.", "operationId": "removeAiCharacterSeriesContent", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorAdminContentSeriesController.removeContentInTheSeries", "RemoveContentToTheSeriesRequest"], "responses": { "200": {"$ref": "#/components/responses/NullSuccess"}, @@ -395,9 +566,9 @@ "tags": ["Community"], "summary": "커뮤니티 게시글 목록", "operationId": "listAiCharacterCommunityPosts", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorCommunityController.getCommunityPostList", "GetCommunityPostListResponse"], - "parameters": [{"$ref": "#/components/parameters/Timezone"}, {"$ref": "#/components/parameters/Page"}, {"$ref": "#/components/parameters/Size"}], + "parameters": [{"$ref": "#/components/parameters/Page"}, {"$ref": "#/components/parameters/Size"}], "responses": { "200": {"$ref": "#/components/responses/CommunityPostListSuccess"}, "400": {"$ref": "#/components/responses/BadRequest"}, @@ -413,7 +584,7 @@ "tags": ["Community"], "summary": "커뮤니티 게시글 등록", "operationId": "createAiCharacterCommunityPost", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorCommunityController.createCommunityPost", "CreateCommunityPostRequest"], "requestBody": {"$ref": "#/components/requestBodies/CommunityPostCreateMultipart"}, "responses": { @@ -436,7 +607,7 @@ "summary": "커뮤니티 게시글 수정/고정/soft delete", "description": "ModifyCommunityPostRequest의 creatorCommunityId와 UpdateCommunityPostFixedRequest의 postId를 path로 이동하고 나머지 레거시 필드를 하나의 request에 합친다.", "operationId": "updateAiCharacterCommunityPost", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorCommunityController.modifyCommunityPost", "CreatorCommunityController.updateCommunityPostFixed", "ModifyCommunityPostRequest", "UpdateCommunityPostFixedRequest"], "requestBody": {"$ref": "#/components/requestBodies/CommunityPostUpdateMultipart"}, "responses": { @@ -452,6 +623,134 @@ } } }, + "/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments": { + "parameters": [ + {"$ref": "#/components/parameters/AcceptLanguage"}, + {"$ref": "#/components/parameters/CharacterId"}, + {"$ref": "#/components/parameters/PostId"} + ], + "get": { + "tags": ["Community"], + "summary": "커뮤니티 게시글 댓글 목록", + "operationId": "listAiCharacterCommunityPostComments", + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorCommunityController.getCommunityPostCommentList", "GetCommunityPostCommentListResponse"], + "parameters": [ + {"$ref": "#/components/parameters/Page"}, + {"$ref": "#/components/parameters/Size"} + ], + "responses": { + "200": {"$ref": "#/components/responses/CommunityCommentListSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + }, + "post": { + "tags": ["Community"], + "summary": "커뮤니티 게시글 댓글 또는 답글 작성", + "description": "parentId가 없으면 원댓글, 있으면 같은 게시글의 활성 원댓글에 대한 답글을 target AI 캐릭터 명의로 작성한다.", + "operationId": "createAiCharacterCommunityPostComment", + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorCommunityController.createCommunityPostComment", "CreateCommunityPostCommentRequest"], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommunityCommentCreateRequest"}}} + }, + "responses": { + "200": {"$ref": "#/components/responses/NullSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, + "/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}": { + "parameters": [ + {"$ref": "#/components/parameters/AcceptLanguage"}, + {"$ref": "#/components/parameters/CharacterId"}, + {"$ref": "#/components/parameters/PostId"}, + {"$ref": "#/components/parameters/CommentId"} + ], + "put": { + "tags": ["Community"], + "summary": "AI 캐릭터 작성 커뮤니티 댓글 수정", + "operationId": "updateAiCharacterCommunityPostComment", + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorCommunityController.modifyCommunityPostComment", "ModifyCommunityPostCommentRequest"], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommentUpdateRequest"}}} + }, + "responses": { + "200": {"$ref": "#/components/responses/NullSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + }, + "delete": { + "tags": ["Community"], + "summary": "커뮤니티 게시글 댓글 soft delete", + "description": "target 소유 게시글의 댓글 또는 답글을 작성자와 관계없이 해당 row만 isActive=false로 변경한다. 이미 비활성이면 성공 no-op이며 하위 답글은 변경하지 않는다.", + "operationId": "deleteAiCharacterCommunityPostComment", + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorCommunityService.modifyCommunityPostComment", "ModifyCommunityPostCommentRequest.isActive"], + "responses": { + "200": {"$ref": "#/components/responses/NullSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, + "/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}/replies": { + "parameters": [ + {"$ref": "#/components/parameters/AcceptLanguage"}, + {"$ref": "#/components/parameters/CharacterId"}, + {"$ref": "#/components/parameters/PostId"}, + {"$ref": "#/components/parameters/CommentId"} + ], + "get": { + "tags": ["Community"], + "summary": "커뮤니티 게시글 댓글 답글 목록", + "operationId": "listAiCharacterCommunityPostCommentReplies", + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorCommunityController.getCommentReplyList", "GetCommunityPostCommentListResponse"], + "parameters": [ + {"$ref": "#/components/parameters/Page"}, + {"$ref": "#/components/parameters/Size"} + ], + "responses": { + "200": {"$ref": "#/components/responses/CommunityCommentListSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, "/api/v2/admin/ai-characters/{characterId}/fan-talks": { "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}], "get": { @@ -459,7 +758,7 @@ "summary": "FanTalk 관리자 목록", "description": "공개 v2 응답 필드 형태를 유지하되 관리자 target/ownership 정책을 사용하고 viewer/block 필터를 적용하지 않는다.", "operationId": "listAiCharacterFanTalks", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorChannelFanTalkController.getFanTalkTab", "CreatorChannelFanTalkTabResponse"], "parameters": [{"$ref": "#/components/parameters/FanTalkPage"}, {"$ref": "#/components/parameters/FanTalkSize"}], "responses": { @@ -474,6 +773,31 @@ } } }, + "/api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}": { + "parameters": [ + {"$ref": "#/components/parameters/AcceptLanguage"}, + {"$ref": "#/components/parameters/CharacterId"}, + {"$ref": "#/components/parameters/FanTalkId"} + ], + "delete": { + "tags": ["FanTalk"], + "summary": "팬 작성 FanTalk 원글 soft delete", + "description": "target 채널의 팬 작성 root만 비활성화하며 이미 비활성이면 성공 no-op이다. 연결 creator reply row는 변경하지 않는다.", + "operationId": "deleteAiCharacterFanTalk", + "x-implementation-status": "implemented", + "x-legacy-sources": ["ExplorerService.modifyCheers", "PutWriteCheersRequest.isActive"], + "responses": { + "200": {"$ref": "#/components/responses/NullSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, "/api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies": { "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}, {"$ref": "#/components/parameters/FanTalkId"}], "post": { @@ -481,7 +805,7 @@ "summary": "FanTalk 답변 작성", "description": "사용자가 승인한 예외로 신규 관리자 축약 응답을 반환한다.", "operationId": "createAiCharacterFanTalkReply", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["ExplorerController.writeCheers", "PostWriteCheersRequest", "CreatorChannelFanTalkReplyResponse"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FanTalkReplyCreateRequest"}}}}, "responses": { @@ -496,6 +820,29 @@ "500": {"$ref": "#/components/responses/InternalServerError"} } } + }, + "/api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}, {"$ref": "#/components/parameters/FanTalkId"}, {"$ref": "#/components/parameters/ReplyId"}], + "put": { + "tags": ["FanTalk"], + "summary": "FanTalk 답변 수정", + "description": "target AI가 작성하고 path의 활성 root에 직접 연결된 reply만 수정한다. 레거시처럼 optional/nullable content와 isActive의 non-null 값만 반영하며 빈 객체는 성공 no-op이다. 비활성 reply는 isActive=true로 재활성화할 수 있고 성공 data는 CreatorChannelFanTalkResponse 필드 형태다.", + "operationId": "updateAiCharacterFanTalkReply", + "x-implementation-status": "implemented", + "x-legacy-sources": ["ExplorerController.modifyCheers", "ExplorerService.modifyCheers", "PutWriteCheersRequest", "CreatorChannelFanTalkResponse"], + "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FanTalkReplyUpdateRequest"}}}}, + "responses": { + "200": {"$ref": "#/components/responses/FanTalkReplyUpdateSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } } }, "components": { @@ -514,12 +861,13 @@ "ContentId": {"name": "contentId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, "SeriesId": {"name": "seriesId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, "PostId": {"name": "postId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, + "CommentId": {"name": "commentId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, "FanTalkId": {"name": "fanTalkId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, + "ReplyId": {"name": "replyId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, "Page": {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0, "minimum": 0}}, "Size": {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20, "minimum": 1}}, "FanTalkPage": {"name": "page", "in": "query", "required": false, "description": "공개 v2 정책에서 0 이상으로 보정한다.", "schema": {"type": "integer", "format": "int32", "default": 0}}, - "FanTalkSize": {"name": "size", "in": "query", "required": false, "description": "공개 v2 정책에서 20..50으로 보정한다.", "schema": {"type": "integer", "format": "int32", "default": 20}}, - "Timezone": {"name": "timezone", "in": "query", "required": true, "example": "Asia/Seoul", "schema": {"type": "string"}} + "FanTalkSize": {"name": "size", "in": "query", "required": false, "description": "공개 v2 정책에서 20..50으로 보정한다.", "schema": {"type": "integer", "format": "int32", "default": 20}} }, "requestBodies": { "CharacterCreateMultipart": { @@ -558,17 +906,22 @@ "responses": { "CharacterListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CharacterListApiResponse"}}}}, "CharacterDetailSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CharacterDetailApiResponse"}}}}, + "OriginalWorkSearchSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OriginalWorkSearchApiResponse"}}}}, "AudioThemeListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AudioThemeListApiResponse"}}}}, "AudioContentListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AudioContentListApiResponse"}}}}, "AudioContentCreateSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AudioContentCreateApiResponse"}}}}, "AudioContentDetailSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AudioContentDetailApiResponse"}}}}, + "AudioContentCommentListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AudioContentCommentListApiResponse"}}}}, "SeriesListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SeriesListApiResponse"}}}}, "SeriesDetailSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SeriesDetailApiResponse"}}}}, + "SeriesGenreListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SeriesGenreListApiResponse"}}}}, "SeriesContentListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SeriesContentListApiResponse"}}}}, "SeriesContentSearchSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SeriesContentSearchApiResponse"}}}}, "CommunityPostListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommunityPostListApiResponse"}}}}, + "CommunityCommentListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommunityCommentListApiResponse"}}}}, "FanTalkListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FanTalkListApiResponse"}}}}, "FanTalkReplySuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FanTalkReplyApiResponse"}}}}, + "FanTalkReplyUpdateSuccess": {"description": "레거시 FanTalk 답변 수정 성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FanTalkReplyUpdateApiResponse"}}}}, "NullSuccess": {"description": "레거시 mutation 성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/NullSuccessResponse"}}}}, "BadRequest": {"description": "잘못된 요청/target/domain 오류", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiErrorResponse"}}}}, "Unauthorized": {"description": "인증 실패", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiErrorResponse"}}}}, @@ -796,6 +1149,27 @@ }, "CharacterListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/CharacterListResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, "CharacterDetailApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/CharacterDetailResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + "OriginalWorkSearchItem": { + "type": "object", + "additionalProperties": false, + "required": ["id", "title", "contentType", "category", "isAdult", "description", "originalWork", "originalLink", "writer", "studio", "originalLinks", "tags", "imageUrl"], + "properties": { + "id": {"type": "integer", "format": "int64"}, + "title": {"type": "string"}, + "contentType": {"type": "string"}, + "category": {"type": "string"}, + "isAdult": {"type": "boolean"}, + "description": {"type": "string"}, + "originalWork": {"$ref": "#/components/schemas/NullableString"}, + "originalLink": {"$ref": "#/components/schemas/NullableString"}, + "writer": {"$ref": "#/components/schemas/NullableString"}, + "studio": {"$ref": "#/components/schemas/NullableString"}, + "originalLinks": {"type": "array", "items": {"type": "string"}}, + "tags": {"type": "array", "items": {"type": "string"}}, + "imageUrl": {"$ref": "#/components/schemas/NullableString"} + } + }, + "OriginalWorkSearchApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"type": "array", "items": {"$ref": "#/components/schemas/OriginalWorkSearchItem"}}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, "AudioContentTheme": { "type": "object", @@ -815,8 +1189,7 @@ "price": {"type": "integer", "format": "int32"}, "purchaseOption": {"$ref": "#/components/schemas/PurchaseOption", "default": "BOTH"}, "limited": {"$ref": "#/components/schemas/NullableInt32"}, - "timezone": {"type": "string", "default": "Asia/Seoul"}, - "releaseDate": {"type": ["string", "null"], "description": "yyyy-MM-dd HH:mm"}, + "releaseDate": {"type": ["string", "null"], "format": "date-time", "pattern": "Z$", "description": "클라이언트가 UTC로 변환해 보내는 ISO-8601 시각. 예: 2026-07-29T09:00:00Z"}, "themeId": {"type": "integer", "format": "int64", "default": 0, "description": "0은 binding 기본값이며 domain validation에서 유효하지 않다."}, "isAdult": {"type": "boolean", "default": false}, "isGeneratePreview": {"type": "boolean", "default": false}, @@ -938,10 +1311,37 @@ "languageCode": {"$ref": "#/components/schemas/NullableString"}, "isSecret": {"type": "boolean"}, "donationCan": {"type": "integer", "format": "int32"}, - "date": {"type": "string"}, + "date": {"type": "string", "format": "date-time", "pattern": "Z$", "description": "ISO-8601 UTC 시각(Z)"}, "replyCount": {"type": "integer", "format": "int32"} } }, + "AudioContentCommentCreateRequest": { + "type": "object", + "additionalProperties": false, + "required": ["comment"], + "properties": { + "comment": {"type": "string"}, + "parentId": {"$ref": "#/components/schemas/NullableInt64"}, + "isSecret": {"type": "boolean", "default": false}, + "languageCode": {"$ref": "#/components/schemas/NullableString"} + } + }, + "CommentUpdateRequest": { + "type": "object", + "additionalProperties": false, + "required": ["comment"], + "properties": {"comment": {"type": "string"}} + }, + "AudioContentCommentListResponse": { + "type": "object", + "additionalProperties": false, + "required": ["totalCount", "items"], + "properties": { + "totalCount": {"type": "integer", "format": "int32"}, + "items": {"type": "array", "items": {"$ref": "#/components/schemas/AudioContentComment"}} + } + }, + "AudioContentCommentListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/AudioContentCommentListResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, "TranslatedContent": { "type": "object", "additionalProperties": false, @@ -963,7 +1363,7 @@ "tag": {"type": "string"}, "price": {"type": "integer", "format": "int32"}, "duration": {"type": "string"}, - "releaseDate": {"$ref": "#/components/schemas/NullableString"}, + "releaseDate": {"type": ["string", "null"], "format": "date-time", "pattern": "Z$", "description": "기존 null/노출 조건을 유지하는 ISO-8601 UTC 시각(Z)"}, "totalContentCount": {"$ref": "#/components/schemas/NullableInt32"}, "remainingContentCount": {"$ref": "#/components/schemas/NullableInt32"}, "orderSequence": {"$ref": "#/components/schemas/NullableInt32"}, @@ -1060,22 +1460,14 @@ "required": ["totalCount", "items"], "properties": {"totalCount": {"type": "integer", "format": "int32"}, "items": {"type": "array", "items": {"$ref": "#/components/schemas/SeriesListItem"}}} }, - "SeriesDetailResponse": { + "SeriesGenreItem": { "type": "object", "additionalProperties": false, - "required": ["seriesId", "title", "introduction", "coverImageUrl", "publishedDaysOfWeek", "genre", "keywords", "isAdult", "state", "writer", "studio"], + "required": ["id", "genre", "isAdult"], "properties": { - "seriesId": {"type": "integer", "format": "int64"}, - "title": {"type": "string"}, - "introduction": {"type": "string"}, - "coverImageUrl": {"type": "string"}, - "publishedDaysOfWeek": {"type": "string", "description": "예: 월, 수"}, + "id": {"type": "integer", "format": "int64"}, "genre": {"type": "string"}, - "keywords": {"type": "string"}, - "isAdult": {"type": "boolean"}, - "state": {"type": "string", "description": "레거시 String 필드. 현재 관찰 값: 연재중, 휴재중, 완결"}, - "writer": {"$ref": "#/components/schemas/NullableString"}, - "studio": {"$ref": "#/components/schemas/NullableString"} + "isAdult": {"type": "boolean"} } }, "SeriesContentListItem": { @@ -1109,7 +1501,8 @@ "properties": {"ids": {"type": "array", "items": {"type": "integer", "format": "int64"}}} }, "SeriesListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/SeriesListResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, - "SeriesDetailApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/SeriesDetailResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + "SeriesDetailApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/SeriesListItem"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + "SeriesGenreListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"type": "array", "items": {"$ref": "#/components/schemas/SeriesGenreItem"}}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, "SeriesContentListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/SeriesContentListResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, "SeriesContentSearchApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"type": "array", "items": {"$ref": "#/components/schemas/SeriesContentSearchItem"}}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, @@ -1124,10 +1517,30 @@ "profileUrl": {"type": "string"}, "comment": {"type": "string"}, "isSecret": {"type": "boolean"}, - "date": {"type": "string"}, + "date": {"type": "string", "format": "date-time", "pattern": "Z$", "description": "ISO-8601 UTC 시각(Z)"}, "replyCount": {"type": "integer", "format": "int32"} } }, + "CommunityCommentCreateRequest": { + "type": "object", + "additionalProperties": false, + "required": ["comment"], + "properties": { + "parentId": {"$ref": "#/components/schemas/NullableInt64"}, + "comment": {"type": "string"}, + "isSecret": {"type": "boolean", "default": false} + } + }, + "CommunityCommentListResponse": { + "type": "object", + "additionalProperties": false, + "required": ["totalCount", "items"], + "properties": { + "totalCount": {"type": "integer", "format": "int32"}, + "items": {"type": "array", "items": {"$ref": "#/components/schemas/CommunityPostComment"}} + } + }, + "CommunityCommentListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/CommunityCommentListResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, "CommunityPostListItem": { "type": "object", "additionalProperties": false, @@ -1153,6 +1566,18 @@ "firstComment": {"oneOf": [{"$ref": "#/components/schemas/CommunityPostComment"}, {"type": "null"}]} } }, + "CommunityPostListResponse": { + "type": "object", + "additionalProperties": false, + "required": ["totalCount", "page", "size", "hasNext", "items"], + "properties": { + "totalCount": {"type": "integer", "format": "int64", "minimum": 0}, + "page": {"type": "integer", "format": "int32", "minimum": 0}, + "size": {"type": "integer", "format": "int32", "minimum": 1}, + "hasNext": {"type": "boolean"}, + "items": {"type": "array", "items": {"$ref": "#/components/schemas/CommunityPostListItem"}} + } + }, "CommunityPostCreateRequest": { "type": "object", "additionalProperties": false, @@ -1194,7 +1619,7 @@ "request": {"$ref": "#/components/schemas/CommunityPostUpdateRequest"} } }, - "CommunityPostListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"type": "array", "items": {"$ref": "#/components/schemas/CommunityPostListItem"}}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + "CommunityPostListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/CommunityPostListResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, "FanTalkCreatorReply": { "type": "object", @@ -1241,6 +1666,15 @@ "required": ["content"], "properties": {"content": {"type": "string"}} }, + "FanTalkReplyUpdateRequest": { + "type": "object", + "additionalProperties": false, + "description": "레거시 PutWriteCheersRequest에서 path로 이동한 cheersId만 제외한다. content와 isActive는 optional/nullable이며 둘 다 생략하거나 null이면 성공 no-op이다.", + "properties": { + "content": {"$ref": "#/components/schemas/NullableString"}, + "isActive": {"$ref": "#/components/schemas/NullableBoolean"} + } + }, "FanTalkReplyResponse": { "type": "object", "additionalProperties": false, @@ -1254,7 +1688,8 @@ } }, "FanTalkListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/FanTalkListResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, - "FanTalkReplyApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/FanTalkReplyResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]} + "FanTalkReplyApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/FanTalkReplyResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + "FanTalkReplyUpdateApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/FanTalkListItem", "description": "레거시 CreatorChannelFanTalkResponse 필드 형태. fanTalkId는 수정한 reply row ID이며 creatorReplies는 빈 배열이다."}, "errorProperty": {"type": ["string", "null"], "const": null}}}]} } } } diff --git a/docs/20260724_AI캐릭터_관리자_API/plan-task.md b/docs/20260724_AI캐릭터_관리자_API/plan-task.md index 2e9b15ad..ce798856 100644 --- a/docs/20260724_AI캐릭터_관리자_API/plan-task.md +++ b/docs/20260724_AI캐릭터_관리자_API/plan-task.md @@ -2,8 +2,9 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** `ADMIN`이 AI 캐릭터용 Member로 로그인하지 않고 `characterId` 기준으로 캐릭터, 콘텐츠, 시리즈, 커뮤니티, -FanTalk 목록·답변을 안전하게 대리 관리하는 신규 v2 관리자 API를 구현한다. +**Goal:** `ADMIN`이 AI 캐릭터용 Member로 로그인하지 않고 `characterId` 기준으로 캐릭터, 콘텐츠·댓글, 시리즈, +커뮤니티·댓글, FanTalk 목록·답변 작성·수정·팬 원글 삭제와 등록용 원작·장르 조회를 안전하게 대리 관리하는 신규 v2 +관리자 API를 구현한다. **Architecture:** 신규 외부 경계는 `/api/v2/admin/ai-characters` 하위 controller/facade/application에 둔다. 공통 target resolver가 `characterId -> ChatCharacter.creatorMember`를 해석하고 `CREATOR + AI_CHARACTER` 불변식과 ownership을 먼저 검증한 뒤, 각 domain vertical slice가 기존 entity/repository/S3/CloudFront/event 컴포넌트를 테스트로 고정해 선택적으로 재사용한다. @@ -11,24 +12,24 @@ FanTalk 목록·답변을 안전하게 대리 관리하는 신규 v2 관리자 A | 문서 항목 | 내용 | |---|---| -| 상태 | 구현 중 | +| 상태 | 구현 완료 | | 작성일 | 2026-07-24 | | 요구사항 기준 | `docs/20260724_AI캐릭터_관리자_API/prd.md` | | API 기준 | `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` | -| 현재 Phase | Phase 2·3 레거시 계약 정합화 | -| 현재 활성 Goal | `P23-CONTRACT-2` 대기 | +| 현재 Phase | 완료 | +| 현재 활성 Goal | 완료 | ## 현재 상태 -| Phase | 상태 | 기존 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 | +| Phase | 상태 | 완료/전체 Task | 활성/다음 Goal | 차단 또는 남은 조건 | |---:|---|---:|---|---| -| 1 | 완료 | 7/7 | 완료 | 없음 | -| 2 | 후속 보완 대기 | 11/11 | `P23-CONTRACT-2` | 레거시 JSON 계약과 현재 v2 구현 정합화 | -| 3 | 후속 보완 대기 | 15/15 | `P23-CONTRACT-3` | `P23-CONTRACT-2` | -| 4 | 대기 | 0/6 | `P4-T1` | `P23-CONTRACT-GATE` | -| 5 | 대기 | 0/6 | `P5-T1` | `P4-GATE` | -| 6 | 대기 | 0/5 | `P6-T1` | `P5-GATE` | -| 7 | 대기 | 0/2 | `P7-T1` | Phase 1~6 Gate 완료 | +| 1 | 완료 | 7/7 | 완료 | 10차 정적 리뷰 신규 finding 없음 | +| 2 | 완료 | 18/18 | 완료 | 16차 정적 리뷰 신규 finding 없음 | +| 3 | 완료 | 29/29 | 완료 | 16차 정적 리뷰 신규 finding 없음 | +| 4 | 완료 | 16/16 | 완료 | 10차 정적 리뷰 신규 finding 없음 | +| 5 | 완료 | 16/16 | 완료 | 10차 정적 리뷰 신규 finding 없음 | +| 6 | 완료 | 10/10 | 완료 | 10차 정적 리뷰 신규 finding 없음 | +| 7 | 완료 | 13/13 | 완료 | 10차 통합 정적 리뷰 신규 finding 없음 | - Phase는 결과와 의존성을 묶는 문서 단위다. `create_goal`에는 Task 또는 Phase Gate 하나만 등록한다. - 동시에 하나의 미완료 goal만 운용하고, 사용자가 명시적으로 요청하지 않으면 token budget을 설정하지 않는다. @@ -62,6 +63,19 @@ FanTalk 목록·답변을 안전하게 대리 관리하는 신규 v2 관리자 A 유지하고, FanTalk 목록은 공개 v2 `CreatorChannelFanTalkTabResponse` 필드 형태의 관리자 전용 endpoint로 제공한다. 따라서 `DEC-P2-T5-001`의 캐릭터 `isActive=false` 단독 입력 제한은 계약 차원에서 폐기하고, 레거시처럼 다른 optional field와 동시 입력을 허용하되 비활성화만 반영한다. +- 2026-07-29 후속 기능 확정 정책: 오디오 콘텐츠 댓글 CRUD, 커뮤니티 댓글 CRUD, 팬 작성 FanTalk 원글 삭제, + 캐릭터 등록용 원작 검색, 시리즈 등록용 장르 목록을 관리자 전용 endpoint로 추가한다. 캐릭터에 직접 달리는 레거시 댓글 + 삭제 API는 v2 전환 뒤 사용하지 않으므로 구현하지 않는다. 시리즈 상세 `data`는 목록 `items`의 단일 객체와 동일한 + 필드·타입으로 정합화한다. +- 2026-07-29 UTC 날짜 계약 확정 정책: 신규 관리자 오디오 생성 request의 `timezone` body와 오디오 상세·오디오 + 댓글/답글·커뮤니티 댓글/답글 GET의 `timezone` query를 제거한다. 생성의 nullable `releaseDate`는 클라이언트가 + ISO-8601 UTC(`Z`)로 변환해 보내고, 상세 `releaseDate`와 댓글 `date`는 기존 필드명 및 null/노출 조건을 유지한 + ISO-8601 UTC(`Z`)로 반환한다. 기존 로컬 시각+timezone 입력은 병행 지원하지 않으며 legacy/public API 계약은 변경하지 않는다. +- 2026-07-29 FanTalk 답변 수정 확정 정책: + `PUT /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}`는 레거시 + `PUT /explorer/profile/cheers`에서 path로 이동한 `cheersId`만 제거한다. optional/nullable `content`, `isActive`, 빈 + 객체 no-op, 비활성 reply 재활성화와 `CreatorChannelFanTalkResponse` 성공 `data`를 유지하되, target AI가 작성하고 + target의 활성 root에 직접 연결된 reply로 한정한다. - 기계 검증 가능한 API 계약 원본: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` - 계약 근거와 예외 설명: @@ -85,21 +99,43 @@ FanTalk 목록·답변을 안전하게 대리 관리하는 신규 v2 관리자 A | Domain | operation | 구현 상태와 소유 Goal | |---|---:|---| -| Character | 4 | runtime 정합화 필요, `P23-CONTRACT-2` | -| AudioContent | 5 | runtime 정합화 필요, `P23-CONTRACT-3` | -| Series | 9 | 구현 예정, Phase 4 | -| Community | 3 | 구현 예정, Phase 5 | -| FanTalk | 2 | 구현 예정, Phase 6 | +| Character | 5 | 5 route 구현 및 multipart request part media type 정합화 완료 (`P2-R10`) | +| AudioContent | 10 | 10 route 구현 및 pagination·multipart part 계약 정합화 완료 (`P3-R15`, `P3-R16`) | +| Series | 10 | 10 route 구현 및 multipart part media type 정합화 완료 (`P4-R7`) | +| Community | 8 | 8 route 구현 및 JSON·multipart part 계약 정합화 완료 (`P5-R7`~`P5-R9`) | +| FanTalk | 4 | 4 route 구현 및 JSON media type·답변 수정 계약 정합화 완료 (`P6-R3`, `P6-R4`) | -- 신규 path에 포함된 `characterId`, `contentId`, `seriesId`, `postId`, `fanTalkId`만 레거시 request body에서 제거한다. +- 신규 path에 포함된 `characterId`, `contentId`, `seriesId`, `postId`, `fanTalkId`, `replyId`만 레거시 request body에서 + 제거한다. - 그 밖의 JSON 필드명·타입·optional/nullable·기본값과 성공 `data` 형태는 레거시 API를 유지한다. - 레거시 mutation의 성공 `data`는 `null`이고 오디오 콘텐츠 생성만 `CreateAudioContentResponse(contentId)`를 반환한다. - FanTalk 답변 작성만 승인된 축약 응답 `fanTalkId`, `replyId`, `creatorMemberId`, `content`, `createdAtUtc`를 사용한다. +- FanTalk 답변 수정은 레거시 `PutWriteCheersRequest`의 optional/nullable `content`, `isActive`와 + `CreatorChannelFanTalkResponse` 성공 `data`를 유지한다. target AI가 작성하고 path의 활성 root에 직접 연결된 reply만 + 수정하며 비활성 reply 재활성화와 빈 객체 no-op을 허용한다. - 캐릭터 수정의 `isActive=false`는 다른 optional JSON field와 함께 받을 수 있으며 레거시 의미대로 비활성화만 반영한다. +- 커뮤니티 목록은 2026-07-29 사용자 확정에 따라 레거시 직접 배열의 예외로 둔다. `timezone` query 없이 + `totalCount`, `page`, `size`, `hasNext`, `items` pagination wrapper를 반환한다. +- 오디오 생성 request는 `timezone` 없이 nullable `releaseDate`를 ISO-8601 UTC(`Z`)로 받는다. 오디오 상세는 + `timezone` query 없이 기존 nullable `releaseDate`를 UTC로 반환한다. +- 오디오 콘텐츠와 커뮤니티 댓글의 조회는 `timezone` 없이 `page`, `size`와 레거시 `totalCount`, `items`를 유지하고 + 각 `date`를 ISO-8601 UTC(`Z`)로 반환한다. 작성은 target AI 캐릭터 명의로 수행하고 수정은 target AI가 작성한 + 댓글/답글만 허용한다. 삭제는 target 소유 리소스의 댓글/답글이면 작성자와 무관하게 해당 row만 soft delete한다. +- 댓글 생성의 optional `parentId`가 없으면 원댓글, 있으면 같은 리소스의 활성 원댓글에 대한 답글이다. 삭제는 cascade하지 + 않으며 이미 비활성인 row 삭제는 성공 no-op이다. +- 팬 작성 FanTalk 삭제는 target 채널의 활성 root만 soft delete하고 creator reply row는 유지한다. +- 캐릭터에 직접 달리는 레거시 댓글 삭제는 v2 미사용 API라 구현 범위에서 제외한다. +- 원작 검색은 필수 `searchTerm`과 `OriginalWorkResponse` 직접 배열, 장르 목록은 활성 장르의 + `id`, `genre`, `isAdult` 직접 배열을 사용한다. +- 시리즈 상세 `data`는 배열 wrapper 없이 목록 `items`의 단일 객체와 동일한 11개 필드를 반환하고 기존 상세 전용 + `genre`, `keywords`는 제거한다. - multipart의 `request` part는 `application/json`이고 각 파일 part의 이름과 required 여부는 OpenAPI encoding을 따른다. - 공통 오류는 400/401/403/404/405/406/415/500과 `ApiResponse.error`를 사용한다. 405의 `Allow`, 415의 `Accept`, 미지원 `Accept-Language`의 KO fallback과 Spring CORS 정책 거부 403 예외를 유지한다. -- Character·AudioContent 9개 operation은 `P23-CONTRACT-GATE` 완료 전 production 호출 호환을 보장하지 않는다. +- 기존 23개 operation route와 구현된 후속 14개 operation route를 유지한다. OpenAPI 37개 모두 + `x-implementation-status`는 `implemented`다. 2026-07-29 6차·7차 정적 리뷰에서 확인한 optional pagination, + JSON-only, multipart media type과 operation별 multipart part 이름 보완은 각 소유 Goal에서 완료했으며, + `P7-R9`에서 전체 문서 상태를 통합 재판정한다. --- @@ -508,6 +544,12 @@ Query parameters: } ``` +#### 시리즈 콘텐츠 연결 해제 + +`DELETE /api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents/{contentId}` + +Request body: 없음. `RemoveContentToTheSeriesRequest`의 `seriesId`, `contentId`는 path variable로 이동한다. + Response `data`: ```json @@ -536,19 +578,25 @@ Request body: ```json { - "contentIds": [501, 502] + "contentIdList": [501, 502] } ``` -Response `data`: 시리즈 상세와 동일하다. +Response `data`: `null` #### 시리즈 콘텐츠 연결 해제 -`DELETE /api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents/{contentId}` +`DELETE /api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents` -Request body 없음. +Request body: -Response `data`: 시리즈 상세와 동일하다. +```json +{ + "contentId": 501 +} +``` + +Response `data`: `null` #### 시리즈 순서 변경 @@ -987,10 +1035,12 @@ Response `data`: ### Phase 2: AI 캐릭터 관리 vertical slice #### 목표 -AI 캐릭터 목록/검색/상세/생성/수정/비활성화를 신규 ADMIN v2 API로 제공하고 레거시 관리자 동작 parity를 고정한다. +AI 캐릭터 목록/검색/상세/생성/수정/비활성화와 등록용 원작 검색을 신규 ADMIN v2 API로 제공하고 레거시 관리자 동작 +parity를 고정한다. #### 범위와 비범위 -- 포함: character CRUD API, 외부 캐릭터 API 연동, 이미지 저장, 원작 연결, 언어 감지/번역 이벤트, creatorMember 생성/표시 정보 동기화 parity. +- 포함: character CRUD API, 등록용 원작 검색, 외부 캐릭터 API 연동, 이미지 저장, 원작 연결, 언어 감지/번역 이벤트, + creatorMember 생성/표시 정보 동기화 parity. - 제외: hard delete, cascade delete, 기존 legacy admin endpoint 변경. #### 선행 Phase 및 의존성 @@ -1005,6 +1055,8 @@ AI 캐릭터 목록/검색/상세/생성/수정/비활성화를 신규 ADMIN v2 - `POST /api/v2/admin/ai-characters` multipart 필수 `image`, 필수 `request: ChatCharacterRegisterRequest` -> `data: null`. - `PUT /api/v2/admin/ai-characters/{characterId}` multipart optional `image`, 필수 `request: ChatCharacterUpdateRequest`에서 `id` 제외 -> `data: null`. +- `GET /api/v2/admin/ai-characters/original-works/search?searchTerm=` -> + soft delete를 제외한 `List`. - update request의 `isActive=false`는 레거시 soft delete 의미다. #### entity, repository, service 변경 @@ -1424,16 +1476,408 @@ git diff --check - **범위 밖:** Gate에서 production code 수정, 기존 Phase 2 완료 이력 변경, Phase 3 production 변경. - 검증 기록: 무엇: `P2-R5-GATE`에서 `REV-018` 문서 계약 정합성을 종결했다. 왜: Phase 3 6차 보완의 시작 조건이 `P2-R5-GATE` 완료이기 때문이다. 어떻게: `P2-R5` focused 검증 결과와 `phase2-character-review.md` 6차 판정을 대조했다. 결과: production 변경 없이 `REV-018` 처리 완료로 판정했다. +#### Phase 2 7차 리뷰 보완 + +- [x] **Task 2.12: 캐릭터 mutation의 미사용 응답 매핑 제거** + +**Goal 실행 `P2-R6`:** `REV-021`의 POST/PUT 성공 응답이 `data: null`인 계약을 유지하면서 controller가 사용하지 않는 +facade 응답 생성과 전체 DTO 매핑을 제거한다. + +- **추적 review ID:** `REV-021`. +- **시작 조건:** `P23-CONTRACT-GATE` 완료 이력과 `phase2-character-review.md` 7차 리뷰 판정 존재. +- **완료 증거:** create/update facade 반환형을 `Unit`으로 축소하고 불필요한 mapper 호출을 제거한 diff, mutation exact + `data: null` 회귀, character/common 영향 범위 회귀와 Progress 기록. +- **범위 밖:** 캐릭터 생성·수정 business pipeline, 외부 API/S3/event 순서, 공개 API schema 변경, 인접 mapper 정리. +- **TDD 예외 사유:** OpenAPI와 기존 actual endpoint test가 이미 `data: null`을 고정한 상태에서 사용되지 않는 내부 계산만 + 제거하는 동작 불변 리팩터링이다. +- **대체 검증 방법:** controller가 facade 반환값을 소비하지 않는지 정적 확인하고 기존 mutation exact JSON 테스트를 + focused 회귀한다. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterFacade.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterController.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerMutationTest.kt` + +- [x] **REFACTOR:** create/update facade 반환형을 `Unit`으로 바꾸고 mutation 마지막의 미사용 + `characterMapper.toResponse(...)`를 제거한다. +- [x] **CONTRACT TEST:** POST/PUT actual endpoint가 계속 200과 exact `data: null`을 반환하는지 확인한다. +- [x] **회귀 확인:** character package와 공통 authorization/error 회귀 및 `ktlintCheck`를 실행한다. + - 검증 기록: 무엇: `REV-021`의 캐릭터 mutation 미사용 response mapping을 제거했다. 왜: POST/PUT 성공 응답은 + `data: null`인데 facade가 controller가 버리는 전체 상세 DTO를 생성하고 있었기 때문이다. 어떻게: `create`/`update` + 반환형을 `Unit`으로 축소하고 마지막 `characterMapper.toResponse(...)` 호출만 제거했다. 결과: mutation focused 명령은 + `BUILD SUCCESSFUL in 1m 54s`, character/common 영향 범위 회귀는 `BUILD SUCCESSFUL in 1m 57s`, `ktlintCheck`는 + `BUILD SUCCESSFUL in 33s`, `git diff --check`는 출력이 없었다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 2 7차 리뷰 Gate + +**Goal 실행 `P2-R6-GATE`:** `REV-021`의 불필요한 mutation 응답 매핑 제거와 계약 불변 증거를 재검토한다. + +- [x] **`P2-R6-GATE` 완료:** `P2-R6` 완료 후 위 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 리뷰 문서와 + Progress를 갱신한다. +- **시작 조건:** `P2-R6` 완료. +- **완료 증거:** `REV-021` 처리 완료, POST/PUT `data: null` 계약 유지, focused/영향 범위 회귀와 lint·diff 성공. +- **범위 밖:** Gate에서 production code 수정, 기존 Phase 2 완료 이력 변경. + - 검증 기록: 무엇: `P2-R6-GATE`에서 `REV-021` 처리 완료와 Phase 2 7차 리뷰 종결을 확인했다. 왜: Phase 3 7차 + 보완의 시작 조건이 `P2-R6-GATE` 완료이기 때문이다. 어떻게: `phase2-character-review.md` 7차 리뷰 후속 판정을 + 갱신하고 위 focused/영향 범위 회귀, lint, diff check 결과를 대조했다. 결과: `REV-021`은 처리 완료로 판정했고 + Phase 2는 12/12 완료 상태로 동기화했다. + +#### Phase 2 9차 리뷰 보완 + +- [x] **Task 2.13: 캐릭터 필수 image와 `isActive=true` 레거시 계약 복구** + +**Goal 실행 `P2-R7`:** 캐릭터 생성의 빈 필수 `image`를 부작용 전에 거부하고, 수정의 `isActive=true` 단독 요청을 +레거시와 같은 유효한 no-op mutation으로 처리한다. + +- **추적 review ID:** `REV-034`, `REV-035`. +- **시작 조건:** `phase2-character-review.md` 9차 정적 리뷰 판정 존재. +- **완료 증거:** 빈 생성 image의 400/no-side-effect와 `isActive=true` 단독 수정의 200 `data: null` actual endpoint + RED/GREEN, character/common 영향 범위 회귀와 Progress 기록. +- **범위 밖:** OpenAPI schema 변경, 레거시 controller/service 변경, 캐릭터 mutation pipeline 리팩터링, + `isActive=false`의 기존 비활성화 의미 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterFacade.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterMapper.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerMutationTest.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** 필수 `image`를 빈 part로 보낸 POST가 현재 200으로 처리되고 외부 생성·DB 저장이 발생하는지 actual + endpoint로 고정한다. +- [x] **RED:** `{"isActive":true}`만 보낸 PUT이 현재 400을 반환하지만 레거시 endpoint는 유효한 변경 요청으로 받아 + 200 `data: null`을 반환하는 차이를 고정한다. +- [x] **GREEN:** create facade 진입 직후 빈 image를 400 `common.error.invalid_request`로 거부해 외부 API, DB, S3, + event를 호출하지 않는다. +- [x] **GREEN:** `isActive`가 null이 아니면 변경 요청으로 인정하되, `false` 비활성화 분기와 `true` no-op의 레거시 + service 호출·응답 의미를 그대로 유지한다. +- [x] **REFACTOR:** character package와 공통 authorization/error 회귀, `ktlintCheck`, `git diff --check`를 실행해 + Progress와 리뷰 문서에 기록한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 2 9차 리뷰 Gate + +**Goal 실행 `P2-R7-GATE`:** `REV-034`~`REV-035`의 multipart 필수 파일과 레거시 update parity를 재검토한다. + +- [x] **`P2-R7-GATE` 완료:** `P2-R7` 완료 후 위 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 리뷰 문서와 + Progress를 갱신한다. +- **시작 조건:** `P2-R7` 완료. +- **완료 증거:** 두 review ID 처리 완료, 빈 생성 image 400/no-side-effect, `isActive=true` 단독 PUT 200 + `data: null`, 기존 `isActive=false`와 일반 mutation 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 공개 API schema 변경. + + - 검증 기록(RED): 무엇: `REV-034` 빈 생성 image와 `REV-035` `isActive=true` 단독 수정. 왜: empty multipart와 optional boolean parity를 실제 endpoint에서 재현하기 위해. 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest`를 실행했다. 결과: 신규 2건이 각각 400 기대 대비 200, 200 기대 대비 400으로 실패했다. + - 검증 기록(GREEN/GATE): 무엇: character empty image 거부와 `isActive=true` no-op parity. 왜: 외부 API·DB·S3·event 전 400과 레거시 200 `data:null` 의미를 복구하기 위해. 어떻게: 같은 focused 명령 재실행 후 `./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*'`, `./gradlew test`, `./gradlew ktlintCheck`, OpenAPI 23개 operation/status `jq`, controller mapping 23개, `git diff --check`를 실행했다. 결과: 모두 성공했고 `git diff --check`는 출력이 없었다. + +#### Phase 2 11차 리뷰 보완 + +- [x] **Task 2.14: 캐릭터 관계 필수 정수의 null·누락 거부** + +**Goal 실행 `P2-R8`:** 캐릭터 생성 관계의 필수 `importance`가 누락되거나 null이면 JVM 기본값 `0`으로 +보정하지 않고 외부 API·DB·S3·event 전에 400으로 거부한다. + +- **추적 review ID:** `REV-040`. +- **시작 조건:** `phase2-character-review.md` 11차 정적 리뷰 판정 존재. +- **완료 증거:** `importance` 누락·null의 actual endpoint RED/GREEN과 no-side-effect, 정상 정수 생성 회귀, + character/common 영향 범위 회귀와 Progress 기록. +- **범위 밖:** 전역 `ObjectMapper` 설정, 레거시 DTO/controller 변경, OpenAPI schema 변경, 관계 중요도 범위 정책 추가. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterFacade.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerMutationTest.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDto.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** 관계 객체에서 `importance`를 누락하거나 null로 보낸 POST가 현재 `0`으로 역직렬화되어 mutation을 + 진행하는지 actual endpoint로 고정하고 외부 API·DB·S3·event 결과를 단언한다. +- [x] **GREEN:** v2 캐릭터 생성 경계에서 필수 non-null primitive의 존재와 null 여부를 strict parse 결과로 검증해 + `common.error.invalid_request` 400으로 변환한다. +- [x] **CONTRACT TEST:** 정상 `importance` 정수와 관계가 없는 생성은 기존 결과를 유지하고, 미지 필드 거부도 + 회귀하지 않는지 확인한다. +- [x] **REFACTOR:** 검증을 v2 경계의 최소 범위에 두고 character/common 영향 범위 회귀, `ktlintCheck`, + `git diff --check`를 실행해 기록한다. + + - 검증 기록(RED): 무엇: 관계 `importance` 누락·null actual POST. 왜: Jackson primitive 기본값 `0` 보정으로 외부 API·DB·S3·event 부작용이 발생할 수 있는지 재현하기 위해. 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest --tests "*shouldRejectMissingRelationshipImportanceBeforeSideEffects" --tests "*shouldRejectNullRelationshipImportanceBeforeSideEffects"`를 실행했다. 결과: 신규 2건이 `status().isBadRequest` 기대에서 실패해 `BUILD FAILED`였다. + - 검증 기록(GREEN): 무엇: v2 캐릭터 request reader의 primitive null/누락 거부. 왜: 전역 mapper·레거시 DTO 변경 없이 v2 생성 경계에서 OpenAPI required non-null 정수 계약을 강제하기 위해. 어떻게: 같은 명령을 재실행했다. 결과: `BUILD SUCCESSFUL in 48s`였다. + - 검증 기록(GATE): 무엇: 정상 정수 관계 생성, 미지 필드 거부, character/common 영향 범위와 lint/diff. 왜: `REV-040` 보완이 기존 캐릭터 생성·공통 오류/인가 계약을 회귀시키지 않는지 확인하기 위해. 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest`, `./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*" --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest`, `./gradlew ktlintCheck`, `git diff --check`를 실행했다. 결과: 각 Gradle 명령은 `BUILD SUCCESSFUL`이었고 `git diff --check`는 출력이 없었다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 2 11차 리뷰 Gate + +**Goal 실행 `P2-R8-GATE`:** `REV-040`의 캐릭터 관계 필수 정수 nullability와 부작용 경계를 재검토한다. + +- [x] **`P2-R8-GATE` 완료:** `P2-R8` 완료 후 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 리뷰 문서와 + Progress를 갱신한다. +- **시작 조건:** `P2-R8` 완료. +- **완료 증거:** review ID 처리 완료, `importance` 누락·null 400/no-side-effect, 정상 생성 회귀 성공. +- **범위 밖:** Gate에서 production code, 레거시 API 또는 공개 API schema 변경. + +#### Phase 2 후속 기능 보완 + +- [x] **Task 2.15: 캐릭터 등록용 원작 검색** + +**Goal 실행 `P2-R9`:** AI 캐릭터 등록 화면에서 soft delete되지 않은 원작을 필수 `searchTerm`으로 검색하고 레거시 +`OriginalWorkResponse` 전체 필드의 직접 배열로 반환한다. + +- **추적 review ID:** `REV-044`. +- **시작 조건:** `P2-R8-GATE` 완료와 PRD·OpenAPI의 승인된 원작 검색 계약 존재. +- **완료 증거:** 제목·콘텐츠 타입·카테고리 부분 검색, soft delete 제외, 빈 결과, 필수 query 오류와 exact response, + Character/common 영향 범위 회귀와 Progress 기록. +- **범위 밖:** 원작 등록·수정·삭제, pagination·정렬 정책 추가, 원작 schema 축약, 레거시 `/admin/chat/original` 변경. + +**Interfaces:** + +- `GET /api/v2/admin/ai-characters/original-works/search?searchTerm={searchTerm}` +- Produces: `ApiResponse>`. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterController.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterFacade.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminOriginalWorkSearchTest.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/dto/OriginalWorkDtos.kt` +- Modify: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** actual GET으로 세 검색 필드, 삭제 원작 제외, 빈 결과, `searchTerm` 누락 400과 + `OriginalWorkResponse`의 13개 필드를 고정한다. +- [x] **GREEN:** `AdminOriginalWorkService.searchOriginalWorksAll`과 `OriginalWorkResponse.from`을 재사용해 별도 + pagination이나 축약 DTO 없이 응답한다. +- [x] **CONTRACT TEST:** 신규 route가 `/{characterId}`와 충돌하지 않고 ADMIN 이중 인가, 오류 envelope와 + `Accept-Language` fallback을 유지하는지 확인한다. +- [x] **REFACTOR:** 조회 전용 facade method 외 추상화를 추가하지 않고 Character/common 영향 범위 회귀, + `ktlintCheck`, OpenAPI 상태, `git diff --check`를 기록한다. +- 검증 기록(RED): 무엇: 캐릭터 등록용 원작 검색 신규 v2 endpoint actual GET 계약. 왜: route 미구현 상태에서 검색 필드·soft delete 제외·빈 결과·필수 query 오류가 실패하는지 확인하기 위해. 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminOriginalWorkSearchTest`를 실행했다. 결과: 4개 중 3개가 신규 route 부재로 실패했다. +- 검증 기록(GREEN): 무엇: 원작 검색 endpoint 최소 구현. 왜: 레거시 `searchOriginalWorksAll`과 `OriginalWorkResponse.from` 재사용이 계약을 충족하는지 확인하기 위해. 어떻게: 같은 focused test를 재실행했다. 결과: `BUILD SUCCESSFUL`이었다. +- 검증 기록(회귀): 무엇: Character/common 영향 범위, lint, diff whitespace. 왜: 신규 target 없는 route가 기존 character route와 공통 ADMIN/error 경계를 깨지 않는지 확인하기 위해. 어떻게: 아래 영향 범위 test, `./gradlew ktlintCheck`, `git diff --check`를 실행했다. 결과: 영향 범위 test와 ktlint는 `BUILD SUCCESSFUL`, `git diff --check`는 출력 없음이었다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminOriginalWorkSearchTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 2 후속 기능 Gate + +**Goal 실행 `P2-R9-GATE`:** `REV-044`의 원작 검색 범위와 레거시 response parity를 재검토한다. + +- [x] **`P2-R9-GATE` 완료:** `P2-R9` 완료 후 focused/영향 범위 회귀와 OpenAPI·lint·diff를 fresh 실행하고 + Phase 2 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P2-R9` 완료. +- **완료 증거:** 검색 필드·soft delete 제외·직접 배열·필수 query·공통 경계 회귀 성공, + OpenAPI operation `implemented`. +- **범위 밖:** Gate에서 production code 또는 public/legacy API 변경. + +#### Phase 2 multipart request part 계약 후속 보완 + +- [x] **Task 2.16: 캐릭터 생성·수정 request part의 application/json 강제** + +**Goal 실행 `P2-R10`:** 캐릭터 생성·수정 multipart의 `request` part가 OpenAPI encoding대로 +`application/json`일 때만 handler에 진입하고, 그 밖의 part media type은 공통 415 계약으로 거부되도록 정합화한다. + +- **추적 review ID:** `REV-055`. +- **시작 조건:** `P2-R9-GATE` 완료와 OpenAPI의 두 multipart request encoding 계약 존재. +- **완료 증거:** POST·PUT actual endpoint가 JSON part는 기존 strict parse·mutation 의미를 유지하고, + `text/plain`·content type 누락 등은 localized 415 `ApiResponse.error`, 표준 `Accept` header, + 외부 API·S3·DB·event no-side-effect를 반환한다. +- **범위 밖:** JSON schema·strict reader·image 계약, external/S3/DB 순서, legacy/public endpoint, + OpenAPI·신규 dependency·DDL 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterController.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerMutationTest.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** POST·PUT의 유효 JSON 문자열을 `text/plain` request part로 보내 현재 handler에 진입하는지 actual + endpoint와 no-side-effect로 고정한다. +- [x] **GREEN:** v2 controller 경계에서 request part의 `application/json` 호환 여부만 확인하고 기존 String + strict reader에 동일 payload를 전달한다. +- [x] **CONTRACT TEST:** KO/EN/JA 415 envelope, `Accept` header, content type 누락과 정상 JSON, + 필수 part 누락 400 계약을 확인한다. +- [x] **REFACTOR:** 공통 helper가 필요하면 8개 multipart mapping의 media type 확인에만 한정하고 + Character/common 영향 범위 회귀, `ktlintCheck`, OpenAPI 정적 대조, `git diff --check`를 기록한다. + +- **RED 결과 (2026-07-29):** `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest`에서 새 POST·PUT, `text/plain`·content type 누락, KO/EN/JA 12개 415 기대 케이스가 실패했다. +- **GREEN/GATE 결과 (2026-07-29):** 같은 focused 명령은 `BUILD SUCCESSFUL in 31s`였고, Character/common 영향 범위 명령은 `BUILD SUCCESSFUL in 1m 2s`, `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 18s`, `git diff --check`는 출력 없이 성공했다. +- **전체 회귀 생략:** controller part 경계와 해당 actual endpoint 테스트만 변경했으므로 focused와 Character/common 오류 계약 회귀로 검증했다. 전체 `./gradlew test`는 실행하지 않았다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 2 multipart request part 계약 후속 Gate + +**Goal 실행 `P2-R10-GATE`:** `REV-055` 수정 뒤 Character POST·PUT의 part-level JSON-only·415 경계를 재검토한다. + +- [x] **`P2-R10-GATE` 완료:** `P2-R10` 완료 후 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 + Phase 2 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P2-R10` 완료. +- **완료 증거:** 정상 JSON·미지원/누락 media type·필수 part·no-side-effect 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 OpenAPI schema 변경. + +#### Phase 2 multipart part 이름 계약 후속 보완 + +- [x] **Task 2.17: 캐릭터 생성·수정의 미정의 multipart part 거부** + +**Goal 실행 `P2-R11`:** Character 생성·수정 multipart에서 OpenAPI가 정의한 `image`, `request` 외 part를 +handler의 business mutation 전에 400 `common.error.invalid_request`로 거부한다. + +- **추적 review ID:** `REV-060`. +- **시작 조건:** `phase2-character-review.md` 13차 정적 리뷰 판정 존재. +- **완료 증거:** POST·PUT actual endpoint에서 `unexpected` 파일/문자열 part의 400 KO/EN/JA envelope와 + 외부 API·S3·DB·event no-side-effect, 정상 허용 part·기존 415 경계 회귀. +- **범위 밖:** OpenAPI schema 변경, 전역 multipart resolver 변경, legacy/public endpoint, 허용 파일의 내용 검증, + 공통 추상화 추가. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterController.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerMutationTest.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** 생성·수정에 정상 `request`와 `unexpected` part를 함께 보내 현재 mutation이 성공하는 경로를 actual + endpoint로 고정하고 외부 API·S3·DB·event 결과를 단언한다. +- [x] **GREEN:** 기존 multipart 검사에서 실제 part 이름 집합이 POST·PUT 허용 집합 `{image, request}`의 부분집합인지 + 확인하고, 초과 이름이 있으면 `AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, + "common.error.invalid_request")`를 던진다. +- [x] **CONTRACT TEST:** KO/EN/JA 400 envelope, 정상 생성·수정, 필수 part 누락 400, request part media type + 415와 no-side-effect를 함께 확인한다. +- [x] **REFACTOR:** Character controller/test만 최소 변경하고 Character/common 영향 범위 회귀, + `ktlintCheck`, OpenAPI 정적 대조와 `git diff --check`를 기록한다. + - 검증 기록(RED): 무엇: Character POST·PUT의 미정의 multipart part 거부. 왜: OpenAPI `additionalProperties:false`와 달리 + `unexpected` part가 무시된 채 mutation이 진행될 수 있었기 때문이다. 어떻게: + `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest --tests '*shouldRejectUndefinedMultipartPartBeforeSideEffects'`를 + 실행했다. 결과: 신규 6개 invocation이 400 기대 대비 200으로 실패해 RED를 확인했다. + - 검증 기록(GREEN/GATE): 무엇: Character multipart 허용 part 이름 `{image, request}` 적용과 기존 request part media type·누락 회귀. + 왜: 미정의 part를 business facade 진입 전에 400으로 차단하고 기존 정상/415/400 경계를 유지하기 위해서다. 어떻게: + controller에서 `MultipartHttpServletRequest.fileMap.keys`를 검사하고 아래 focused/영향 범위 회귀와 `ktlintCheck`, `git diff --check`를 + 실행했다. 결과: focused undefined/non-json/missing request 명령은 `BUILD SUCCESSFUL in 1m 41s`, Character/common 영향 범위는 + `BUILD SUCCESSFUL in 1m 36s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 30s`, `git diff --check`는 출력 없음이었다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest --tests '*shouldRejectUndefinedMultipartPartBeforeSideEffects' --tests '*shouldRejectNonJsonRequestPartBeforeSideEffects' --tests '*shouldKeepMissingRequestPartAsBadRequest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 2 multipart part 이름 계약 후속 Gate + +**Goal 실행 `P2-R11-GATE`:** `REV-060` 수정 뒤 Character POST·PUT의 허용 part 이름과 기존 media type 경계를 +재검토한다. + +- [x] **`P2-R11-GATE` 완료:** `P2-R11` 완료 후 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 Phase 2 + 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P2-R11` 완료. +- **완료 증거:** 미정의 part 400/no-side-effect, 정상 허용 part, 필수 part·415 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 OpenAPI schema 변경. + - 검증 기록: 무엇: `P2-R11-GATE`에서 `REV-060` 처리 완료와 Phase 2 13차 리뷰 종결을 확인했다. 왜: Phase 3 + `P3-R17` 시작 조건인 `P2-R11-GATE` 완료를 판정하기 위해서다. 어떻게: focused/영향 범위 회귀, lint, diff check와 + `phase2-character-review.md` 최신 결론을 대조했다. 결과: Character 미정의 multipart part는 400/no-side-effect로 + 처리되고 기존 정상 허용 part·필수 part 누락·request part 415 회귀가 유지됐다. + +#### Phase 2 multipart 일반 form-field part 후속 보완 + +- [x] **Task 2.18: 캐릭터 생성·수정의 전체 multipart part 이름 검증** + +**Goal 실행 `P2-R12`:** Character POST·PUT에서 파일 part뿐 아니라 filename 없는 일반 form-field part를 포함한 +모든 multipart part 이름을 검사해 `{image, request}` 외 이름을 mutation 전에 400 +`common.error.invalid_request`로 거부한다. + +- **추적 review ID:** `REV-065`. +- **시작 조건:** `phase2-character-review.md` 8차 정적 리뷰 판정 존재. +- **완료 증거:** filename 없는 `unexpected` part의 KO/EN/JA 400 envelope와 external API·S3·DB·event + no-side-effect, 기존 파일형 미정의 part·정상 허용 part·필수 part·request part 415 회귀 성공. +- **범위 밖:** OpenAPI schema, 전역 multipart resolver, legacy/public endpoint, 공통 추상화, 신규 dependency·DDL. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterController.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerMutationTest.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** `MockPart` 등 filename 없는 `unexpected` 일반 form-field part를 정상 request와 함께 POST·PUT으로 보내 + 현재 `fileMap.keys` 검사를 우회하는 경로와 mutation 부작용을 actual endpoint로 고정한다. +- [x] **GREEN:** servlet request의 전체 part 이름 집합을 `{image, request}`와 비교해 초과 이름을 facade 진입 전에 + 공통 400으로 거부한다. +- [x] **CONTRACT TEST:** KO/EN/JA 오류 envelope, 파일형 미정의 part, 정상 생성·수정, 필수 part 누락, + request part 415와 no-side-effect를 확인한다. +- [x] **REFACTOR:** Character controller/test만 최소 변경하고 Character/common 영향 범위 회귀, + `ktlintCheck`, OpenAPI 정적 대조와 `git diff --check`를 기록한다. + +#### Phase 2 multipart 전체 part 이름 후속 Gate + +**Goal 실행 `P2-R12-GATE`:** `REV-065` 수정 뒤 Character POST·PUT의 파일·일반 form-field를 포함한 전체 part +이름과 기존 media type 경계를 재검토한다. + +- [x] **`P2-R12-GATE` 완료:** `P2-R12` 완료 후 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 Phase 2 + 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P2-R12` 완료. +- **완료 증거:** filename 없는 미정의 part 400/no-side-effect와 기존 multipart 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 OpenAPI schema 변경. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest --tests '*shouldRejectFilenameLessUndefinedMultipartPartBeforeSideEffects*' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +- 검증 기록(RED): 무엇: Character POST·PUT filename 없는 `unexpected` multipart part. 왜: `fileMap.keys` 검사가 일반 form-field part를 보지 못하는지 고정하기 위해. 어떻게: Phase 2~5 multipart와 Phase 4 genre focused RED 묶음을 실행했다. 결과: 신규 multipart/genre 36건이 실패했다. +- 검증 기록(GREEN): 무엇: Character multipart 전체 part 이름 검사. 왜: 파일형 part와 일반 form-field part 모두 `{image, request}` allow-list를 따라야 하기 때문이다. 어떻게: controller가 `fileMap.keys`와 servlet `parts` 이름을 모두 검사하도록 바꾸고 같은 focused 묶음을 재실행했다. 결과: `BUILD SUCCESSFUL in 1m 17s`였다. +- 검증 기록(GATE): Character/common 영향 범위 회귀, lint, diff 결과는 `P7-R10-GATE`에 통합 기록한다. + --- -### Phase 3: 오디오 콘텐츠 관리와 signed URL vertical slice +### Phase 3: 오디오 콘텐츠·댓글 관리와 signed URL vertical slice #### 목표 -선택한 AI 캐릭터 소유 오디오 콘텐츠 목록/검색/상세/생성/수정/soft delete와 관리자 재생용 signed URL을 제공한다. +선택한 AI 캐릭터 소유 오디오 콘텐츠 목록/검색/상세/생성/수정/soft delete, 댓글 CRUD와 관리자 재생용 signed URL을 +제공한다. #### 범위와 비범위 -- 포함: 콘텐츠 owner 검증, 기존 파일 처리/가격/공개/예약/번역/알림 parity, `AudioContentCloudFront` 재사용, private path 비노출. -- 제외: 콘텐츠 구매/좋아요/댓글, content upload/processing pipeline 변경, community audio 30분 정책 통합. +- 포함: 콘텐츠 owner 검증, 기존 파일 처리/가격/공개/예약/번역/알림 parity, 댓글 root/reply 조회·작성·수정·soft + delete, `AudioContentCloudFront` 재사용, private path 비노출. +- 제외: 콘텐츠 구매/좋아요, 캐릭터 직접 댓글, 댓글 hard delete·cascade, content upload/processing pipeline 변경, + community audio 30분 정책 통합. #### 선행 Phase 및 의존성 - Phase 1 target resolver. @@ -1454,6 +1898,8 @@ git diff --check - multipart optional `coverImage`, 필수 `request: UpdateCreatorAdminContentRequest`에서 `id` 제외. - Response: `data: null`. - 수정 `audioFile` 교체는 레거시 creator admin 수정 pipeline에 없어 제공하지 않는다. +- 댓글은 `GET|POST .../{contentId}/comments`, `PUT|DELETE .../{contentId}/comments/{commentId}`, + `GET .../{commentId}/replies`의 5개 operation을 사용한다. - 정식 전체 schema와 optional/nullable은 `api-contract.openapi.json`의 AudioContent operation을 따른다. 현재 구현의 `description`, `releaseDateUtc`, `audioSignedUrl`, `status`, `seriesIds`, `themeName`, `imageUrl` 별칭은 `P23-CONTRACT-3`에서 레거시 계약으로 정합화한다. @@ -1999,6 +2445,672 @@ git diff --check no-interaction 증거, focused/영향 범위 회귀와 lint·diff check 성공. - **범위 밖:** Gate에서 production code 수정, 기존 Phase 3 완료 이력 변경, Phase 4 기능 구현. +#### Phase 3 7차 리뷰 보완 + +- [x] **Task 3.19: 관리자 오디오 repository의 미사용 확장 제거** + +**Goal 실행 `P3-R9`:** `REV-022`의 실제 호출되지 않는 조회·시리즈 교체 helper와 그 전용 status enum을 제거해 +repository를 현재 owner-scoped 상세 조회 책임으로 축소한다. + +- **추적 review ID:** `REV-022`. +- **시작 조건:** `P2-R6-GATE` 완료와 `phase3-audio-content-review.md` 7차 리뷰 판정 존재. +- **완료 증거:** 호출 검색 결과와 일치하는 미사용 method/enum/import 제거 diff, owner-scoped 상세 조회 회귀, + content/common 영향 범위 회귀와 Progress 기록. +- **범위 밖:** 실제 사용 중인 `findByIdAndCreatorMemberId`, legacy repository/service, 콘텐츠·시리즈 동작 변경, + 인접 repository 리팩터링. +- **TDD 예외 사유:** 호출자가 없는 내부 코드 제거이며 외부 동작이나 계약을 추가하지 않는 동작 불변 리팩터링이다. +- **대체 검증 방법:** production/test 전체 호출 검색으로 제거 대상을 확정하고 상세·ownership 테스트를 focused 회귀한다. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentRepository.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentDto.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentOwnershipTest.kt` + +- [x] **REFACTOR:** `findPage`, `findSeriesIds`, `replaceSeriesIds`, 그 private helper와 + `AiCharacterAdminAudioContentStatus`를 제거하고 발생한 unused import만 정리한다. +- [x] **STATIC 확인:** `findByIdAndCreatorMemberId` 외 제거 대상의 호출이 0건인지 production/test 전체에서 확인한다. +- [x] **회귀 확인:** 콘텐츠 상세·ownership focused test, content/common 영향 범위 회귀와 `ktlintCheck`를 실행한다. + - 검증 기록: 무엇: `REV-022`의 관리자 오디오 repository 미사용 확장과 전용 status enum을 제거했다. 왜: 현재 + facade가 사용하는 repository 경계는 owner-scoped 상세 조회 `findByIdAndCreatorMemberId` 하나뿐이기 때문이다. 어떻게: + `findPage`, `findSeriesIds`, `replaceSeriesIds`, `hasActiveSeriesIds`, 관련 private helper와 + `AiCharacterAdminAudioContentStatus`를 제거하고 package-scoped 호출 검색을 실행했다. 결과: 대상 package 호출 검색은 + 출력이 없었고, 상세·ownership focused 명령은 `BUILD SUCCESSFUL in 3m 39s`, content/common 영향 범위 회귀는 + `BUILD SUCCESSFUL in 2m 22s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 31s`, `git diff --check`는 출력이 없었다. + +```bash +rg -n 'findPage|findSeriesIds|replaceSeriesIds|hasActiveSeriesIds|AiCharacterAdminAudioContentStatus' src/main src/test +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentControllerTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentOwnershipTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 3 7차 리뷰 Gate + +**Goal 실행 `P3-R9-GATE`:** `REV-022`의 미사용 코드 제거와 콘텐츠 동작 불변 증거를 재검토한다. + +- [x] **`P3-R9-GATE` 완료:** `P3-R9` 완료 후 위 static/focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 + 리뷰 문서와 Progress를 갱신한다. +- **시작 조건:** `P3-R9` 완료. +- **완료 증거:** `REV-022` 처리 완료, owner-scoped 상세 동작 유지, focused/영향 범위 회귀와 lint·diff 성공. +- **범위 밖:** Gate에서 production code 수정, legacy 콘텐츠·시리즈 동작 변경. + - 검증 기록: 무엇: `P3-R9-GATE`에서 `REV-022` 처리 완료와 Phase 3 7차 리뷰 종결을 확인했다. 왜: Phase 4 7차 + 보완의 시작 조건이 `P3-R9-GATE` 완료이기 때문이다. 어떻게: `phase3-audio-content-review.md` 7차 리뷰 후속 판정을 + 갱신하고 위 static/focused/영향 범위 회귀, lint, diff check 결과를 대조했다. 결과: `REV-022`는 처리 완료로 + 판정했고 Phase 3은 19/19 완료 상태로 동기화했다. + +#### Phase 3 8차 리뷰 보완 + +- [x] **Task 3.20: 오디오 생성 날짜·시간대 의미 검증 복구** + +**Goal 실행 `P3-R10`:** `REV-030`의 잘못된 `releaseDate` 형식과 `timezone` 값을 legacy service 호출 전에 +400 `common.error.invalid_request`로 거부한다. + +- **추적 review ID:** `REV-030`. +- **시작 조건:** `P3-R9-GATE` 완료와 `phase3-audio-content-review.md` 8차 리뷰 판정 존재. +- **완료 증거:** 잘못된 날짜 형식·시간대의 actual endpoint RED, KO/EN/JA 400 envelope과 DB/S3/event 0건, + 정상 생성 및 content/common 영향 범위 회귀, lint·diff와 Progress 기록. +- **범위 밖:** OpenAPI field/schema 변경, legacy `AudioContentService` 전역 동작 변경, 공통 예외 handler에 + `DateTimeException`을 일괄 client 오류로 추가, upload/processing pipeline 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** 그 밖의 필드는 유효한 생성 request에서 `releaseDate="not-a-date"`와 + `timezone="Invalid/Zone"`을 각각 보내 현재 500 `common.error.unknown`이 반환되는지 확인한다. +- [x] **RED:** 두 입력을 KO/EN/JA actual endpoint matrix로 고정하고 AudioContent·S3·event가 요청 전후 + 변하지 않음을 단언한다. +- [x] **GREEN:** strict JSON parse 결과를 재사용해 `releaseDate`의 `yyyy-MM-dd HH:mm` 형식과 `timezone`의 + `ZoneId`를 legacy service 호출 전에 검증하고 `DateTimeException`을 해당 입력 경계에서만 400으로 변환한다. +- [x] **REFACTOR:** 정상 예약/즉시 생성과 기존 preview/theme 오류 key를 유지하고 content/common 영향 범위 회귀, + `ktlintCheck`, `git diff --check`를 실행해 Progress에 기록한다. + - 검증 기록(RED): 무엇: 오디오 생성의 잘못된 `releaseDate` 형식과 `timezone` 의미 오류를 KO/EN/JA actual endpoint로 고정했다. 왜: strict JSON parse는 통과하지만 legacy `AudioContentService`의 Java time 변환 예외가 500으로 분류됐기 때문이다. 어떻게: `AiCharacterAdminAudioContentCreateTest`에 6개 matrix를 추가하고 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest`를 실행했다. 결과: 신규 6건이 400 기대 assertion에서 실패해 `BUILD FAILED in 1m 4s`를 확인했다. + - 검증 기록(GREEN/REFACTOR): 무엇: v2 facade 생성 경계에서 `releaseDate`를 `yyyy-MM-dd HH:mm`으로, `timezone`을 `ZoneId`로 legacy 호출 전에 검증했다. 왜: 전역 handler나 legacy service 영향 없이 신규 관리자 생성 API의 client 오류만 400으로 분류하기 위해. 어떻게: strict parse 결과를 재사용해 `DateTimeException`을 `common.error.invalid_request`로 변환하고 focused/영향 범위 회귀를 실행했다. 결과: create focused는 `BUILD SUCCESSFUL in 1m 4s`, create+controller focused는 `BUILD SUCCESSFUL in 1m 16s`, content/common 영향 범위 회귀는 `BUILD SUCCESSFUL in 2m 58s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 38s`였다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentControllerTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 3 8차 리뷰 Gate + +**Goal 실행 `P3-R10-GATE`:** `REV-030`의 의미 검증과 오류·no-side-effect 계약을 재검토한다. + +- [x] **`P3-R10-GATE` 완료:** `P3-R10` 완료 후 위 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 + 리뷰 문서와 Progress를 갱신한다. +- **시작 조건:** `P3-R10` 완료. +- **완료 증거:** `REV-030` 처리 완료, 잘못된 날짜·시간대 400/no-side-effect, 정상 생성 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 공개 API schema 변경. + - 검증 기록: 무엇: `P3-R10-GATE`에서 `REV-030` 처리 완료와 Phase 3 8차 리뷰 종결을 확인했다. 왜: Phase 4 2차 보완으로 넘어가기 전에 오디오 생성 의미 검증과 영향 범위 회귀가 완료됐는지 판정하기 위해. 어떻게: `phase3-audio-content-review.md`에 처리 결과를 누적하고 위 focused/영향 범위 회귀와 lint 결과를 대조했다. 결과: 잘못된 날짜·시간대는 400/no-side-effect로 고정됐고 Phase 3은 20/20 완료 상태로 동기화했다. + +#### Phase 3 9차 리뷰 보완 + +- [x] **Task 3.21: 오디오 상세의 예약 공개일 레거시 표시 복구** + +**Goal 실행 `P3-R11`:** 미래 예약 콘텐츠 상세의 `releaseDate`를 KO/EN/JA 레거시 형식으로 반환하고, 공개 시각이 지난 +콘텐츠는 기존처럼 null을 반환한다. + +- **추적 review ID:** `REV-036`. +- **시작 조건:** `P2-R7-GATE` 완료와 `phase3-audio-content-review.md` 9차 정적 리뷰 판정 존재. +- **완료 증거:** 미래·과거 예약일과 KO/EN/JA locale actual endpoint RED/GREEN, signed URL·전체 상세 DTO 불변, + content/common 영향 범위 회귀와 Progress 기록. +- **범위 밖:** OpenAPI schema 변경, 레거시 `AudioContentService` 변경, 예약 공개·signed URL 정책 재설계, + 목록 response mapping 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentMapper.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentQueryTest.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` +- Confirm: `src/main/resources/messages*.properties` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** 미래 UTC `releaseDate`를 가진 owner 콘텐츠 상세가 현재 모든 locale에서 null을 반환하는 계약 차이를 + actual endpoint로 고정한다. +- [x] **GREEN:** 기존 `SodaMessageSource`와 `LangContext`를 사용해 legacy의 미래 여부, UTC→Asia/Seoul 변환, + `content.release_date.format` 포맷을 동일하게 적용한다. +- [x] **CONTRACT TEST:** 미래 예약일은 KO/EN/JA 형식 문자열, 현재 또는 과거 예약일은 null이며 나머지 상세 필드와 + signed URL 결과가 변하지 않는지 확인한다. +- [x] **REFACTOR:** 단일 mapper 안에서 legacy 규칙만 최소 이관하고 content package와 공통 authorization/error 회귀, + `ktlintCheck`, `git diff --check`를 실행해 기록한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentQueryTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 3 9차 리뷰 Gate + +**Goal 실행 `P3-R11-GATE`:** `REV-036`의 미래·과거 예약일과 locale별 상세 응답 parity를 재검토한다. + +- [x] **`P3-R11-GATE` 완료:** `P3-R11` 완료 후 위 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 리뷰 문서와 + Progress를 갱신한다. +- **시작 조건:** `P3-R11` 완료. +- **완료 증거:** review ID 처리 완료, 미래 KO/EN/JA `releaseDate`, 과거 null, signed URL·상세 DTO 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 공개 API schema 변경. + + - 검증 기록(RED): 무엇: 미래 예약 오디오 상세 `releaseDate` KO/EN/JA. 왜: mapper가 모든 상세 `releaseDate`를 null로 고정하는지 확인하기 위해. 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentQueryTest`를 실행했다. 결과: 신규 미래 locale 3건이 기대 문자열 대비 null로 실패했다. + - 검증 기록(GREEN/GATE): 무엇: 미래 예약일의 레거시 locale 표시와 과거 null. 왜: 기존 `content.release_date.format`과 UTC→Asia/Seoul 변환을 신규 상세 endpoint에 맞추기 위해. 어떻게: 같은 focused 명령 재실행 후 targeted/전체/lint/OpenAPI/mapping/diff 검증을 실행했다. 결과: focused query test와 전체 검증이 모두 성공했다. + +#### Phase 3 11차 리뷰 보완 + +- [x] **Task 3.22: 오디오 생성 primitive 필드의 null·누락 계약 강제** + +**Goal 실행 `P3-R12`:** 오디오 생성의 필수 `price` 누락·null과 non-null primitive의 명시적 null을 JVM 기본값으로 +보정하지 않고 파일 업로드·DB·event 전에 400으로 거부하며, optional 필드 생략 시 기존 기본값은 유지한다. + +- **추적 review ID:** `REV-041`. +- **시작 조건:** `P2-R8-GATE` 완료와 `phase3-audio-content-review.md` 11차 정적 리뷰 판정 존재. +- **완료 증거:** 필수 `price` 누락·null과 optional primitive null의 actual endpoint RED/GREEN/no-side-effect, + optional 생략·정상 생성 회귀, content/common 영향 범위 회귀와 Progress 기록. +- **범위 밖:** 전역 `ObjectMapper` 설정, 레거시 DTO/service 변경, OpenAPI schema·기본값 변경, 별도 입력 범위 정책 추가. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/content/CreateAudioContentRequest.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** `price` 누락·null과 `themeId`, `isAdult`, `isGeneratePreview`, `isOnlyRental`, + `isPointAvailable`, `isCommentAvailable`, `isFullDetailVisible`의 명시적 null이 현재 기본값으로 처리되는지 actual + endpoint matrix로 고정하고 AudioContent·S3·event 무변경을 단언한다. +- [x] **GREEN:** v2 생성 경계에서 OpenAPI required/non-null primitive의 존재와 null 여부를 검증하고 + `common.error.invalid_request` 400으로 변환한다. +- [x] **CONTRACT TEST:** optional primitive를 생략하면 OpenAPI·레거시 기본값을 유지하고 정상 예약·즉시 생성, + 기존 미지 필드·날짜·시간대 검증이 변하지 않는지 확인한다. +- [x] **REFACTOR:** 기존 strict parse 결과를 재사용하는 최소 검증으로 제한하고 content/common 영향 범위 회귀, + `ktlintCheck`, `git diff --check`를 실행해 기록한다. + + - 검증 기록(RED): 무엇: 오디오 생성 `price` 누락·null과 primitive field explicit null actual POST. 왜: Jackson primitive 기본값 `0`/`false` 보정으로 파일 업로드·DB·event 부작용이 발생할 수 있는지 재현하기 위해. 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest --tests "*shouldRejectMissingOrNullPriceBeforeUpload" --tests "*shouldRejectNullPrimitiveFieldsBeforeUpload"`를 실행했다. 결과: 8개 invocation이 `status().isBadRequest` 기대에서 실패해 `BUILD FAILED`였다. `themeId:null`은 기존 missing-theme 검증으로 이미 400이었다. + - 검증 기록(GREEN): 무엇: v2 오디오 생성 request reader의 primitive null/누락 거부. 왜: 전역 mapper·레거시 DTO/service 변경 없이 v2 생성 경계에서 OpenAPI required/non-null primitive 계약을 강제하기 위해. 어떻게: 같은 명령을 재실행했다. 결과: `BUILD SUCCESSFUL in 42s`였다. + - 검증 기록(GATE): 무엇: optional 생략 기본값, 정상 생성, 날짜/시간대·미지 필드 검증, content/common 영향 범위와 lint/diff. 왜: `REV-041` 보완이 기존 오디오 생성·공통 오류/인가 계약을 회귀시키지 않는지 확인하기 위해. 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest`, `./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*" --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest`, `./gradlew ktlintCheck`, `git diff --check`를 실행했다. 결과: 각 Gradle 명령은 `BUILD SUCCESSFUL`이었고 `git diff --check`는 출력이 없었다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 3 11차 리뷰 Gate + +**Goal 실행 `P3-R12-GATE`:** `REV-041`의 오디오 생성 primitive nullability와 기본값·부작용 경계를 재검토한다. + +- [x] **`P3-R12-GATE` 완료:** `P3-R12` 완료 후 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 리뷰 문서와 + Progress를 갱신한다. +- **시작 조건:** `P3-R12` 완료. +- **완료 증거:** review ID 처리 완료, invalid primitive 400/no-side-effect, 생략 기본값과 정상 생성 회귀 성공. +- **범위 밖:** Gate에서 production code, 레거시 API 또는 공개 API schema 변경. + +#### Phase 3 후속 기능 보완 + +- [x] **Task 3.23: 오디오 콘텐츠 댓글 CRUD** + +**Goal 실행 `P3-R13`:** target AI 캐릭터 소유 활성 오디오 콘텐츠의 원댓글·답글을 조회하고 target AI 명의로 +작성·수정하며, 해당 콘텐츠에 달린 댓글·답글은 작성자와 관계없이 row 단위로 soft delete한다. + +- **추적 review ID:** `REV-045`. +- **시작 조건:** `P2-R9-GATE` 완료와 PRD·OpenAPI의 승인된 댓글 행위자·소유권 계약 존재. +- **완료 증거:** 5개 actual endpoint, root/reply 조회, target AI 작성, 작성자 제한 수정, owner 범위 삭제, + cross-resource/parent/character 격리, idempotent delete와 exact response 회귀. +- **범위 밖:** 캐릭터 직접 댓글 삭제, 댓글 hard delete·cascade, 새 pagination wrapper, 레거시/public endpoint 변경. + +**Interfaces:** + +- `GET|POST /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments` +- `PUT|DELETE /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments/{commentId}` +- `GET /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments/{commentId}/replies` +- 조회 query: 필수 `timezone`, `page`, `size`; response `GetAudioContentCommentListResponse(totalCount, items)`. +- 작성 body: 필수 `comment`, optional/nullable `parentId`, optional `isSecret=false`, optional/nullable `languageCode`. +- 수정 body: 필수 `comment`; mutation 성공 `data: null`. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentController.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentDto.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentRepository.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCommentTest.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt` +- Modify: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** root/reply 목록의 `timezone/page/size`, `totalCount/items`, target 소유 활성 콘텐츠 경계를 actual + GET으로 고정한다. +- [x] **RED:** root와 reply 작성 시 저장된 `member`가 target `creatorMember`이고, `parentId`가 같은 콘텐츠의 활성 + root가 아니면 400/no insert/no event인지 고정한다. +- [x] **RED:** target AI가 작성한 활성 댓글/답글만 수정되고 팬 작성, 다른 콘텐츠·캐릭터 댓글 수정은 + 400/no mutation인지 고정한다. +- [x] **RED:** target 소유 콘텐츠의 팬/AI 댓글·답글 삭제는 해당 row만 비활성화하고 하위 답글은 유지하며, 이미 + 비활성인 row는 200 no-op인지 고정한다. +- [x] **GREEN:** 기존 `AudioContentCommentService`의 조회·작성·수정 의미를 재사용하되 facade에서 target, + active owner, 동일 리소스 root parent, actor 권한을 먼저 검증한다. +- [x] **CONTRACT TEST:** 미지 필드, 잘못된 page/size/timezone, cross-resource ID의 400 + envelope와 모든 mutation의 `data: null`을 확인한다. +- [x] **REFACTOR:** 댓글 전용 추상화나 cascade 로직을 추가하지 않고 content/common 영향 범위 회귀, + `ktlintCheck`, OpenAPI 상태, `git diff --check`를 기록한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCommentTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 3 후속 기능 Gate + +**Goal 실행 `P3-R13-GATE`:** `REV-045`의 댓글 actor·owner·parent·soft delete 경계를 재검토한다. + +- [x] **`P3-R13-GATE` 완료:** `P3-R13` 완료 후 focused/영향 범위 회귀와 OpenAPI·lint·diff를 fresh 실행하고 + Phase 3 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P3-R13` 완료. +- **완료 증거:** 5개 operation, 레거시 목록 parity, AI 작성·수정 제한, owner 범위 row soft delete, + cross-resource no-side-effect 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 public/legacy API 변경. + +#### Phase 3 UTC 날짜 계약 보완 + +- [x] **Task 3.24: 오디오 생성·상세·댓글의 timezone 제거와 UTC 계약 정합화** + +**Goal 실행 `P3-R14`:** 신규 관리자 오디오 생성에서 `timezone`을 제거하고 nullable `releaseDate`를 UTC +`date-time`으로 받으며, 상세·댓글·답글 조회도 `timezone` 없이 기존 날짜 필드를 ISO-8601 UTC(`Z`)로 반환한다. + +- **추적 review ID:** `REV-050`. +- **시작 조건:** `P3-R13-GATE` 완료와 `DEC-UTC-DATE-001` 및 OpenAPI 2.2.0 계약 존재. +- **완료 증거:** 오디오 생성·상세·댓글·답글 4개 actual operation의 query/body·UTC exact JSON RED/GREEN, + 기존 상세 `releaseDate` null/노출 조건·댓글 pagination/ownership 보존, legacy/public 회귀와 Progress 기록. +- **범위 밖:** 오디오 목록의 날짜 필드 변경, legacy/public request/response 변경, 로컬 시각+timezone 병행 지원, + 신규 dependency·DDL, 댓글 mutation 의미 변경. + +**Interfaces:** + +- `POST /api/v2/admin/ai-characters/{characterId}/audio-contents`: multipart `request`에 `timezone`이 없고, + optional/nullable `releaseDate`는 ISO-8601 UTC(`Z`)다. +- `GET /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}`: query 없음. nullable + `releaseDate`는 기존 미래 예약일 노출·현재/과거 null 조건을 유지하고 값이 있으면 ISO-8601 UTC(`Z`)다. +- `GET .../audio-contents/{contentId}/comments`와 `GET .../comments/{commentId}/replies`: query는 `page`, + `size`만 사용하고 `totalCount`, `items`와 각 item의 기존 `date` 필드명을 유지한다. `date` 값은 ISO-8601 UTC(`Z`)다. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentController.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentDto.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentMapper.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentRepository.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentQueryTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCommentTest.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt` +- Modify: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** `timezone` 없는 상세·댓글·답글 GET이 현재 400이고, 현재 상세 `releaseDate`와 댓글 `date`가 + locale/legacy 문자열인 계약 차이를 actual endpoint로 고정한다. +- [x] **RED:** `timezone` 없는 생성 request의 UTC `releaseDate`가 현재 legacy 형식 검증에서 거부되는 것과, + 로컬 문자열·non-UTC offset·`timezone` 미지 필드가 400/no upload/no DB/no event인지 고정한다. +- [x] **GREEN:** v2 전용 생성 DTO에서 `timezone`을 제거하고 UTC instant를 한 번 파싱한다. 초 단위를 버리는 문자열 + 재포맷을 하지 않고 UTC `LocalDateTime`을 내부 생성 경계에 전달하되, 기존 legacy 생성 진입점의 외부 계약은 유지한다. +- [x] **GREEN:** controller/facade의 세 GET signature와 timezone 검증을 제거하고, 상세 mapper와 v2 owner-scoped + 댓글 query/mapping에서 기존 `toUtcIso()`를 재사용해 `releaseDate`/`date`만 UTC로 직렬화한다. +- [x] **CONTRACT TEST:** 생성 `releaseDate` 생략·null·정상 UTC, 상세 미래 UTC·현재/과거 null, root/reply UTC + `date`, `totalCount/items`, page/size와 target/owner/block/secret 필터가 기존 의미를 유지하는지 확인한다. +- [x] **REFACTOR:** legacy/public controller·DTO·timezone 동작을 변경하지 않고 v2 경계의 최소 분기만 남긴다. + content/common 및 직접 영향 legacy 회귀, `ktlintCheck`, OpenAPI 상태, `git diff --check`를 기록한다. + +```bash +./gradlew test \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentQueryTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCommentTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest \ + --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest +./gradlew ktlintCheck +jq -e ' + [.paths[] | to_entries[] | select(.key | IN("get", "post", "put", "delete")) | .value] as $operations + | ($operations | length) == 36 + and ([$operations[] | select(.["x-implementation-status"] == "implemented")] | length) == 34 + and ([$operations[] | select(.["x-implementation-status"] == "alignment-required")] | length) == 2 +' docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json +git diff --check +``` + +#### Phase 3 UTC 날짜 계약 Gate + +**Goal 실행 `P3-R14-GATE`:** `REV-050`의 오디오 생성·상세·댓글 UTC 계약과 legacy/public 격리를 재검토한다. + +- [x] **`P3-R14-GATE` 완료:** `P3-R14` 완료 후 focused/영향 범위 회귀와 OpenAPI·lint·diff를 fresh 실행하고 + Phase 3 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P3-R14` 완료. +- **완료 증거:** 오디오 4개 operation의 timezone 제거·UTC `releaseDate`/`date`, 기존 null/노출·pagination·ownership + 및 legacy/public 계약 회귀 성공, OpenAPI 해당 4개 operation `implemented`. +- **범위 밖:** Gate에서 production code 또는 public/legacy API schema 변경. + +- **`P3-R14` / `P3-R14-GATE` 검증(2026-07-29):** RED는 create/query/comment focused 명령에서 48개 중 9개가 + 기존 `timezone` 필수·legacy 날짜 포맷 차이로 실패해 `BUILD FAILED in 1m 1s`였다. v2 전용 생성 DTO와 UTC 내부 생성 + 경계, 세 GET query 제거·거부, `toUtcIso()` 응답 mapping 후 같은 focused 명령은 `BUILD SUCCESSFUL in 2m 13s`였다. + content/common·legacy 영향 범위 회귀는 `BUILD SUCCESSFUL in 1m 35s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 23s`였다. + OpenAPI 36개 operation 상태는 `implemented` 34개와 `alignment-required` 2개를 확인했고, `git diff --check`는 출력이 + 없었다. 전체 `./gradlew test`는 v2 audio/content-common 및 legacy service 회귀가 직접 영향 범위를 포함하므로 생략했다. + +#### Phase 3 pagination 계약 후속 보완 + +- [x] **Task 3.25: 오디오 댓글·답글 목록의 optional page/size 기본값 복구** + +**Goal 실행 `P3-R15`:** OpenAPI 공통 `Page`, `Size` 계약대로 오디오 댓글·답글 목록에서 `page`, `size` 생략과 +부분 생략을 허용하고 각각 `0`, `20`을 적용한다. + +- **추적 review ID:** `REV-052`. +- **시작 조건:** `P2-R10-GATE`, `P3-R14-GATE` 완료와 OpenAPI의 optional `Page`/`Size` 계약 존재. +- **완료 증거:** 두 actual GET에서 query 전체 생략·`page`만 지정·`size`만 지정 시 200과 기본값이 적용되고, + 음수 page·1 미만 size·미지 query는 400이며 기존 pagination·UTC date·ownership/filter 결과가 유지된다. +- **범위 밖:** OpenAPI pagination schema 변경, FanTalk 보정 정책 적용, 댓글 mutation·legacy/public API 변경, + 신규 dependency·DDL. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentController.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCommentTest.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** 댓글·답글 GET에서 query 전체 생략과 한쪽만 지정한 요청이 현재 400인 것을 actual endpoint로 고정한다. +- [x] **GREEN:** controller의 `page`, `size`에 OpenAPI 기본값을 적용하고 facade의 query 이름 검증은 미지 + parameter만 거부하도록 최소 수정한다. +- [x] **CONTRACT TEST:** 전체·부분 생략, 유효 page/size, 음수 page, 1 미만 size, `timezone` 등 미지 query와 + 기존 UTC exact JSON을 확인한다. +- [x] **REFACTOR:** 다른 목록 API와 legacy/public pagination은 변경하지 않고 content/common 영향 범위 회귀, + `ktlintCheck`, OpenAPI 정적 대조, `git diff --check`를 기록한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCommentTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 3 pagination 계약 후속 Gate + +**Goal 실행 `P3-R15-GATE`:** `REV-052` 수정 뒤 두 GET의 optional pagination과 미지 query 거부 경계를 재검토한다. + +- [x] **`P3-R15-GATE` 완료:** `P3-R15` 완료 후 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 + Phase 3 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P3-R15` 완료. +- **완료 증거:** 두 operation의 기본값·부분 생략·범위 오류·미지 query 및 기존 UTC/ownership 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 OpenAPI schema 변경. + +- **`P3-R15` / `P3-R15-GATE` 검증(2026-07-29):** RED는 `AiCharacterAdminAudioContentCommentTest` 9건 중 + 댓글·답글의 전체 생략 `isOk` 기대 2건이 각각 실패해 `BUILD FAILED in 45s`였다. controller의 두 목록 query에 + `page=0`, `size=20` 기본값을 적용하고 facade가 known query 이름의 부분집합을 허용하도록 수정한 뒤 같은 focused + 명령은 `BUILD SUCCESSFUL in 40s`였다. content package와 `AiCharacterAdminErrorContractTest` 영향 범위 회귀는 + `BUILD SUCCESSFUL in 2m 58s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 37s`였다. 전체 `./gradlew test`는 + controller/facade와 해당 actual endpoint test만 변경했고 직접 영향 범위 회귀가 이를 포함하므로 실행하지 않았다. + +#### Phase 3 multipart request part 계약 후속 보완 + +- [x] **Task 3.26: 오디오 생성·수정 request part의 application/json 강제** + +**Goal 실행 `P3-R16`:** 오디오 생성·수정 multipart의 `request` part가 OpenAPI encoding대로 +`application/json`일 때만 handler에 진입하고, 그 밖의 part media type은 공통 415 계약으로 거부되도록 정합화한다. + +- **추적 review ID:** `REV-056`. +- **시작 조건:** `P3-R15-GATE` 완료와 OpenAPI의 두 multipart request encoding 계약 존재. +- **완료 증거:** POST·PUT actual endpoint가 JSON part는 기존 strict parse·file/series/UTC 의미를 유지하고, + `text/plain`·content type 누락 등은 localized 415 `ApiResponse.error`, 표준 `Accept` header, + S3·DB·processing/event no-side-effect를 반환한다. +- **범위 밖:** JSON schema·strict reader·file empty 정책, series/UTC 의미, legacy/public endpoint, + OpenAPI·신규 dependency·DDL 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentController.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentUpdateTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** 현재 200을 기대하는 `text/plain` request part 수정 테스트를 OpenAPI의 415/no-side-effect 계약으로 + 교정하고 생성·수정 actual endpoint에서 재현한다. +- [x] **GREEN:** v2 controller 경계에서 request part의 `application/json` 호환 여부만 확인하고 기존 String + strict reader에 동일 payload를 전달한다. +- [x] **CONTRACT TEST:** KO/EN/JA 415 envelope, `Accept` header, content type 누락과 정상 JSON, + 필수 part 누락 400 및 기존 UTC/file/series 회귀를 확인한다. +- [x] **REFACTOR:** content facade/domain 로직을 변경하지 않고 content/common 영향 범위 회귀, + `ktlintCheck`, OpenAPI 정적 대조, `git diff --check`를 기록한다. + +```bash +./gradlew test \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentUpdateTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentControllerTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 3 multipart request part 계약 후속 Gate + +**Goal 실행 `P3-R16-GATE`:** `REV-056` 수정 뒤 AudioContent POST·PUT의 part-level JSON-only·415 경계를 재검토한다. + +- [x] **`P3-R16-GATE` 완료:** `P3-R16` 완료 후 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 + Phase 3 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P3-R16` 완료. +- **완료 증거:** 정상 JSON·미지원/누락 media type·필수 part·no-side-effect 및 UTC/file/series 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 OpenAPI schema 변경. + +- **`P3-R16` / `P3-R16-GATE` 검증(2026-07-29):** RED는 새 POST·PUT의 `text/plain`·content type 누락 + KO/EN/JA 415 기대와 기존 controller의 text/plain 성공 기대를 포함해 focused 83건 중 13건이 200 응답으로 실패해 + `BUILD FAILED in 1m 15s`였다. controller의 request multipart header만 `application/json` 호환 여부를 확인하도록 + 하고 기존 strict String reader와 facade를 그대로 둔 뒤 focused는 `BUILD SUCCESSFUL in 56s`, content package와 + `AiCharacterAdminErrorContractTest` 영향 범위 회귀는 `BUILD SUCCESSFUL in 1m 55s`, `ktlintCheck`는 + `BUILD SUCCESSFUL in 16s`였다. OpenAPI 두 AudioContent multipart encoding은 `application/json`으로 정적 대조했고, + `git diff --check`는 출력이 없었다. 전체 `./gradlew test`는 v2 AudioContent controller와 실제 endpoint test에 + 변경을 한정했고 content/common 영향 범위 회귀가 이를 포함하므로 실행하지 않았다. + +#### Phase 3 multipart part 이름 계약 후속 보완 + +- [x] **Task 3.27: 오디오 생성·수정의 미정의 multipart part 거부** + +**Goal 실행 `P3-R17`:** AudioContent 생성은 `contentFile`, `coverImage`, `request`, 수정은 +`coverImage`, `request` 외 multipart part를 business mutation 전에 400 `common.error.invalid_request`로 거부한다. + +- **추적 review ID:** `REV-061`. +- **시작 조건:** `P2-R11-GATE` 완료와 `phase3-audio-content-review.md` 13차 정적 리뷰 판정 존재. +- **완료 증거:** POST·PUT의 미정의 part 400 KO/EN/JA envelope와 S3·DB·processing/event no-side-effect, + 수정의 기존 `audioFile`·`contentFile` 거부 및 정상/필수 part/415 회귀. +- **범위 밖:** OpenAPI schema, file empty·UTC·series 의미, 전역 multipart resolver, legacy/public endpoint, + 신규 dependency·DDL. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentController.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentUpdateTest.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** 생성·수정에 `unexpected` part를 추가해 현재 정상 mutation으로 진행되는 경로와 side effect를 actual + endpoint로 고정한다. +- [x] **GREEN:** 실제 part 이름 집합을 생성 `{contentFile, coverImage, request}`, 수정 + `{coverImage, request}`와 비교해 초과 이름을 공통 400으로 거부한다. 수정 controller의 기존 + `audioFile`·`contentFile` 인자는 제거하고 같은 미정의 part 검증으로 거부한다. +- [x] **CONTRACT TEST:** KO/EN/JA 400 envelope, 정상 생성·수정, 필수 part 누락, 빈 파일, request part 415, + 수정 파일 교체 미지원과 no-side-effect를 확인한다. +- [x] **REFACTOR:** content controller/test만 최소 변경하고 content/common 영향 범위 회귀, + `ktlintCheck`, OpenAPI 정적 대조와 `git diff --check`를 기록한다. + +**처리 기록 (2026-07-29 / P3-R17):** + +- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest --tests '*shouldRejectUndefinedMultipartPartBeforeSideEffects' --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentUpdateTest --tests '*shouldRejectUndefinedMultipartPartBeforeSideEffects'` → 새 테스트 6개가 400 기대 대비 기존 정상 mutation 경로로 실패. +- GREEN/focused: 동일 focused 명령 재실행 → `BUILD SUCCESSFUL in 1m 33s`. +- 파일 교체 회귀: `audioFile`, `contentFile` 수정 part를 `shouldRejectFileReplacementPartBeforeSideEffects`로 통합 확인, focused 재실행 → `BUILD SUCCESSFUL in 1m 59s`. +- 영향 범위: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*' --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest` → `BUILD SUCCESSFUL in 2m 27s`. +- 정적 검증: `./gradlew ktlintCheck` → `BUILD SUCCESSFUL in 31s`; `git diff --check` → 출력 없음. +- OpenAPI 대조: `api-contract.openapi.json`의 AudioContent 생성 schema는 required `{contentFile, coverImage, request}`, 수정 schema는 `{coverImage, request}` 및 `additionalProperties: false` 유지 확인. + +#### Phase 3 multipart part 이름 계약 후속 Gate + +**Goal 실행 `P3-R17-GATE`:** `REV-061` 수정 뒤 AudioContent POST·PUT의 exact part 이름과 기존 파일·media type +경계를 재검토한다. + +- [x] **`P3-R17-GATE` 완료:** `P3-R17` 완료 후 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 Phase 3 + 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P3-R17` 완료. +- **완료 증거:** 미정의 part 400/no-side-effect, 정상·필수·빈 파일·415·수정 교체 미지원 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 OpenAPI schema 변경. + +**Gate 기록 (2026-07-29 / P3-R17-GATE):** + +- Focused: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest --tests '*shouldRejectUndefinedMultipartPartBeforeSideEffects' --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentUpdateTest --tests '*shouldRejectUndefinedMultipartPartBeforeSideEffects' --tests '*shouldRejectFileReplacementPartBeforeSideEffects'` → `BUILD SUCCESSFUL in 1m 30s`. +- 영향 범위: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*' --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest` → `BUILD SUCCESSFUL in 2m 14s`. +- 정적 검증: `./gradlew ktlintCheck` → `BUILD SUCCESSFUL in 1s`; `git diff --check` → 출력 없음. +- 판정: AudioContent POST·PUT exact multipart part 이름과 기존 필수/빈 파일/request 415/파일 교체 미지원 회귀가 모두 통과해 Phase 3 완료. + +#### Phase 3 multipart 일반 form-field part 후속 보완 + +- [x] **Task 3.28: 오디오 콘텐츠 생성·수정의 전체 multipart part 이름 검증** + +**Goal 실행 `P3-R18`:** AudioContent POST·PUT에서 파일 part뿐 아니라 filename 없는 일반 form-field part를 포함한 +모든 multipart part 이름을 검사해 생성 `{contentFile, coverImage, request}`, 수정 `{coverImage, request}` 외 이름을 +mutation 전에 400 `common.error.invalid_request`로 거부한다. + +- **추적 review ID:** `REV-066`. +- **시작 조건:** `P2-R12-GATE` 완료와 `phase3-audio-content-review.md` 8차 정적 리뷰 판정 존재. +- **완료 증거:** filename 없는 `unexpected` part의 KO/EN/JA 400 envelope와 S3·DB·processing·event + no-side-effect, 기존 파일형 미정의 part·수정 파일 교체 거부·필수/빈 파일·request part 415 회귀 성공. +- **범위 밖:** OpenAPI schema, 파일 교체 지원, 전역 multipart resolver, legacy/public endpoint, 신규 dependency·DDL. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentController.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentUpdateTest.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** filename 없는 `unexpected` 일반 form-field part를 정상 request와 함께 POST·PUT으로 보내 현재 + `fileMap.keys` 검사를 우회하는 경로와 mutation 부작용을 actual endpoint로 고정한다. +- [x] **GREEN:** servlet request의 전체 part 이름 집합을 operation별 허용 집합과 비교해 초과 이름을 facade 진입 + 전에 공통 400으로 거부한다. +- [x] **CONTRACT TEST:** KO/EN/JA 오류 envelope, 기존 파일형 미정의 part, 수정 파일 교체 거부, 정상·필수·빈 파일, + request part 415와 no-side-effect를 확인한다. +- [x] **REFACTOR:** AudioContent controller/test만 최소 변경하고 content/common 영향 범위 회귀, + `ktlintCheck`, OpenAPI 정적 대조와 `git diff --check`를 기록한다. + +#### Phase 3 multipart 전체 part 이름 후속 Gate + +**Goal 실행 `P3-R18-GATE`:** `REV-066` 수정 뒤 AudioContent POST·PUT의 파일·일반 form-field를 포함한 전체 part +이름과 기존 파일·media type 경계를 재검토한다. + +- [x] **`P3-R18-GATE` 완료:** `P3-R18` 완료 후 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 Phase 3 + 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P3-R18` 완료. +- **완료 증거:** filename 없는 미정의 part 400/no-side-effect와 기존 multipart 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 OpenAPI schema 변경. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest --tests '*shouldRejectFilenameLessUndefinedMultipartPartBeforeSideEffects*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentUpdateTest --tests '*shouldRejectFilenameLessUndefinedMultipartPartBeforeSideEffects*' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +- 검증 기록(RED): 무엇: AudioContent POST·PUT filename 없는 `unexpected` multipart part. 왜: `fileMap.keys` 검사가 일반 form-field part를 보지 못하는지 고정하기 위해. 어떻게: Phase 2~5 multipart와 Phase 4 genre focused RED 묶음을 실행했다. 결과: 신규 multipart/genre 36건이 실패했다. +- 검증 기록(GREEN): 무엇: AudioContent multipart 전체 part 이름 검사. 왜: 파일형 part와 일반 form-field part 모두 생성 `{contentFile, coverImage, request}`, 수정 `{coverImage, request}` allow-list를 따라야 하기 때문이다. 어떻게: controller가 `fileMap.keys`와 servlet `parts` 이름을 모두 검사하도록 바꾸고 같은 focused 묶음을 재실행했다. 결과: `BUILD SUCCESSFUL in 1m 17s`였다. +- 검증 기록(GATE): content/common 영향 범위 회귀, lint, diff 결과는 `P7-R10-GATE`에 통합 기록한다. + +#### Phase 3 15차 리뷰 보완 + +- [x] **Task 3.29: v2 오디오 생성 preview 시간 검증 복구** + +**Goal 실행 `P3-R19`:** `REV-072`에 따라 v2 오디오 생성도 기존 creator 생성과 동일하게 +`previewStartTime`·`previewEndTime`의 쌍, 형식, 최소 15초 규칙을 파일 업로드와 DB·event 부작용 전에 검증한다. + +- **추적 review ID:** `REV-072`. +- **시작 조건:** Phase 1~7 9차 정적 리뷰 판정과 `phase3-audio-content-review.md`의 `REV-072` 근거 존재. +- **완료 증거:** v2 actual endpoint의 한쪽만 입력, 잘못된 형식, 15초 미만 RED와 + `content.error.preview_time_both_required`·`content.error.preview_time_format`· + `content.error.preview_time_minimum` 오류 계약, DB/S3/event 0건, 정상 preview 및 legacy/public 회귀, + `ktlintCheck`, `git diff --check`, Progress 기록. +- **범위 밖:** preview 규칙·오류 key 변경, OpenAPI field/schema 변경, upload/processing pipeline 변경, + 관련 없는 `AudioContentService` refactor. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** 나머지 필드는 유효한 actual endpoint 생성 request에 preview 시작만 입력, 형식 오류, + 15초 미만 구간을 각각 보내 현재 200과 DB/S3/event 부작용이 발생하는 경로를 고정한다. +- [x] **GREEN:** 문자열 request overload에만 있던 `validatePreviewTime` 호출을 두 생성 경로가 공유하는 + parsed request overload로 이동해 legacy와 v2가 검증을 정확히 한 번 수행하도록 한다. +- [x] **CONTRACT TEST:** KO/EN/JA의 세 기존 오류 key와 no-side-effect, 정상 15초 이상 preview의 metadata를 + actual endpoint 및 service 단위에서 확인한다. +- [x] **REFACTOR:** 공유 검증 호출 위치만 최소 변경하고 v2 content 및 legacy creator content 영향 범위 회귀, + `ktlintCheck`, `git diff --check` 결과를 Progress에 기록한다. +- 검증 기록: 무엇: v2 오디오 생성 preview 시간 검증을 기존 creator 생성과 동일한 공유 parsed request overload로 복구했다. 왜: + `REV-072`처럼 v2 경로가 문자열 request overload의 검증을 우회해 잘못된 preview 입력이 DB/S3/event 경계로 진행될 수 있었기 + 때문이다. 어떻게: invalid preview actual endpoint 9건은 production 변경 전 400 기대 대비 200/부작용 경로로 실패했고, 테스트 JSON + 조립 오류 수정 후 `AudioContentService.createAudioContent(CreateAudioContentRequest, ...)` 시작부로 `validatePreviewTime`을 이동했다. + 결과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.LegacyCreatorAdminAudioContentCharacterizationTest`가 + `BUILD SUCCESSFUL in 39s`였다. + +#### Phase 3 preview 시간 검증 후속 Gate + +**Goal 실행 `P3-R19-GATE`:** `REV-072` 수정 뒤 v2·legacy 생성의 preview 검증과 부작용 순서를 재판정한다. + +- [x] **`P3-R19-GATE` 완료:** `P3-R19` 완료 후 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 + Phase 3 리뷰·finding 상태·Progress를 갱신한다. +- **시작 조건:** `P3-R19` 완료. +- **완료 증거:** 세 preview 오류 계약과 DB/S3/event no-side-effect, 정상 preview 및 legacy/public 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 OpenAPI schema 변경. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest \ + --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*' \ + --tests 'kr.co.vividnext.sodalive.content.*AudioContent*Test' +./gradlew ktlintCheck +git diff --check +``` + +- Gate 검증 기록: 무엇: `REV-072` 수정 뒤 Phase 3 content와 legacy AudioContent 영향 범위를 재검증했다. 왜: 공유 service + overload 변경이 v2 actual endpoint와 legacy creator 생성 경로를 동시에 통과해야 하기 때문이다. 어떻게: + `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*' --tests 'kr.co.vividnext.sodalive.content.*AudioContent*Test'`, + `./gradlew ktlintCheck`, `git diff --check`를 fresh 실행했다. 결과: 영향 범위 회귀는 `BUILD SUCCESSFUL in 1m 22s`, + `ktlintCheck`는 `BUILD SUCCESSFUL in 32s`, `git diff --check`는 출력 없이 통과했다. + --- ### Phase 2·3 후속: 레거시 JSON 계약 정합화 @@ -2034,7 +3146,7 @@ JSON과 설명 문서로 고정한다. - [x] FanTalk 관리자 목록과 시리즈 미연결 콘텐츠 검색을 별도 operation으로 포함해 총 23개 endpoint를 검증한다. - [x] OpenAPI lint/validate, TypeScript Fetch client 생성과 `tsc --noEmit`을 실행하고 결과를 기록한다. -- [ ] **Task 3.17: Phase 2 캐릭터 runtime 계약 정합화** +- [x] **Task 3.17: Phase 2 캐릭터 runtime 계약 정합화** **Goal 실행 `P23-CONTRACT-2`:** 현재 구현된 캐릭터 4개 endpoint를 `api-contract.openapi.json`의 레거시 필드명·전체 request/response·mutation 응답에 맞춘다. @@ -2056,11 +3168,11 @@ JSON과 설명 문서로 고정한다. - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerMutationTest.kt` - Confirm: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/LegacyChatCharacterAdminCharacterizationTest.kt` -- [ ] **RED:** 목록 `content`, 상세 전체 nested 필드, 생성 전체 request와 필수 `image`, 수정 optional 필드, +- [x] **RED:** 목록 `content`, 상세 전체 nested 필드, 생성 전체 request와 필수 `image`, 수정 optional 필드, `isActive=false`와 다른 optional JSON field의 동시 입력·미반영, 생성·수정 `data: null` exact JSON 테스트를 작성해 현재 v2 축약/변환 DTO와의 불일치를 확인한다. -- [ ] **GREEN:** business pipeline을 바꾸지 않는 최소 DTO/controller/facade/mapper 변경으로 OpenAPI 계약을 통과시킨다. -- [ ] **REFACTOR:** Phase 2 actual endpoint와 legacy characterization, 공통 오류 계약을 회귀하고 결과를 기록한다. +- [x] **GREEN:** business pipeline을 바꾸지 않는 최소 DTO/controller/facade/mapper 변경으로 OpenAPI 계약을 통과시킨다. +- [x] **REFACTOR:** Phase 2 actual endpoint와 legacy characterization, 공통 오류 계약을 회귀하고 결과를 기록한다. ```bash ./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*' \ @@ -2069,7 +3181,23 @@ JSON과 설명 문서로 고정한다. ./gradlew ktlintCheck ``` -- [ ] **Task 3.18: Phase 3 오디오 콘텐츠 runtime 계약 정합화** +- 검증 기록(RED): 무엇: 캐릭터 4개 actual endpoint의 레거시 list/detail/multipart/mutation/soft-delete 계약. 왜: 현재 v2 DTO와 + mutation 응답 및 단독 soft-delete 제한이 OpenAPI 원본과 다른지 실제 실패로 고정하기 위해. 어떻게: + `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest`를 + 실행했다. 결과: test compile은 성공했고 59건 중 계약 불일치 9건이 의도한 assertion에서 실패해 `BUILD FAILED`를 확인했다. +- 검증 기록(GREEN/REFACTOR): 무엇: 레거시 DTO 재사용, 필수 생성 image, exact `data: null`, mixed JSON soft-delete와 character/common + 회귀. 왜: business pipeline을 유지하면서 runtime 경계만 계약 원본에 맞추고 legacy·인가·오류 회귀를 방지하기 위해. 어떻게: + 위 focused 명령을 먼저 실행한 뒤 Task의 character/authorization/error 회귀 명령과 `./gradlew ktlintCheck`를 fresh 실행했다. + 결과: focused 59건은 `BUILD SUCCESSFUL in 2m 5s`, 지정 회귀 6개 suite 170건은 failure/error/skipped 0으로 + `BUILD SUCCESSFUL in 2m 36s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 48s`였다. +- 검증 기록(독립 리뷰 보완): 무엇: OpenAPI `size` minimum 1과 mixed soft-delete의 미존재 `originalWorkId` 무시 계약. 왜: 독립 + 코드 리뷰에서 기존 20..50 보정이 Character list 계약과 다르고 soft-delete 검증 경계를 더 직접 고정할 필요가 확인됐기 때문이다. + 어떻게: `size=1`에서 두 row 중 `content` 한 건만 반환하는 RED와 `isActive=false` + `originalWorkId=999999` 성공 assertion을 + 추가했다. 결과: focused 60건 중 pagination 1건이 RED로 실패했고 soft-delete 강화분은 통과했다. size 하한만 1로 바꾼 뒤 + focused 60건은 `BUILD SUCCESSFUL in 2m 20s`, 지정 회귀 6개 suite 171건은 failure/error/skipped 0으로 + `BUILD SUCCESSFUL in 2m 21s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 29s`였다. + +- [x] **Task 3.18: Phase 3 오디오 콘텐츠 runtime 계약 정합화** **Goal 실행 `P23-CONTRACT-3`:** 현재 구현된 테마·오디오 콘텐츠 5개 endpoint를 `api-contract.openapi.json`의 레거시 필드명·전체 request/response·성공 응답에 맞춘다. @@ -2093,10 +3221,29 @@ JSON과 설명 문서로 고정한다. - Confirm: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentOwnershipTest.kt` - Confirm: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/LegacyCreatorAdminAudioContentCharacterizationTest.kt` -- [ ] **RED:** 테마 `id/theme/image`, 목록 전체 item, 상세 전체 nested DTO, 생성 `contentFile`과 +- [x] **RED:** 테마 `id/theme/image`, 목록 전체 item, 상세 전체 nested DTO, 생성 `contentFile`과 `CreateAudioContentRequest`, 수정 `UpdateCreatorAdminContentRequest` 및 각 성공 `data` 형태를 exact JSON으로 고정한다. -- [ ] **GREEN:** 기존 business pipeline과 signed URL 계산을 유지하는 최소 DTO/controller/facade/mapper adapter를 구현한다. -- [ ] **REFACTOR:** Phase 3 actual endpoint·legacy characterization·ownership/error 계약을 회귀하고 결과를 기록한다. +- [x] **GREEN:** 기존 business pipeline과 signed URL 계산을 유지하는 최소 DTO/controller/facade/mapper adapter를 구현한다. +- [x] **REFACTOR:** Phase 3 actual endpoint·legacy characterization·ownership/error 계약을 회귀하고 결과를 기록한다. + +- 검증 기록(RED): 무엇: 테마 `id/theme/image`, 목록 `search_word`와 전체 legacy item, 상세 필수 `timezone`과 전체 nested + DTO, 생성 `contentFile`·`CreateAudioContentRequest`·`data.contentId`, 수정 `UpdateCreatorAdminContentRequest`·`data: null`. + 왜: 현재 v2 alias와 mutation 상세 응답이 확정 OpenAPI 계약과 다른 상태를 실제 실패로 고정하기 위해. 어떻게: 지정된 theme, + query, controller, create, update 5개 test class를 production 변경 전에 실행했다. 결과: test compile은 성공했고 61건 중 + 계약 불일치 21건이 의도한 assertion에서 실패해 `BUILD FAILED in 2m 17s`를 확인했다. +- 검증 기록(GREEN/REFACTOR): 무엇: legacy DTO·목록 service 재사용, owner-scoped 상세 mapping, 기존 create/update service + 위임, series 연결 보존, ownership·legacy·authorization·error 회귀. 왜: upload/processing·signed URL·ownership 의미는 + 유지하면서 HTTP 경계만 확정 계약에 맞추기 위해. 어떻게: focused 5개 class를 먼저 실행한 뒤 Task에 명시된 content 전체와 + authorization/error 명령 및 `./gradlew ktlintCheck`를 fresh 실행했다. 결과: focused 61건은 `BUILD SUCCESSFUL in 3m 27s`, + 최종 10개 suite 206건은 failure/error/skipped 0으로 `BUILD SUCCESSFUL in 1m 43s`, `ktlintCheck`는 + `BUILD SUCCESSFUL in 16s`였다. +- 검증 기록(Important review 보완): 무엇: PUT에 계약 밖 `audioFile` 또는 `contentFile` part가 있으면 400으로 거부하고 + AudioContent·SeriesContent, S3, event를 변경하지 않는 계약. 왜: `contentFile`은 controller에 bind되지 않아 create-style part를 + 보낸 PUT이 파일을 무시한 채 200 `data: null`로 처리됐기 때문이다. 어떻게: 기존 `audioFile` no-side-effect test를 두 part + parameterized test로 확장하고 controller/facade에 optional `contentFile` binding과 공동 guard만 추가했다. 결과: RED는 2건 중 + `contentFile` 1건만 실패해 `BUILD FAILED in 30s`, GREEN은 2건 모두 `BUILD SUCCESSFUL in 33s`였다. content·authorization·error + 10개 suite 207건은 failure/error/skipped 0으로 `BUILD SUCCESSFUL in 1m 46s`, `ktlintCheck`는 + `BUILD SUCCESSFUL in 32s`였다. ```bash ./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*' \ @@ -2110,7 +3257,7 @@ JSON과 설명 문서로 고정한다. **Goal 실행 `P23-CONTRACT-GATE`:** 문서 계약과 구현된 9개 endpoint의 runtime 응답이 일치하고 Phase 4가 같은 계약을 소비할 수 있는지 판정한다. -- [ ] **`P23-CONTRACT-GATE` 완료:** `P23-CONTRACT-1`~`P23-CONTRACT-3` 완료 후 character/content focused·legacy +- [x] **`P23-CONTRACT-GATE` 완료:** `P23-CONTRACT-1`~`P23-CONTRACT-3` 완료 후 character/content focused·legacy 회귀, OpenAPI validate/client 생성과 `ktlintCheck`를 fresh 실행한다. - **범위 밖:** Gate에서 직접 production code 수정, Phase 4 이후 기능 구현. @@ -2128,6 +3275,12 @@ npx --yes @openapitools/openapi-generator-cli generate \ -o /tmp/ai-character-admin-typescript-client ``` +- 검증 기록: 무엇: `P23-CONTRACT-GATE` runtime/API 계약 Gate. 왜: `P23-CONTRACT-1`~`P23-CONTRACT-3` 완료 후 Phase 4가 + 소비할 Character·AudioContent 9개 endpoint의 runtime 응답과 OpenAPI 계약이 함께 유효한지 확인하기 위해. 어떻게: 위 네 + Gate 명령을 fresh 실행했다. 결과: character/content/common 회귀는 `BUILD SUCCESSFUL in 4m 59s`, `ktlintCheck`는 + `BUILD SUCCESSFUL in 45s`, OpenAPI validate는 `No validation issues detected.`, TypeScript Fetch client 생성은 + `/tmp/ai-character-admin-typescript-client`에 성공했다. + --- ### Phase 4: 시리즈 관리 vertical slice @@ -2145,14 +3298,16 @@ npx --yes @openapitools/openapi-generator-cli generate \ - 기존 creator series 생성/수정/soft delete, 콘텐츠 연결/해제, 조회/검색, 순서 변경 behavior를 통과하는 특성화 테스트로 먼저 고정해야 한다. #### API endpoint와 request/response contract -- 정식 전체 schema는 `api-contract.openapi.json`의 Series operation 9개를 따른다. +- 정식 전체 schema는 `api-contract.openapi.json`의 Series operation 10개를 따른다. +- `GET /api/v2/admin/ai-characters/series-genres`는 활성 장르의 `id`, `genre`, `isAdult` 직접 배열을 반환한다. +- `GET /series/{seriesId}`의 `data`는 목록 `items`의 단일 객체와 동일한 11개 필드·타입을 반환한다. - `GET/POST/PUT /api/v2/admin/ai-characters/{characterId}/series...` - `GET /series/{seriesId}/contents` query: `page`, `size`; response: `GetCreatorAdminContentSeriesContentResponse(totalCount, items)` - `GET /series/{seriesId}/contents/search` query: 필수 `search_word`; response: `List` - `POST /series/{seriesId}/contents` request: `AddingContentToTheSeriesRequest(contentIdList: List)` -- `DELETE /series/{seriesId}/contents/{contentId}` +- `DELETE /series/{seriesId}/contents/{contentId}` request body 없음 - `PUT /series/orders` request: `UpdateOrdersRequest(ids: List)` #### entity, repository, service 변경 @@ -2194,7 +3349,7 @@ npx --yes @openapitools/openapi-generator-cli generate \ #### 권장 commit 경계 - `feat: add ai character admin series slice` -- [ ] **Task 4.1: 기존 series parity 특성화 baseline 고정** +- [x] **Task 4.1: 기존 series parity 특성화 baseline 고정** **Goal 실행 `P4-T1`:** 기존 creator series의 CRUD·연결·검색·순서 동작을 신규 v2 구현의 비교 기준으로 고정한다. @@ -2206,13 +3361,53 @@ npx --yes @openapitools/openapi-generator-cli generate \ - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/LegacyCreatorAdminSeriesCharacterizationTest.kt` -- [ ] 목록·상세·생성·수정·soft delete와 inactive 조회 baseline test를 작성한다. -- [ ] 콘텐츠 연결·해제·검색과 순서 변경의 결과·검증·side effect를 고정한다. -- [ ] Phase 4 domain/client 오류별 정확한 status와 KO/EN/JA message key를 확정해 구현 Goal에 반영한다. -- [ ] production code 변경 없이 특성화 테스트가 기존 구현을 대상으로 통과함을 확인한다. -- [ ] fixture만 정리하고 test와 `ktlintCheck` 결과를 Progress에 기록한다. +- [x] 목록·상세·생성·수정·soft delete와 inactive 조회 baseline test를 작성한다. +- [x] 콘텐츠 연결·해제·검색과 순서 변경의 결과·검증·side effect를 고정한다. +- [x] Phase 4 domain/client 오류별 정확한 status와 KO/EN/JA message key를 확정해 구현 Goal에 반영한다. +- [x] production code 변경 없이 특성화 테스트가 기존 구현을 대상으로 통과함을 확인한다. +- [x] fixture만 정리하고 test와 `ktlintCheck` 결과를 Progress에 기록한다. -- [ ] **Task 4.2: 시리즈 목록·상세 조회 구현** +- **관찰된 legacy baseline:** creator 목록은 owner의 활성 시리즈만 `orders` 오름차순으로 반환하지만 owner 상세는 + `isActive=false`도 반환하고, 전역 admin의 `findByIdAndActiveTrue`는 inactive를 제외한다. 생성은 DB save → S3 upload → + language detect event 순이며 키워드를 `#` prefix 기준으로 중복 제거한다. 수정은 일반 field와 `isActive=false`를 한 요청에서 + 모두 반영하고 title/introduction 변경 시 translation event를 발행한다. +- **관찰된 연결·검색·순서 baseline:** 콘텐츠 연결은 owned ID만 부분 반영하고 foreign/missing ID를 건너뛰며 전부 무효일 때만 + `creator.admin.series.no_content_added`를 던진다. 해제할 link가 없으면 no-op이다. 연결 목록 `totalCount`는 target series가 아닌 + owner의 활성 시리즈 전체 link 수이고, missing series 조회도 이 count와 빈 items를 반환한다. 미연결 검색은 processed 또는 + reserved owner content만 반환하지만 series 존재·owner를 검증하지 않는다. 순서 변경은 owner·active를 검증하지 않고 존재하는 + ID만 요청 index + 1로 갱신하므로 foreign/inactive도 변경되고 missing ID 자리는 순번 gap으로 남는다. +- **legacy 오류 표면:** creator-admin security 실패는 401/403 `sendError`이고, controller 이후 `SodaException`은 HTTP 200 + `ApiResponse.error`로 노출된다. 아래 Phase 4 v2 결정은 이를 복제하지 않고 신규 prefix의 비2xx envelope 정책을 따른다. +- **Phase 4 v2 domain/client 오류 결정:** target/series/content 미존재·inactive·cross-owner, 존재하지 않는 양수 genre, + 잘못된 pagination, malformed request, 중복·누락·foreign/inactive order ID, 이미 연결된 content와 없는 link 해제는 mutation 전 + 400 `common.error.invalid_request`로 실패하고 DB/S3/event side effect는 0건이어야 한다. 빈 `contentIdList` 또는 legacy 규칙상 + 추가 가능한 ID가 0개인 비소유권 입력은 400 `creator.admin.series.no_content_added`를 유지한다. create/update의 legacy 입력 + validation key도 아래 표처럼 400으로 유지한다. 예상하지 못한 DB/S3/event 오류는 500 `common.error.unknown`을 사용하며, + transaction DB 변경과 미발행 event는 rollback하지만 이미 성공한 S3 upload는 legacy에 삭제 계약이 없어 보상하지 않는다. + +| Phase 4 domain/client 경우 | status | message key | KO | EN | JA | +|---|---:|---|---|---|---| +| missing/inactive/cross-owner resource, pagination·binding·order/link 검증 실패 | 400 | `common.error.invalid_request` | 잘못된 요청입니다. | Invalid request. | 無効なリクエストです。 | +| 생성 title 공백 | 400 | `creator.admin.series.title_required` | 시리즈 제목을 입력하세요 | Please enter a series title. | シリーズのタイトルを入力してください。 | +| 생성 introduction 공백 | 400 | `creator.admin.series.introduction_required` | 시리즈 소개를 입력하세요 | Please enter a series introduction. | シリーズ紹介を入力してください。 | +| 생성 keyword 공백 | 400 | `creator.admin.series.keyword_required` | 시리즈를 설명할 수 있는 키워드를 입력하세요 | Please enter keywords that describe the series. | シリーズを説明できるキーワードを入力してください。 | +| 생성 genre ID 0 이하 | 400 | `creator.admin.series.genre_required` | 올바른 장르를 선택하세요 | Please select a valid genre. | 正しいジャンルを選択してください。 | +| 생성 published days 비어 있음 | 400 | `creator.admin.series.published_days_required` | 시리즈 연재요일을 선택하세요 | Please select publishing days. | シリーズの連載曜日を選択してください。 | +| `RANDOM`과 특정 요일 혼합 | 400 | `creator.admin.series.published_days_random_exclusive` | 랜덤과 연재요일 동시에 선택할 수 없습니다. | You cannot select random and specific days at the same time. | ランダムと連載曜日を同時に選択することはできません。 | +| 생성 cover image 누락 | 400 | `creator.admin.series.cover_image_required` | 커버이미지를 선택해 주세요. | Please select a cover image. | カバー画像を選択してください。 | +| 수정 field와 image 모두 없음 | 400 | `creator.admin.series.no_changes` | 변경사항이 없습니다. | No changes to update. | 変更データがありません。 | +| 추가 가능한 content ID 0개 | 400 | `creator.admin.series.no_content_added` | 추가된 콘텐츠가 없습니다. | No content was added. | 追加されたコンテンツがありません。 | +| 예상하지 못한 server/infrastructure 오류 | 500 | `common.error.unknown` | 알 수 없는 오류가 발생했습니다. 다시 시도해 주세요. | An unknown error occurred. try again. | 不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。 | + +- 검증 기록: 무엇: creator-admin series CRUD/list/inactive, content link/unlink/count/search, owner-less order와 legacy 오류 key + characterization. 왜: 신규 v2가 legacy JSON·domain 의미를 재사용하되 legacy의 owner-less·부분 성공·HTTP 200 오류 표면은 + 안전한 비2xx owner-first 계약으로 분리하기 위해. 어떻게: + `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.LegacyCreatorAdminSeriesCharacterizationTest`와 + `./gradlew ktlintCheck`를 production 변경 없이 실행했다. 결과: focused test는 첫 실행부터 `BUILD SUCCESSFUL in 47s`, + `ktlintCheck`는 `BUILD SUCCESSFUL in 19s`였다. 전체 `./gradlew test`는 production 변경이 없고 지정 focused test가 실제 + service/repository/S3/event 경계를 포함하므로 실행하지 않았다. + +- [x] **Task 4.2: 시리즈 목록·상세 조회 구현** **Goal 실행 `P4-T2`:** target owner의 활성 시리즈 목록·상세를 레거시 response 계약으로 제공한다. @@ -2228,13 +3423,30 @@ npx --yes @openapitools/openapi-generator-cli generate \ - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesQueryTest.kt` -- [ ] 미구현 목록·상세, inactive 제외, cross-owner 거부와 pagination 경계 실패 test를 작성한다. -- [ ] owner-scoped 목록·상세 최소 구현으로 focused test를 통과시킨다. -- [ ] 목록 `totalCount/items`와 item 전체 필드, 상세의 문자열 `publishedDaysOfWeek/genre/keywords/state`를 +- [x] 미구현 목록·상세, inactive 제외, cross-owner 거부와 pagination 경계 실패 test를 작성한다. +- [x] owner-scoped 목록·상세 최소 구현으로 focused test를 통과시킨다. +- [x] 목록 `totalCount/items`와 item 전체 필드, 상세의 문자열 `publishedDaysOfWeek/genre/keywords/state`를 `api-contract.openapi.json`과 exact JSON으로 검증한다. -- [ ] focused test와 `ktlintCheck` 결과를 Progress에 기록한다. +- [x] focused test와 `ktlintCheck` 결과를 Progress에 기록한다. -- [ ] **Task 4.3: 시리즈 생성·수정·soft delete 구현** +- 검증 기록(RED): 무엇: v2 series 목록·상세 전체 legacy 필드, 활성 owner 범위, missing/inactive/cross-owner 상세와 + `page=1&size=1`, 음수 page·0 size 경계. 왜: 신규 route 미구현과 Phase 4 오류 결정을 실제 HTTP 계약 실패로 고정하기 위해. + 어떻게: production 파일 생성 전에 + `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.AiCharacterAdminSeriesQueryTest`를 실행했다. + 결과: test compile은 성공했고 5건 모두 기대 status 200/400 대신 미구현 404로 실패해 `BUILD FAILED in 44s`를 확인했다. +- 검증 기록(GREEN/REFACTOR): 무엇: target resolver 선행, 활성 owner 목록·상세, exact legacy DTO, pagination과 legacy + characterization 회귀. 왜: 기존 creator series 동작과 응답 형태는 재사용하면서 inactive·cross-owner 상세만 신규 400 계약으로 + 제한하기 위해. 어떻게: 같은 focused 명령, series package 회귀와 `./gradlew ktlintCheck`를 실행했다. 결과: focused 5건은 + failure/error/skipped 0으로 `BUILD SUCCESSFUL in 34s`, Task 4.1 포함 series 12건은 failure/error/skipped 0으로 + `BUILD SUCCESSFUL in 46s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 26s`였다. +- 검증 기록(독립 리뷰 보완): 무엇: inactive `ChatCharacter` target의 목록·상세 400 계약. 왜: resolver는 role/memberKind만 + 검증하므로 Phase 4의 target inactive 결정이 series facade에서 누락됐기 때문이다. 어떻게: inactive target 목록·상세 테스트를 + 추가해 focused 명령을 실행한 뒤 facade의 공통 active target guard를 적용하고 focused/series 회귀와 `ktlintCheck`를 재실행했다. + 결과: 보완 RED는 7건 중 기존 5건은 통과하고 신규 2건만 400 기대 대비 200으로 실패해 `BUILD FAILED in 35s`였다. 보완 후 + focused 7건은 `BUILD SUCCESSFUL in 44s`, Task 4.1 포함 series 14건은 `BUILD SUCCESSFUL in 49s`, `ktlintCheck`는 + `BUILD SUCCESSFUL in 23s`였다. + +- [x] **Task 4.3: 시리즈 생성·수정·soft delete 구현** **Goal 실행 `P4-T3`:** target owner의 시리즈 생성·수정·soft delete를 기존 creator parity로 제공한다. @@ -2249,12 +3461,27 @@ npx --yes @openapitools/openapi-generator-cli generate \ - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesFacade.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesMutationTest.kt` -- [ ] 생성·수정·soft delete와 cross-owner mutation 실패 test를 작성한다. -- [ ] owner 검증 후 최소 CRUD 구현으로 test를 통과시킨다. -- [ ] `isActive=false`와 활성 조회 제외, invalid target의 DB/event no-side-effect를 검증한다. -- [ ] focused/legacy test와 `ktlintCheck` 결과를 Progress에 기록한다. +- [x] 생성·수정·soft delete와 cross-owner mutation 실패 test를 작성한다. +- [x] owner 검증 후 최소 CRUD 구현으로 test를 통과시킨다. +- [x] `isActive=false`와 활성 조회 제외, invalid target의 DB/event no-side-effect를 검증한다. +- [x] focused/legacy test와 `ktlintCheck` 결과를 Progress에 기록한다. -- [ ] **Task 4.4: 시리즈 콘텐츠 조회·검색·연결·해제 구현** +- 검증 기록(RED): 무엇: v2 series 생성·수정·DELETE soft delete, cross-owner·missing·inactive·no-change와 + DB/S3/event 부작용 0건. 왜: mutation route 미구현과 owner-first 계약을 실제 HTTP 경계로 고정하기 위해. 어떻게: production + 변경 전에 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.AiCharacterAdminSeriesMutationTest`를 + 실행했다. 결과: 12건 모두 기대 200/400 대신 미구현 method의 405로 실패해 `BUILD FAILED in 51s`를 확인했다. +- 검증 기록(GREEN/REFACTOR): 무엇: legacy 생성·수정 위임, path `seriesId` 조립, 활성 target/owned series 선검증과 null 성공 + envelope. 왜: keyword/S3/genre/event/entity 갱신을 복제하지 않고 creator parity를 유지하기 위해. 어떻게: focused test, + `./gradlew test --rerun-tasks --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.*'`, `./gradlew ktlintCheck`와 + `git diff --check`를 실행했다. 결과: focused 12건과 series 회귀 26건은 failure/error/skipped 0, fresh 회귀는 + `BUILD SUCCESSFUL in 4m 36s`, ktlint는 import 정렬 1건 수정 후 `BUILD SUCCESSFUL in 21s`, diff check는 오류가 없었다. +- 검증 기록(독립 리뷰 보완): 무엇: 존재하지 않는 양수 `genreId` 생성·이미지 포함 수정 요청을 legacy 호출 전에 400으로 차단하고 + DB/S3/event 부작용 0건을 보장했다. 왜: legacy 수정 path는 이미지 업로드 후 genre를 조회하므로 Phase 4의 mutation 전 검증 + 결정을 위반할 수 있었기 때문이다. 어떻게: 누락 genre 생성·수정 테스트 2건을 추가해 focused 14건 중 신규 2건만 RED로 실패함을 + 확인한 뒤 active genre 사전 guard를 추가했다. 결과: focused 14건은 `BUILD SUCCESSFUL in 35s`, series 회귀 28건은 + `BUILD SUCCESSFUL in 1m 30s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 49s`, `git diff --check`는 오류가 없었다. + +- [x] **Task 4.4: 시리즈 콘텐츠 조회·검색·연결·해제 구현** **Goal 실행 `P4-T4`:** 동일 owner의 시리즈와 콘텐츠만 검색·연결·해제할 수 있도록 한다. @@ -2270,13 +3497,23 @@ npx --yes @openapitools/openapi-generator-cli generate \ - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesContentTest.kt` -- [ ] `GET .../contents`와 `GET .../contents/search?search_word=...`의 별도 응답, 연결·해제와 cross-owner ID 실패 +- [x] `GET .../contents`와 `GET .../contents/search?search_word=...`의 별도 응답, 연결·해제와 cross-owner ID 실패 test를 작성한다. -- [ ] 모든 series/content ID를 mutation 전에 검증하는 최소 구현을 통과시킨다. -- [ ] `contentIdList` 필드와 mutation `data: null`, 일부 연결 성공이 남지 않는 원자성과 pagination 경계를 검증한다. -- [ ] focused/Phase 3 owner query 회귀와 `ktlintCheck` 결과를 Progress에 기록한다. +- [x] 모든 series/content ID를 mutation 전에 검증하는 최소 구현을 통과시킨다. +- [x] `contentIdList` 필드와 mutation `data: null`, 일부 연결 성공이 남지 않는 원자성과 pagination 경계를 검증한다. +- [x] focused/Phase 3 owner query 회귀와 `ktlintCheck` 결과를 Progress에 기록한다. -- [ ] **Task 4.5: owner-scoped 시리즈 순서 변경 구현** +- 검증 기록(RED): 무엇: 연결 목록·미연결 검색·pagination·원자적 연결/해제와 invalid series/content 경계. 왜: 신규 v2 route와 + legacy 부분 성공/no-op을 owner-first 400 계약으로 바꾸기 위해. 어떻게: production 변경 전에 + `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.AiCharacterAdminSeriesContentTest`를 실행했다. + 결과: 7건 모두 미구현 route의 404/405로 기대한 200/400을 충족하지 못해 `BUILD FAILED in 54s`를 확인했다. +- 검증 기록(GREEN/REFACTOR): 무엇: legacy 목록/검색 DTO 재사용, `contentIdList` 전체 선검증, 연결/해제 `data: null`과 invalid + 요청의 무변경 경계. 왜: legacy의 owner 전체 link count와 응답 형태는 유지하면서 foreign/missing/inactive/already-linked ID와 + 없는 link 해제의 부분 성공을 막기 위해. 어떻게: focused, Phase 4 series 회귀, Phase 3 content owner-query 회귀와 + `ktlintCheck`를 실행했다. 결과: focused 7건은 `BUILD SUCCESSFUL in 1m 23s`, series 회귀는 + `BUILD SUCCESSFUL in 1m 55s`, content 회귀는 `BUILD SUCCESSFUL in 1m 24s`, ktlint는 `BUILD SUCCESSFUL in 36s`였다. + +- [x] **Task 4.5: owner-scoped 시리즈 순서 변경 구현** **Goal 실행 `P4-T5`:** 요청된 모든 series ID의 owner를 먼저 검증한 뒤 한 transaction에서 순서를 변경한다. @@ -2291,12 +3528,21 @@ npx --yes @openapitools/openapi-generator-cli generate \ - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesOrderTest.kt` -- [ ] 정상 순서와 cross-owner ID-only 취약 경로를 재현하는 실패 test를 작성한다. -- [ ] 동일 owner 전체 검증 후 한 transaction에서 갱신하는 최소 구현을 통과시킨다. -- [ ] 검증 실패 시 update 0건과 동시 요청의 기존 last-transaction 정책을 확인한다. -- [ ] focused/legacy order test와 `ktlintCheck` 결과를 Progress에 기록한다. +- [x] 정상 순서와 cross-owner ID-only 취약 경로를 재현하는 실패 test를 작성한다. +- [x] 동일 owner 전체 검증 후 한 transaction에서 갱신하는 최소 구현을 통과시킨다. +- [x] 검증 실패 시 update 0건과 동시 요청의 기존 last-transaction 정책을 확인한다. +- [x] focused/legacy order test와 `ktlintCheck` 결과를 Progress에 기록한다. -- [ ] **Task 4.6: Phase 4 보안·오류·회귀 검증** +- 검증 기록(GREEN/REFACTOR): 무엇: v2 series order endpoint와 owner active series 선검증, duplicate/missing/cross-owner/inactive/empty + ID 거부, 동시 순서 변경용 ID 오름차순 pessimistic lock, last-request 결과를 고정했다. 왜: legacy `updateSeriesOrders(ids)`는 + owner-less로 요청 순서대로 row를 수정하므로 신규 v2 경계에서 owner 검증과 lock 순서를 먼저 보장해야 하기 때문이다. 어떻게: + `AiCharacterAdminSeriesOrderTest` RED 후 `PUT /api/v2/admin/ai-characters/{characterId}/series/orders`를 추가하고, + `CreatorAdminContentSeriesRepository.findActiveByCreatorIdAndIdInForUpdate`로 같은 transaction 안에서 대상 row를 선잠금한 뒤 + legacy update를 재사용했다. 결과: focused order test는 `BUILD SUCCESSFUL in 34s`, Phase 4 series 회귀는 + `BUILD SUCCESSFUL in 55s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 25s`, `git diff --check`는 출력이 없었다. 리뷰 재확인에서 + blocking/important/minor finding 0건을 확인했다. + +- [x] **Task 4.6: Phase 4 보안·오류·회귀 검증** **Goal 실행 `P4-T6`:** 모든 series endpoint의 ADMIN·오류·ownership 계약과 legacy 회귀를 고정한다. @@ -2309,46 +3555,590 @@ npx --yes @openapitools/openapi-generator-cli generate \ - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesContractTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAuthorizationTest.kt` -- [ ] endpoint별 ADMIN 이중 인가와 stale claim을 검증한다. -- [ ] target/series/content/pagination 오류의 정확한 status, message key, KO/EN/JA envelope를 확정·검증한다. -- [ ] invalid ownership의 DB/event side effect 0건과 legacy creator series 계약을 검증한다. -- [ ] Phase 4 focused test와 `ktlintCheck` 결과를 Progress에 기록한다. +- [x] endpoint별 ADMIN 이중 인가와 stale claim을 검증한다. +- [x] target/series/content/pagination 오류의 정확한 status, message key, KO/EN/JA envelope를 확정·검증한다. +- [x] invalid ownership의 DB/event side effect 0건과 legacy creator series 계약을 검증한다. +- [x] Phase 4 focused test와 `ktlintCheck` 결과를 Progress에 기록한다. #### Phase 4 Gate **Goal 실행 `P4-GATE`:** Phase 4 series 사용자 흐름과 ownership·회귀 품질을 최종 판정한다. -- [ ] **`P4-GATE` 완료:** 시작 조건과 완료 증거를 모두 충족하고 현재 상태표와 Progress를 갱신한다. +- [x] **`P4-GATE` 완료:** 시작 조건과 완료 증거를 모두 충족하고 현재 상태표와 Progress를 갱신한다. - **시작 조건:** `P4-T1`~`P4-T6` 완료. - **완료 증거:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.*'`와 `./gradlew ktlintCheck` 성공, Progress 기록. - **범위 밖:** 실패와 무관한 Phase 5 구현. +#### Phase 4 후속 리뷰 보완 + +- [x] **Task 4.7: 시리즈 HTTP 경계를 OpenAPI 9개 operation과 정합화** + +**Goal 실행 `P4-R1`:** `REV-023`의 계약 밖 시리즈 DELETE endpoint를 제거하고 `REV-024`의 JSON body 두 곳에서 +`additionalProperties: false`를 실제로 강제한다. + +- **추적 review ID:** `REV-023`, `REV-024`. +- **시작 조건:** `P3-R9-GATE` 완료와 `phase4-series-review.md` 판정 존재. +- **완료 증거:** `DELETE /series/{seriesId}`가 405이고 `PUT /series/{seriesId}`의 `isActive=false`가 soft delete를 + 담당하는 actual endpoint 테스트, 콘텐츠 추가·순서 변경 request의 미지 필드 400/no-side-effect 테스트, + Series 9개 operation mapping 정적 대조와 Progress 기록. +- **범위 밖:** OpenAPI operation 추가, legacy controller 변경, 시리즈 CRUD/ownership/lock 정책 변경, 다른 Phase JSON 경계. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesController.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesFacade.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesMutationTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesContentTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesOrderTest.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** `DELETE /series/{seriesId}` 405와 `PUT` `isActive=false` soft delete 성공을 actual endpoint로 고정한다. +- [x] **RED:** 콘텐츠 추가와 순서 변경 JSON에 계약 밖 필드를 추가하면 400 + `common.error.invalid_request`이고 DB/event 부작용이 0회임을 확인한다. +- [x] **GREEN:** 계약 밖 DELETE controller/facade 경로를 제거하고 두 JSON body만 strict reader로 역직렬화한다. +- [x] **REFACTOR:** OpenAPI Series operation 9개와 controller mapping을 대조하고 series/common 영향 범위 회귀, + `ktlintCheck`, diff check를 실행한다. +- 검증 기록(RED): 무엇: 계약 밖 `DELETE /series/{seriesId}` 제거 기대와 `PUT isActive=false` soft delete, 콘텐츠 추가·순서 변경 미지 필드 거부를 actual endpoint로 고정했다. 왜: `REV-023`~`REV-024`가 실제 실패를 내는지 확인하기 위해. 어떻게: 아래 focused series mutation/content/order 명령을 production 변경 전 실행했다. 결과: 28개 중 6개가 기존 DELETE 200 또는 unknown-field 성공 때문에 실패해 RED를 확인했다. +- 검증 기록(GREEN): 무엇: `DELETE /series/{seriesId}` controller/facade 경로를 제거하고 content add/order JSON body를 facade strict reader로 파싱했다. 왜: OpenAPI 9개 operation과 `additionalProperties: false` 계약을 runtime에 맞추기 위해. 어떻게: 같은 focused 명령을 재실행했다. 결과: `BUILD SUCCESSFUL in 1m 25s`였다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.AiCharacterAdminSeriesMutationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.AiCharacterAdminSeriesContentTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.AiCharacterAdminSeriesOrderTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 4 후속 리뷰 Gate + +**Goal 실행 `P4-R1-GATE`:** `REV-023`~`REV-024` 수정 뒤 Series controller가 OpenAPI 9개 operation과 일치하는지 +재검토한다. + +- [x] **`P4-R1-GATE` 완료:** `P4-R1` 완료 후 mapping 정적 대조와 series/common 회귀, lint·diff를 fresh 실행하고 + 리뷰 문서와 Progress를 갱신한다. +- **시작 조건:** `P4-R1` 완료. +- **완료 증거:** 계약 밖 DELETE 제거, 두 JSON body 미지 필드 거부, 9개 operation 일치, 영향 범위 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 OpenAPI schema 변경. +- 검증 기록: 무엇: `P4-R1-GATE`에서 Series controller mapping과 회귀 품질을 재판정했다. 왜: `REV-023`~`REV-024` 처리 후 OpenAPI 9개 operation과 runtime JSON 경계가 일치하는지 확인하기 위해. 어떻게: `rg -n "@(Get|Post|Put|Delete)Mapping|fun delete\(|facade\.delete\(|@RequestBody request:" "src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series"`로 9개 mapping과 두 `@RequestBody String`, 삭제 facade 부재를 확인했고, series/common 회귀와 `ktlintCheck`, `git diff --check`를 fresh 실행했다. 결과: 정적 대조는 9개 mapping만 출력했고 `DELETE /series/{seriesId}`와 `facade.delete`는 출력되지 않았다. 회귀 명령은 `BUILD SUCCESSFUL in 2m 19s`, 최초 `ktlintCheck`는 blank line 1건으로 실패했으나 포맷 수정 후 재실행은 `BUILD SUCCESSFUL in 33s`, `git diff --check`는 출력이 없었다. + +#### Phase 4 2차 후속 리뷰 보완 + +- [x] **Task 4.8: 시리즈 필수 이미지와 연결 해제 경계 복구** + +**Goal 실행 `P4-R2`:** `REV-031`의 soft-delete 콘텐츠 연결 해제를 복구하고, `REV-032`의 생성 필수 `image` +part를 공통 multipart binding 계약에 맞춘다. + +- **추적 review ID:** `REV-031`, `REV-032`. +- **시작 조건:** `P4-R1-GATE` 완료와 `phase4-series-review.md` 2차 리뷰 판정 존재. +- **완료 증거:** 연결 후 soft delete된 owner 콘텐츠 해제 성공, cross-owner/missing link 무변경, + 생성 `image` 누락의 exact `MissingServletRequestPartException`·KO/EN/JA 400/no-side-effect RED/GREEN, + series/common 영향 범위 회귀와 Progress 기록. +- **범위 밖:** 콘텐츠 연결 추가·검색의 active/duration 적격성 변경, 빈 `image` 파일 정책 신설, + legacy/public series controller 변경, OpenAPI schema 변경. +- **계약 판정:** PRD API Expectations와 OpenAPI `SeriesCreateMultipart.required`를 우선한다. 기존 Phase 4 오류 표의 + `creator.admin.series.cover_image_required`는 nullable legacy 전달을 기록한 과거 결정이며, + 신규 관리자 endpoint의 누락 part는 `common.error.invalid_request`로 정정한다. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesController.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesFacade.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesMutationTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesContentTest.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesRepository.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** owner 콘텐츠를 시리즈에 연결한 뒤 `isActive=false`, `releaseDate=null`로 soft delete하고 + `DELETE .../contents/{contentId}`가 현재 400으로 실패하며 link가 남는지 확인한다. +- [x] **RED:** 생성 `image` part 누락을 KO/EN/JA actual endpoint로 보내 exact + `MissingServletRequestPartException`, 400 `common.error.invalid_request`, facade/DB/S3/event 0회를 단언한다. +- [x] **GREEN:** 해제는 실제 series link와 그 콘텐츠 owner만 검증하고, 연결 추가에만 필요한 + active/release/duration 적격성 검사를 해제 경로에서 제거한다. +- [x] **GREEN:** 생성 controller/facade의 `image`를 non-null `MultipartFile`로 바꾸고 검증된 파일을 legacy service에 + 그대로 전달한다. update의 optional `image`는 유지한다. +- [x] **REFACTOR:** 정상 연결/해제, missing/cross-owner link, 생성 validation key와 series/common 영향 범위 회귀, + `ktlintCheck`, `git diff --check`를 실행해 Progress에 기록한다. +- 검증 기록(RED): 무엇: soft-delete된 owner content의 기존 series link 해제와 생성 `image` 누락의 공통 binding 오류를 actual endpoint로 고정했다. 왜: `REV-031`~`REV-032`가 실제 runtime에서 실패하는지 확인하기 위해. 어떻게: 아래 focused mutation/content 명령을 production 변경 전 실행했다. 결과: soft-delete unlink 1건은 400, missing image KO/EN/JA 3건은 legacy message 기대 차이로 실패해 RED를 확인했다. +- 검증 기록(GREEN/REFACTOR): 무엇: unlink 경로에서 추가 적격성 guard를 제거하고 실제 owner link만 검증했으며, create `image` part를 non-null binding으로 변경했다. 왜: 연결 추가 조건과 기존 link 해제 조건을 분리하고 OpenAPI required part 계약을 MVC binding 단계에서 강제하기 위해. 어떻게: focused 명령, series/common 영향 범위 회귀, `./gradlew ktlintCheck`, `git diff --check`를 실행했다. 결과: focused는 `BUILD SUCCESSFUL`, 영향 범위 회귀는 `BUILD SUCCESSFUL`, `ktlintCheck`는 `BUILD SUCCESSFUL in 52s`, `git diff --check`는 출력이 없었다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.AiCharacterAdminSeriesMutationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.AiCharacterAdminSeriesContentTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 4 2차 후속 리뷰 Gate + +**Goal 실행 `P4-R2-GATE`:** `REV-031`~`REV-032`의 unlink 상태 전이와 multipart 필수 part 계약을 재검토한다. + +- [x] **`P4-R2-GATE` 완료:** `P4-R2` 완료 후 위 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 + 리뷰 문서와 Progress를 갱신한다. +- **시작 조건:** `P4-R2` 완료. +- **완료 증거:** 두 review ID 처리 완료, soft-delete 콘텐츠 unlink 성공, 필수 image 누락 exact 400, + 기존 연결 추가·update optional image 계약 유지. +- **범위 밖:** Gate에서 production code 또는 공개 API schema 변경. +- 검증 기록: 무엇: `P4-R2-GATE`에서 `REV-031`~`REV-032` 처리 결과를 재판정했다. 왜: soft-delete linked content 해제, 필수 create image 누락, 기존 link 오류와 optional update image 계약이 동시에 유지되는지 확인하기 위해. 어떻게: focused mutation/content, series/common 영향 범위 회귀, `ktlintCheck`, `git diff --check`를 fresh 실행했다. 결과: 모든 Gradle 명령이 `BUILD SUCCESSFUL`이고 diff check 출력이 없어 Phase 4 후속 리뷰를 완료로 판정했다. + +#### Phase 4 3차 리뷰 보완 + +- [x] **Task 4.9: 시리즈 빈 image의 0-byte 업로드 차단** + +**Goal 실행 `P4-R3`:** 시리즈 생성의 빈 필수 `image`를 부작용 전에 거부하고, 수정의 빈 optional `image`는 생략으로 +정규화해 기존 커버를 유지한다. + +- **추적 review ID:** `REV-037`. +- **시작 조건:** `P3-R11-GATE` 완료와 `phase4-series-review.md` 3차 정적 리뷰 판정 존재. +- **완료 증거:** 생성 빈 image 400/no-side-effect, 수정 JSON+빈 image의 기존 커버 유지/S3 0회 actual endpoint + RED/GREEN, series/common 영향 범위 회귀와 Progress 기록. +- **범위 밖:** OpenAPI schema 변경, 레거시 series service 변경, 정상 image 업로드 경로·파일 정책 확장. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesFacade.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesMutationTest.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** 빈 필수 image로 시리즈를 생성하면 현재 0-byte S3 upload와 DB/event 부작용이 발생하는지 actual + endpoint로 고정한다. +- [x] **RED:** 유효한 수정 JSON과 빈 optional image를 함께 보내면 현재 0-byte cover로 교체되는지 고정한다. +- [x] **GREEN:** create facade에서 `image.isEmpty`를 legacy 호출 전에 400 `common.error.invalid_request`로 거부하고, + update의 빈 image는 null로 정규화해 legacy service에 전달한다. +- [x] **CONTRACT TEST:** 빈 image만 있고 JSON 변경 필드가 없는 update는 기존 `no_changes` 400을 유지하며, 정상 + image 생성·교체와 image 생략 수정은 그대로 동작하는지 확인한다. +- [x] **REFACTOR:** series package와 공통 authorization/error 회귀, `ktlintCheck`, `git diff --check`를 실행해 + Progress와 리뷰 문서에 기록한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.AiCharacterAdminSeriesMutationTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 4 3차 리뷰 Gate + +**Goal 실행 `P4-R3-GATE`:** `REV-037`의 생성·수정 empty-file 정책과 기존 정상 upload 계약을 재검토한다. + +- [x] **`P4-R3-GATE` 완료:** `P4-R3` 완료 후 위 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 리뷰 문서와 + Progress를 갱신한다. +- **시작 조건:** `P4-R3` 완료. +- **완료 증거:** review ID 처리 완료, 생성 빈 image 400/no-side-effect, 수정 빈 image 생략, 정상 upload 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 공개 API schema 변경. + + - 검증 기록(RED): 무엇: 시리즈 생성·수정 empty image. 왜: empty multipart가 legacy service로 전달되어 0-byte S3/cover 변경을 유발하는지 고정하기 위해. 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.AiCharacterAdminSeriesMutationTest`를 실행했다. 결과: 신규 3건이 실패해 생성 400 미충족, 수정 cover 유지 미충족, empty-only no_changes 미충족을 확인했다. + - 검증 기록(GREEN/GATE): 무엇: 생성 empty image 400/no-side-effect, 수정 empty image 생략, empty-only no_changes 유지. 왜: legacy service 공용 동작 변경 없이 v2 facade 경계만 보정하기 위해. 어떻게: 같은 focused 명령 재실행 후 targeted/전체/lint/OpenAPI/mapping/diff 검증을 실행했다. 결과: focused series mutation과 전체 검증이 모두 성공했다. + +#### Phase 4 5차 리뷰 보완 + +- [x] **Task 4.10: 시리즈 생성 primitive 필드의 명시적 null 거부** + +**Goal 실행 `P4-R4`:** 시리즈 생성의 non-null primitive `genreId`, `isAdult`에 명시적 null이 들어오면 JVM 기본값으로 +보정하지 않고 S3·DB·event 전에 400으로 거부하며, 필드 생략 시 기존 기본값은 유지한다. + +- **추적 review ID:** `REV-042`. +- **시작 조건:** `P3-R12-GATE` 완료와 `phase4-series-review.md` 5차 정적 리뷰 판정 존재. +- **완료 증거:** 두 필드의 explicit null actual endpoint RED/GREEN/no-side-effect, 생략 기본값·정상 생성 회귀, + series/common 영향 범위 회귀와 Progress 기록. +- **범위 밖:** 전역 `ObjectMapper` 설정, 레거시 DTO/service 변경, OpenAPI schema·기본값 변경, genre 유효성 정책 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesFacade.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesMutationTest.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreateSeriesRequest.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** `genreId: null`, `isAdult: null`이 현재 각각 primitive 기본값으로 역직렬화되는 경로를 actual endpoint로 + 고정하고 S3·DB·event 결과를 단언한다. +- [x] **GREEN:** v2 생성 경계에서 두 non-null primitive의 명시적 null을 `common.error.invalid_request` 400으로 + 변환한다. +- [x] **CONTRACT TEST:** 두 필드 생략 시 `genreId=0`, `isAdult=false` 기본값과 정상 image 생성, 기존 미지 필드·빈 image + 검증을 유지한다. +- [x] **REFACTOR:** strict parse 결과를 활용한 v2 전용 최소 검증으로 제한하고 series/common 영향 범위 회귀, + `ktlintCheck`, `git diff --check`를 실행해 기록한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.AiCharacterAdminSeriesMutationTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 4 5차 리뷰 Gate + +**Goal 실행 `P4-R4-GATE`:** `REV-042`의 시리즈 생성 primitive nullability와 기본값·부작용 경계를 재검토한다. + +- [x] **`P4-R4-GATE` 완료:** `P4-R4` 완료 후 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 리뷰 문서와 + Progress를 갱신한다. +- **시작 조건:** `P4-R4` 완료. +- **완료 증거:** review ID 처리 완료, explicit null 400/no-side-effect, 생략 기본값과 정상 생성 회귀 성공. +- **범위 밖:** Gate에서 production code, 레거시 API 또는 공개 API schema 변경. +- 검증 기록: 무엇: `REV-042`의 시리즈 생성 primitive explicit null 경계를 처리했다. 왜: OpenAPI non-null primitive가 + Jackson 기본값으로 보정되어 S3·DB·event mutation으로 이어질 수 있기 때문이다. 어떻게: actual multipart POST RED/GREEN, + series/common 영향 범위 회귀, `ktlintCheck`, `git diff --check`를 fresh 실행하고 리뷰 문서를 갱신했다. 결과: `genreId:null`, + `isAdult:null`은 400/no-side-effect로 고정됐고 생략 기본값·정상 생성 회귀는 유지됐다. + +#### Phase 4 후속 기능 보완 + +- [x] **Task 4.11: 시리즈 등록용 장르 목록** + +**Goal 실행 `P4-R5`:** AI 캐릭터 시리즈 등록 화면에서 활성 장르를 `orders` 오름차순으로 조회하고 +`id`, `genre`, `isAdult`의 직접 배열로 반환한다. + +- **추적 review ID:** `REV-046`. +- **시작 조건:** `P3-R13-GATE` 완료와 PRD·OpenAPI의 승인된 장르 목록 계약 존재. +- **완료 증거:** 활성 장르만 정렬된 exact response, 빈 목록, ADMIN 공통 경계와 Series/common 회귀, + OpenAPI `implemented`, Progress 기록. +- **범위 밖:** 장르 CRUD·순서 수정, character별 장르 제한, pagination, 레거시 장르 endpoint 변경. + +**Interfaces:** + +- `GET /api/v2/admin/ai-characters/series-genres` +- Produces: `ApiResponse>`. + +**Files:** + +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesReferenceController.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesFacade.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesGenreTest.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/genre/AdminContentSeriesGenreService.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/genre/AdminContentSeriesGenreRepository.kt` +- Modify: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** active/inactive와 서로 다른 `orders`를 가진 장르로 exact array·정렬·빈 목록을 actual GET에 고정한다. +- [x] **GREEN:** `AdminContentSeriesGenreService.getSeriesGenreList`를 그대로 재사용하고 별도 query·DTO·pagination을 + 추가하지 않는다. +- [x] **CONTRACT TEST:** 정적 `/series-genres`가 character/series 동적 route와 충돌하지 않고 ADMIN 이중 인가와 + 오류 envelope를 유지하는지 확인한다. +- [x] **REFACTOR:** 조회 controller와 facade method만 추가하고 Series/common 영향 범위 회귀, `ktlintCheck`, + OpenAPI 상태, `git diff --check`를 기록한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.AiCharacterAdminSeriesGenreTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 4 장르 목록 Gate + +**Goal 실행 `P4-R5-GATE`:** `REV-046`의 활성 장르·정렬·직접 배열 계약을 재검토한다. + +- [x] **`P4-R5-GATE` 완료:** `P4-R5` 완료 후 focused/영향 범위 회귀와 OpenAPI·lint·diff를 fresh 실행하고 + Phase 4 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P4-R5` 완료. +- **완료 증거:** 활성 장르 `orders` 정렬, exact direct array, route·공통 경계 회귀 성공, + OpenAPI operation `implemented`. +- **범위 밖:** Gate에서 production code 또는 public/legacy API 변경. + +- [x] **Task 4.12: 시리즈 상세 data를 목록 item과 정합화** + +**Goal 실행 `P4-R6`:** 시리즈 상세 `data`를 별도 레거시 상세 DTO가 아니라 시리즈 목록 `items` 하나와 동일한 +11개 필드·타입으로 반환한다. + +- **추적 review ID:** `REV-047`. +- **시작 조건:** `P4-R5-GATE` 완료와 PRD·OpenAPI의 승인된 시리즈 상세 계약 존재. +- **완료 증거:** 목록과 상세의 동일 series exact JSON 대조, owner/active 격리, enum·nullable·cover URL parity, + 기존 상세 전용 `genre`, `keywords` 부재와 Progress 기록. +- **범위 밖:** 목록 wrapper 변경, 시리즈 entity/legacy detail DTO 변경, 새 필드 추가, public/legacy endpoint 변경. + +**Interfaces:** + +- `GET /api/v2/admin/ai-characters/{characterId}/series/{seriesId}` +- Produces `data`: + `seriesId`, `title`, `introduction`, `coverImageUrl`, `publishedDaysOfWeek`, `genreId`, `isAdult`, `state`, + `isActive`, `writer`, `studio`. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesDto.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesFacade.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesController.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesQueryTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesContractTest.kt` +- Modify: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** 동일 series의 목록 item과 상세 `data`가 현재 필드·타입이 다른 것을 exact JSON 비교로 고정한다. +- [x] **GREEN:** v2 상세 response type을 `AiCharacterAdminSeriesListItem`으로 통일하고 owner 범위에서 조회한 entity를 + 동일 필드로 매핑한다. +- [x] **CONTRACT TEST:** `publishedDaysOfWeek`와 `state` enum, `genreId`, `isActive`, nullable `writer/studio`, + cover URL이 목록과 같고 `genre`, `keywords`가 없는지 확인한다. +- [x] **REFACTOR:** 레거시 `GetCreatorAdminContentSeriesDetailResponse`와 entity mapper는 변경하지 않고 v2 + series 경계만 수정해 Series/common 회귀, `ktlintCheck`, OpenAPI 상태, `git diff --check`를 기록한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.AiCharacterAdminSeriesQueryTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.AiCharacterAdminSeriesContractTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 4 시리즈 상세 정합화 Gate + +**Goal 실행 `P4-R6-GATE`:** `REV-047`의 상세 단일 목록-item schema와 runtime parity를 재검토한다. + +- [x] **`P4-R6-GATE` 완료:** `P4-R6` 완료 후 focused/영향 범위 회귀와 OpenAPI·lint·diff를 fresh 실행하고 + Phase 4 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P4-R6` 완료. +- **완료 증거:** 목록 item/상세 data exact parity, owner/active 경계, 구 상세 필드 제거 회귀 성공, + OpenAPI operation `implemented`. +- **범위 밖:** Gate에서 production code 또는 public/legacy API 변경. + +#### Phase 4 multipart request part 계약 후속 보완 + +- [x] **Task 4.13: 시리즈 생성·수정 request part의 application/json 강제** + +**Goal 실행 `P4-R7`:** 시리즈 생성·수정 multipart의 `request` part가 OpenAPI encoding대로 +`application/json`일 때만 handler에 진입하고, 그 밖의 part media type은 공통 415 계약으로 거부되도록 정합화한다. + +- **추적 review ID:** `REV-057`. +- **시작 조건:** `P3-R16-GATE` 완료와 OpenAPI의 두 multipart request encoding 계약 존재. +- **완료 증거:** POST·PUT actual endpoint가 JSON part는 기존 strict parse·image/genre/owner 의미를 유지하고, + `text/plain`·content type 누락 등은 localized 415 `ApiResponse.error`, 표준 `Accept` header, + S3·DB·event no-side-effect를 반환한다. +- **범위 밖:** JSON schema·strict reader·image/genre/state 의미, legacy/public endpoint, + OpenAPI·신규 dependency·DDL 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesController.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesMutationTest.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** POST·PUT의 유효 JSON 문자열을 `text/plain` request part로 보내 현재 handler에 진입하는지 actual + endpoint와 no-side-effect로 고정한다. +- [x] **GREEN:** v2 controller 경계에서 request part의 `application/json` 호환 여부만 확인하고 기존 String + strict reader에 동일 payload를 전달한다. +- [x] **CONTRACT TEST:** KO/EN/JA 415 envelope, `Accept` header, content type 누락과 정상 JSON, + 필수 part 누락 400 및 기존 image/genre/owner 회귀를 확인한다. +- [x] **REFACTOR:** series facade/domain 로직을 변경하지 않고 series/common 영향 범위 회귀, + `ktlintCheck`, OpenAPI 정적 대조, `git diff --check`를 기록한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.AiCharacterAdminSeriesMutationTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 4 multipart request part 계약 후속 Gate + +**Goal 실행 `P4-R7-GATE`:** `REV-057` 수정 뒤 Series POST·PUT의 part-level JSON-only·415 경계를 재검토한다. + +- [x] **`P4-R7-GATE` 완료:** `P4-R7` 완료 후 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 + Phase 4 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P4-R7` 완료. +- **완료 증거:** 정상 JSON·미지원/누락 media type·필수 part·no-side-effect 및 image/genre/owner 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 OpenAPI schema 변경. + +#### Phase 4 multipart part 이름 계약 후속 보완 + +- [x] **Task 4.14: 시리즈 생성·수정의 미정의 multipart part 거부** + +**Goal 실행 `P4-R8`:** Series 생성·수정 multipart에서 OpenAPI가 정의한 `image`, `request` 외 part를 +business mutation 전에 400 `common.error.invalid_request`로 거부한다. + +- **추적 review ID:** `REV-062`. +- **시작 조건:** `P3-R17-GATE` 완료와 `phase4-series-review.md` 7차 정적 리뷰 판정 존재. +- **완료 증거:** POST·PUT 미정의 part의 KO/EN/JA 400 envelope와 S3·DB·event no-side-effect, + 정상 image/genre/owner·필수 part·415 회귀. +- **범위 밖:** OpenAPI schema, image empty·genre/state 의미, 전역 multipart resolver, legacy/public endpoint, + 신규 dependency·DDL. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesController.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesMutationTest.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** 생성·수정에 정상 part와 `unexpected` part를 함께 보내 현재 mutation이 성공하는 경로를 actual + endpoint와 side effect로 고정한다. +- [x] **GREEN:** 실제 part 이름 집합이 POST·PUT 허용 집합 `{image, request}`의 부분집합인지 검사해 초과 이름을 + `AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")`로 거부한다. +- [x] **CONTRACT TEST:** KO/EN/JA 400 envelope, 정상 생성·수정, 필수/빈 image, request part 415, + genre/owner 경계와 no-side-effect를 확인한다. +- [x] **REFACTOR:** Series controller/test만 최소 변경하고 Series/common 영향 범위 회귀, + `ktlintCheck`, OpenAPI 정적 대조와 `git diff --check`를 기록한다. + +**처리 기록 (2026-07-29 / P4-R8):** + +- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.AiCharacterAdminSeriesMutationTest --tests '*shouldRejectUndefinedCreateMultipartPartBeforeSideEffects' --tests '*shouldRejectUndefinedUpdateMultipartPartBeforeSideEffects'` → 새 테스트 6개가 400 기대 대비 기존 mutation 경로로 실패. +- GREEN/focused: 동일 focused 명령 재실행 → `BUILD SUCCESSFUL in 3m 5s`. +- 영향 범위: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.*' --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest` → `BUILD SUCCESSFUL in 2m 9s`. +- 정적 검증: `./gradlew ktlintCheck` → `BUILD SUCCESSFUL in 42s`; `git diff --check` → 출력 없음. +- OpenAPI 대조: `api-contract.openapi.json`의 `SeriesCreateMultipart`, `SeriesUpdateMultipart`는 `additionalProperties: false`이고 허용 property가 `image`, `request`임을 확인했다. + +#### Phase 4 multipart part 이름 계약 후속 Gate + +**Goal 실행 `P4-R8-GATE`:** `REV-062` 수정 뒤 Series POST·PUT의 허용 part 이름과 image·media type 경계를 +재검토한다. + +- [x] **`P4-R8-GATE` 완료:** `P4-R8` 완료 후 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 Phase 4 + 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P4-R8` 완료. +- **완료 증거:** 미정의 part 400/no-side-effect, 정상·필수/빈 image·415·genre/owner 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 OpenAPI schema 변경. + +**Gate 기록 (2026-07-29 / P4-R8-GATE):** + +- Focused: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.AiCharacterAdminSeriesMutationTest --tests '*shouldRejectUndefinedCreateMultipartPartBeforeSideEffects' --tests '*shouldRejectUndefinedUpdateMultipartPartBeforeSideEffects'` → `BUILD SUCCESSFUL in 57s`. +- 영향 범위: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.*' --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest` → `BUILD SUCCESSFUL in 1m 52s`. +- 정적 검증: `./gradlew ktlintCheck` → `BUILD SUCCESSFUL in 16s`; `git diff --check` → 출력 없음. +- 판정: Series POST·PUT 미정의 part 400/no-side-effect, 정상·필수/빈 image·request 415·genre/owner 회귀가 모두 통과해 Phase 4 완료. + +#### Phase 4 multipart 일반 form-field part 후속 보완 + +- [x] **Task 4.15: 시리즈 생성·수정의 전체 multipart part 이름 검증** + +**Goal 실행 `P4-R9`:** Series POST·PUT에서 파일 part뿐 아니라 filename 없는 일반 form-field part를 포함한 모든 +multipart part 이름을 검사해 `{image, request}` 외 이름을 mutation 전에 400 +`common.error.invalid_request`로 거부한다. + +- **추적 review ID:** `REV-067`. +- **시작 조건:** `P3-R18-GATE` 완료와 `phase4-series-review.md` 8차 정적 리뷰 판정 존재. +- **완료 증거:** filename 없는 `unexpected` part의 KO/EN/JA 400 envelope와 S3·DB·event no-side-effect, + 기존 파일형 미정의 part·정상·필수/빈 image·request part 415 회귀 성공. +- **범위 밖:** OpenAPI schema, 전역 multipart resolver, legacy/public endpoint, 신규 dependency·DDL. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesController.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesMutationTest.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** filename 없는 `unexpected` 일반 form-field part를 정상 request와 함께 POST·PUT으로 보내 현재 + `fileMap.keys` 검사를 우회하는 경로와 mutation 부작용을 actual endpoint로 고정한다. +- [x] **GREEN:** servlet request의 전체 part 이름 집합을 `{image, request}`와 비교해 초과 이름을 facade 진입 전에 + 공통 400으로 거부한다. +- [x] **CONTRACT TEST:** KO/EN/JA 오류 envelope, 기존 파일형 미정의 part, 정상·필수/빈 image, + request part 415와 no-side-effect를 확인한다. +- [x] **REFACTOR:** Series controller/test만 최소 변경하고 Series/common 영향 범위 회귀, + `ktlintCheck`, OpenAPI 정적 대조와 `git diff --check`를 기록한다. + +#### Phase 4 multipart 전체 part 이름 후속 Gate + +**Goal 실행 `P4-R9-GATE`:** `REV-067` 수정 뒤 Series POST·PUT의 파일·일반 form-field를 포함한 전체 part 이름과 +기존 image·media type 경계를 재검토한다. + +- [x] **`P4-R9-GATE` 완료:** `P4-R9` 완료 후 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 Phase 4 + 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P4-R9` 완료. +- **완료 증거:** filename 없는 미정의 part 400/no-side-effect와 기존 multipart 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 OpenAPI schema 변경. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.AiCharacterAdminSeriesMutationTest --tests '*shouldRejectFilenameLessUndefined*MultipartPartBeforeSideEffects*' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +- 검증 기록(RED): 무엇: Series POST·PUT filename 없는 `unexpected` multipart part. 왜: `fileMap.keys` 검사가 일반 form-field part를 보지 못하는지 고정하기 위해. 어떻게: Phase 2~5 multipart와 Phase 4 genre focused RED 묶음을 실행했다. 결과: 신규 multipart/genre 36건이 실패했다. +- 검증 기록(GREEN): 무엇: Series multipart 전체 part 이름 검사. 왜: 파일형 part와 일반 form-field part 모두 `{image, request}` allow-list를 따라야 하기 때문이다. 어떻게: controller가 `fileMap.keys`와 servlet `parts` 이름을 모두 검사하도록 바꾸고 같은 focused 묶음을 재실행했다. 결과: `BUILD SUCCESSFUL in 1m 17s`였다. +- 검증 기록(GATE): Series/common 영향 범위 회귀, lint, diff 결과는 `P7-R10-GATE`에 통합 기록한다. + +#### Phase 4 장르 ID domain validation 후속 보완 + +- [x] **Task 4.16: 시리즈 생성·수정의 0 이하 장르 ID 사전 거부** + +**Goal 실행 `P4-R10`:** Series POST·PUT의 non-null `genreId`가 0 이하이거나 활성 장르가 아니면 legacy service +호출 전에 400 `common.error.invalid_request`로 거부한다. + +- **추적 review ID:** `REV-068`. +- **시작 조건:** `P4-R9-GATE` 완료. +- **완료 증거:** 생성·수정의 `genreId=0`, 음수, 미존재 양수는 모두 KO/EN/JA 400이고 S3·DB·event + no-side-effect이며, 활성 장르와 수정 `genreId=null` 회귀 성공. +- **범위 밖:** 장르 조회 정책, OpenAPI schema, legacy repository 반환형, DB constraint, legacy/public endpoint. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesFacade.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesMutationTest.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/genre/CreatorAdminContentSeriesGenreRepository.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** 생성·수정 actual endpoint에 `genreId=0`과 음수를 보내 현재 active-genre 검사를 우회하고 legacy + repository의 non-null 경계에서 예외가 발생하는 경로를 400 기대와 no-side-effect로 고정한다. +- [x] **GREEN:** `rejectMissingActiveGenre`에서 `genreId <= 0 || !repository.existsActiveGenre(genreId)`를 + 공통 invalid request로 변환한다. +- [x] **CONTRACT TEST:** KO/EN/JA 400 envelope, 미존재 양수, 활성 장르, 수정 `genreId=null`, + owner/image/media type 경계와 no-side-effect를 확인한다. +- [x] **REFACTOR:** Series facade/test만 최소 변경하고 Series/common 영향 범위 회귀, + `ktlintCheck`, OpenAPI 정적 대조와 `git diff --check`를 기록한다. + +#### Phase 4 장르 ID domain validation 후속 Gate + +**Goal 실행 `P4-R10-GATE`:** `REV-068` 수정 뒤 Series 생성·수정의 장르 ID domain validation과 기존 +genre/owner/multipart 경계를 재검토한다. + +- [x] **`P4-R10-GATE` 완료:** `P4-R10` 완료 후 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 Phase 4 + 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P4-R10` 완료. +- **완료 증거:** 0 이하·미존재 장르 400/no-side-effect와 활성·nullable 수정 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 OpenAPI schema 변경. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.AiCharacterAdminSeriesMutationTest --tests '*shouldRejectNonPositiveGenreBeforeSideEffects*' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +- 검증 기록(RED): 무엇: Series POST·PUT `genreId=0/-1`. 왜: 0 이하 장르 ID가 active genre 검사를 우회해 legacy service/repository까지 도달하는지 고정하기 위해. 어떻게: Phase 2~5 multipart와 Phase 4 genre focused RED 묶음을 실행했다. 결과: 신규 multipart/genre 36건이 실패했다. +- 검증 기록(GREEN): 무엇: 0 이하·미존재 장르 ID 사전 거부. 왜: 생성·수정의 non-null `genreId`가 유효한 활성 장르가 아니면 legacy 호출 전에 400이어야 하기 때문이다. 어떻게: `rejectMissingActiveGenre`가 `genreId <= 0 || !existsActiveGenre`를 검사하도록 바꾸고 같은 focused 묶음을 재실행했다. 결과: `BUILD SUCCESSFUL in 1m 17s`였다. +- 검증 기록(GATE): Series/common 영향 범위 회귀, lint, diff 결과는 `P7-R10-GATE`에 통합 기록한다. + --- -### Phase 5: 커뮤니티 게시글 관리 vertical slice +### Phase 5: 커뮤니티 게시글·댓글 관리 vertical slice #### 목표 -선택한 AI 캐릭터 소유 커뮤니티 게시글 등록, 수정, 고정/해제, soft delete와 관리자 조회를 제공한다. +선택한 AI 캐릭터 소유 커뮤니티 게시글 등록, 수정, 고정/해제, soft delete, 관리자 조회와 댓글 CRUD를 제공한다. #### 범위와 비범위 -- 포함: owner-scoped community query/write, 최대 고정 3개, soft delete 시 fixed 상태 제거, 이미지/오디오/유료 게시글 검증, 기존 알림/최근 소식 side effect parity. -- 제외: 구매/좋아요/댓글 관리, public community 조회 정책 변경. +- 포함: owner-scoped community query/write, 최대 고정 3개, soft delete 시 fixed 상태 제거, 댓글 root/reply + 조회·작성·수정·soft delete, 이미지/오디오/유료 게시글 검증, 기존 알림/최근 소식 side effect parity. +- 제외: 구매/좋아요, 캐릭터 직접 댓글, 댓글 hard delete·cascade, public community 조회 정책 변경. #### 선행 Phase 및 의존성 - Phase 1 target resolver. - 기존 community write behavior 특성화 테스트. #### API endpoint와 request/response contract -- 정식 전체 schema는 `api-contract.openapi.json`의 Community operation 3개를 따른다. -- `GET /api/v2/admin/ai-characters/{characterId}/community-posts?timezone=&page=&size=` -> - `List`. +- 정식 전체 schema는 `api-contract.openapi.json`의 Community operation 8개를 따른다. +- `GET /api/v2/admin/ai-characters/{characterId}/community-posts?page=&size=` -> + `AiCharacterAdminCommunityPostListResponse(totalCount, page, size, hasNext, items)`. +- `totalCount`는 target creatorMember 소유 active 게시글 전체 개수이고, `items`는 기존 + `GetCommunityPostListResponse` item 필드와 고정 우선 정렬을 유지한다. - `POST /api/v2/admin/ai-characters/{characterId}/community-posts`는 optional `audioFile`, optional `postImage`, 필수 `request: CreateCommunityPostRequest`를 받고 `data: null`을 반환한다. - `PUT /api/v2/admin/ai-characters/{characterId}/community-posts/{postId}`는 optional `postImage`, 필수 request를 받는다. - update request는 두 레거시 update DTO에서 ID를 제외한 `content`, `isCommentAvailable`, `isAdult`, `isActive`, `isFixed`만 포함하고 `data: null`을 반환한다. 수정 `audioFile`, `price`는 레거시 계약에 없어 포함하지 않는다. +- 댓글은 `GET|POST .../{postId}/comments`, `PUT|DELETE .../{postId}/comments/{commentId}`, + `GET .../{commentId}/replies`의 5개 operation을 사용한다. #### entity, repository, service 변경 - Entity: 변경 없음. @@ -2388,7 +4178,7 @@ npx --yes @openapitools/openapi-generator-cli generate \ #### 권장 commit 경계 - `feat: add ai character admin community slice` -- [ ] **Task 5.1: 기존 community behavior 특성화 baseline 고정** +- [x] **Task 5.1: 기존 community behavior 특성화 baseline 고정** **Goal 실행 `P5-T1`:** 기존 community의 media·유료·고정·soft delete·알림 동작을 신규 v2 비교 기준으로 고정한다. @@ -2400,13 +4190,39 @@ npx --yes @openapitools/openapi-generator-cli generate \ - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt` -- [ ] image/audio/paid post validation과 notification/recent-news side effect baseline을 작성한다. -- [ ] 최대 고정 3개와 fixed post soft delete clearing baseline을 작성한다. -- [ ] Phase 5 domain/client 오류별 정확한 status와 KO/EN/JA message key를 확정해 구현 Goal에 반영한다. -- [ ] production code 변경 없이 기존 구현 대상 특성화 테스트 통과를 확인한다. -- [ ] fixture/event spy만 정리하고 test와 `ktlintCheck` 결과를 Progress에 기록한다. +- [x] image/audio/paid post validation과 notification/recent-news side effect baseline을 작성한다. +- [x] 최대 고정 3개와 fixed post soft delete clearing baseline을 작성한다. +- [x] Phase 5 domain/client 오류별 정확한 status와 KO/EN/JA message key를 확정해 구현 Goal에 반영한다. +- [x] production code 변경 없이 기존 구현 대상 특성화 테스트 통과를 확인한다. +- [x] fixture/event spy만 정리하고 test와 `ktlintCheck` 결과를 Progress에 기록한다. -- [ ] **Task 5.2: 관리자 게시글 목록 조회 구현** +- **관찰된 legacy baseline:** 유료 게시글은 `postImage`가 필수이고 오디오 게시글도 `postImage` 없이는 실패한다. 무료 게시글 생성은 FCM + `CHANGE_NOTICE`와 home following recent-news를 발행하지만 유료 게시글은 recent-news를 발행하지 않는다. 이미 고정된 게시글의 + 재고정은 최대 3개 count를 다시 적용하지 않고, 다른 미고정 게시글을 4번째로 고정하려 하면 `creator.community.max_fixed_post_count`로 + 실패한다. `isActive=false` 수정은 같은 transaction에서 `isFixed=false`, `fixedAt=null`로 정리한다. +- **Phase 5 v2 domain/client 오류 결정:** target/post missing·inactive·cross-owner, pagination·binding 실패, 최대 고정 3개 초과, + 유료/오디오 게시글 이미지 누락, 이미지 validation 실패는 신규 prefix에서 400으로 반환한다. ownership/validation 실패는 DB/S3/FCM/recent-news + side effect 전에 발생해야 한다. 예상하지 못한 S3/event/server 오류는 500 `common.error.unknown`을 사용하며, legacy처럼 recent-news + publish 실패는 게시글 생성을 실패시키지 않는다. + +| Phase 5 domain/client 경우 | status | message key | KO | EN | JA | +|---|---:|---|---|---|---| +| missing/inactive/cross-owner resource, pagination·binding 검증 실패 | 400 | `common.error.invalid_request` | 잘못된 요청입니다. | Invalid request. | 無効なリクエストです。 | +| 유료 게시글 이미지 누락 | 400 | `creator.community.paid_post_image_required` | 유료 게시글은 이미지를 등록해 주세요. | Please add an image for paid posts. | 有料投稿には画像を登録してください。 | +| 오디오 게시글 이미지 누락 | 400 | `creator.community.audio_post_image_required` | 오디오 게시글은 이미지를 등록해 주세요. | Please add an image for audio posts. | オーディオ投稿には画像を登録してください。 | +| 고정 게시글 3개 초과 | 400 | `creator.community.max_fixed_post_count` | 고정 게시글은 최대 3개까지 가능합니다. | You can pin up to 3 posts. | 固定投稿は最大3件まで可能です。 | +| 이미지가 아님 | 400 | `image.error.only_image_allowed` | 이미지만 업로드할 수 있습니다. | Only images can be uploaded. | 画像のみアップロードできます。 | +| 유료가 아닌 게시글 GIF 이미지 | 400 | `image.error.gif_paid_only` | GIF 이미지는 유료 게시글에만 등록할 수 있습니다. | GIF images can only be used for paid posts. | GIF画像は有料投稿にのみ登録できます。 | +| 예상하지 못한 server/infrastructure 오류 | 500 | `common.error.unknown` | 알 수 없는 오류가 발생했습니다. 다시 시도해 주세요. | An unknown error occurred. try again. | 不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。 | + +- 검증 기록: 무엇: legacy community media/paid validation, FCM/recent-news side effect, fixed limit와 soft-delete fixed clearing baseline. + 왜: Phase 5 v2 구현 전에 재사용할 legacy 동작과 신규 prefix에서 보강할 owner-first 오류 경계를 분리하기 위해. 어떻게: + `LegacyCommunityPostCharacterizationTest`를 추가하고 production 변경 없이 focused/community package test, `ktlintCheck`, `git diff --check`를 + 실행했다. 결과: focused characterization은 `BUILD SUCCESSFUL in 2m 22s`, community package targeted test는 + `BUILD SUCCESSFUL in 2m 22s`, `ktlintCheck`는 unused import 2건 정리 후 `BUILD SUCCESSFUL in 21s`, `git diff --check`는 + 출력이 없었다. + +- [x] **Task 5.2: 관리자 게시글 목록 조회 구현** **Goal 실행 `P5-T2`:** target owner의 관리자용 커뮤니티 게시글 목록을 안전한 전용 DTO와 pagination으로 제공한다. @@ -2422,12 +4238,21 @@ npx --yes @openapitools/openapi-generator-cli generate \ - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostQueryTest.kt` -- [ ] 미구현 목록, owner 격리, pagination 경계와 관리자 DTO 실패 test를 작성한다. -- [ ] 최소 owner-scoped query와 `page/size` 보정으로 focused test를 통과시킨다. -- [ ] 유료/media private 정보와 public viewer 상태를 부적절하게 노출하지 않는지 검증한다. -- [ ] focused test와 `ktlintCheck` 결과를 Progress에 기록한다. +- [x] 미구현 목록, owner 격리, pagination 경계와 관리자 DTO 실패 test를 작성한다. +- [x] 최소 owner-scoped query와 `page/size` 보정으로 focused test를 통과시킨다. +- [x] 유료/media private 정보와 public viewer 상태를 부적절하게 노출하지 않는지 검증한다. +- [x] focused test와 `ktlintCheck` 결과를 Progress에 기록한다. -- [ ] **Task 5.3: 커뮤니티 게시글 생성 구현** +- 검증 기록: 무엇: target 소유 활성 게시글 목록, 고정 정렬, pagination, legacy DTO 배열 형태, 유료 오디오 owner signed URL과 오류 계약. + 왜: 공개 viewer 정책을 복제하지 않고 target `creatorMember`를 관리자 조회의 owner viewer로 고정하기 위해. 어떻게: RED로 + `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostQueryTest --rerun-tasks`를 + 실행해 route 부재로 4개 상태 코드 assertion이 실패함을 확인했다. 리뷰 보완으로 row별 count 조회가 붙은 목록의 `size=51`을 + 추가 RED로 확인했고, `size` 허용 범위를 1..50으로 제한했다. 최종 GREEN으로 같은 focused 명령은 + `BUILD SUCCESSFUL in 3m`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*' --rerun-tasks`는 + `BUILD SUCCESSFUL in 3m 24s`, `./gradlew ktlintCheck --rerun-tasks`는 `BUILD SUCCESSFUL in 27s`, `git diff --check`는 + 출력이 없었다. 전체 회귀는 task 범위가 신규 관리자 community query에 한정되어 있어 실행하지 않았다. + +- [x] **Task 5.3: 커뮤니티 게시글 생성 구현** **Goal 실행 `P5-T3`:** target creatorMember 작성자로 이미지·오디오·유료 게시글을 기존 검증과 side effect parity로 생성한다. @@ -2442,12 +4267,12 @@ npx --yes @openapitools/openapi-generator-cli generate \ - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt` -- [ ] 정상/image/audio/paid validation과 invalid target 실패 test를 작성한다. -- [ ] 해석된 creatorMember를 writer/owner로 사용하는 최소 생성 구현을 통과시킨다. -- [ ] S3, notification, recent-news 호출 순서와 실패 시 부분 저장/no-side-effect를 검증한다. -- [ ] focused/legacy characterization test와 `ktlintCheck` 결과를 Progress에 기록한다. +- [x] 정상/image/audio/paid validation과 invalid target 실패 test를 작성한다. +- [x] 해석된 creatorMember를 writer/owner로 사용하는 최소 생성 구현을 통과시킨다. +- [x] S3 media upload 결과와 validation/target 실패 시 DB·S3 no-side-effect를 검증한다. +- [x] focused/legacy characterization test와 `ktlintCheck` 결과를 Progress에 기록한다. -- [ ] **Task 5.4: 게시글 수정·고정·soft delete 구현** +- [x] **Task 5.4: 게시글 수정·고정·soft delete 구현** **Goal 실행 `P5-T4`:** owner 게시글만 수정·고정/해제하고 soft delete 시 고정 상태와 시간을 함께 제거한다. @@ -2458,16 +4283,23 @@ npx --yes @openapitools/openapi-generator-cli generate \ **Files:** - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostController.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostDto.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostUpdateTest.kt` -- [ ] 수정·고정/해제·soft delete와 cross-owner 실패 test를 작성한다. -- [ ] owner 검증 후 최소 mutation 구현으로 test를 통과시킨다. -- [ ] soft delete가 한 transaction에서 `isActive=false`, `isFixed=false`, `fixedAt=null`을 적용하는지 검증한다. -- [ ] focused/legacy characterization test와 `ktlintCheck` 결과를 Progress에 기록한다. +- [x] 수정·고정/해제·soft delete와 cross-owner 실패 test를 작성한다. +- [x] owner 검증 후 최소 mutation 구현으로 test를 통과시킨다. +- [x] soft delete가 한 transaction에서 `isActive=false`, `isFixed=false`, `fixedAt=null`을 적용하는지 검증한다. +- [x] focused/legacy characterization test와 `ktlintCheck` 결과를 Progress에 기록한다. -- [ ] **Task 5.5: 최대 고정 수·동시성·side effect 검증** +- 검증 기록(RED): 무엇: content/comment/adult 수정, 이미지 교체, 고정/해제, soft delete, target/post/cross-owner/inactive 거부와 request part 누락 실제 endpoint 계약. 왜: `PUT` route와 owner-first mutation이 구현 전에는 존재하지 않음을 고정하기 위해. 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostUpdateTest`를 production 변경 전에 실행했다. 결과: 6개 테스트가 기대 200/400 대신 미구현 `PUT`의 405로 실패해 `BUILD FAILED in 50s`였다. +- 검증 기록(GREEN/REFACTOR): 무엇: active target과 active owner post 사전 검증, legacy 수정/고정 위임, soft delete fixed clearing 및 no-side-effect. 왜: legacy media/fixed 정책은 유지하면서 v2 경계의 cross-owner/inactive mutation을 차단하기 위해. 어떻게: focused test, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*'`, `./gradlew ktlintCheck`를 실행했다. 결과: focused test는 `BUILD SUCCESSFUL in 3m 52s`, community package 회귀는 `BUILD SUCCESSFUL in 59s`, ktlint는 `BUILD SUCCESSFUL in 29s`였다. 전체 `./gradlew test`는 신규 community update slice의 direct focused/community 회귀가 실행됐으므로 실행하지 않았다. +- 최종 fresh community 회귀: `./gradlew test --rerun-tasks --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*'` 실행 결과 10개 Gradle task가 수행되어 `BUILD SUCCESSFUL in 5m 34s`였다. +- 코드 품질 보완 RED: 최대 고정 3개인 owner가 네 번째 게시글을 `postImage`와 `isFixed=true`로 수정할 때 legacy 최대 고정 오류를 반환하면서도 imagePath와 S3 `putObject`가 변경되지 않아야 하는 test를 추가했다. 기존 호출 순서에서 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostUpdateTest`를 실행한 결과 7개 중 새 test 1개가 imagePath 변경 assertion에서 실패해 `BUILD FAILED in 48s`였다. +- 코드 품질 보완 GREEN: `isActive != false && isFixed != null`인 고정 호출을 legacy 이미지 수정 앞에 두고 같은 focused 명령을 실행한 결과 7개 test가 `BUILD SUCCESSFUL in 59s`였다. 이어 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*'`는 `BUILD SUCCESSFUL in 1m 10s`, `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 32s`였다. + +- [x] **Task 5.5: 최대 고정 수·동시성·side effect 검증** **Goal 실행 `P5-T5`:** 최대 고정 게시글 3개 정책이 동시 요청과 실패에서도 깨지지 않도록 고정한다. @@ -2481,12 +4313,31 @@ npx --yes @openapitools/openapi-generator-cli generate \ - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostConcurrencyTest.kt` -- [ ] 3개 허용·4번째 거부와 동시 고정 요청 실패 test를 작성한다. -- [ ] 기존 repository count/update 순서를 유지하는 최소 구현으로 test를 통과시킨다. -- [ ] invalid target/ownership 실패 시 DB/S3/event 0건을 검증한다. -- [ ] concurrency/focused test와 `ktlintCheck` 결과를 Progress에 기록한다. +- [x] 순차 고정 요청에서 3개 허용·4번째 거부와 재고정 count 생략을 특성화한다. +- [x] 기존 legacy repository count/update 순서를 유지하고 production 변경 없이 test를 통과시킨다. +- [x] invalid target/ownership 고정 실패 시 DB/S3/event 0건을 검증한다. +- [x] concurrency 제약, focused/community test와 `ktlintCheck` 결과를 Progress에 기록한다. -- [ ] **Task 5.6: Phase 5 보안·오류·회귀 검증** +- **TDD 예외/특성화:** `AiCharacterAdminCommunityPostConcurrencyTest`를 production 변경 전에 추가해 첫 focused + 실행했으나 3개 test가 모두 통과했다. 이는 P5-T4 facade가 legacy 고정 호출을 수정·이미지 업로드보다 먼저 수행하고, + `CreatorCommunityService.updateCommunityPostFixed`가 미고정 post에만 활성 고정 수를 조회하는 기존 동작이 이미 요구를 + 충족했기 때문이다. 따라서 production 코드, lock, DB constraint, dependency를 추가하지 않았다. +- **동시성 관찰 제한:** 현재 legacy 정책은 `countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue` 뒤 entity를 갱신하는 + count/update 순서이며 lock 또는 DB constraint가 없다. 서로 다른 미고정 post의 실제 병렬 요청은 두 요청이 같은 count를 + 읽을 수 있어 결정적으로 재현·검증할 수 없으므로 sleep/flaky test를 추가하지 않고, 2개 고정 상태에서 세 번째 성공 뒤 + 네 번째 거부되는 순차 특성화만 고정했다. 이 Task 범위는 기존 정책 변경을 포함하지 않는다. +- 검증 기록(특성화): 무엇: 세 번째 활성 고정 성공, 네 번째 활성 고정 400 최대 고정 메시지, 최대 상태의 이미 고정된 post + 재고정, invalid target/cross-owner fixed multipart 요청의 DB/imagePath/S3/event 무변경. 왜: 기존 legacy 고정 정책과 + owner-first side-effect 차단을 production 변경 없이 고정하기 위해. 어떻게: + `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostConcurrencyTest`를 + 실행했다. 결과: `BUILD SUCCESSFUL in 45s`, 10 actionable tasks 중 3 executed, 7 up-to-date였다. +- 검증 기록(영향 범위): 무엇: 전체 v2 admin community package 회귀와 Kotlin lint. 왜: 신규 focused test의 controller/facade와 + legacy community 경계 회귀를 확인하기 위해. 어떻게: + `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*'`, `./gradlew ktlintCheck`를 실행했다. + 결과: package test는 `BUILD SUCCESSFUL in 1m 14s`, 10 actionable tasks 중 1 executed, 9 up-to-date였고, ktlint는 + `BUILD SUCCESSFUL in 23s`, 7 actionable tasks 중 2 executed, 5 up-to-date였다. + +- [x] **Task 5.6: Phase 5 보안·오류·회귀 검증** **Goal 실행 `P5-T6`:** 모든 community endpoint의 ADMIN·오류·ownership 계약과 기존 public/legacy 회귀를 고정한다. @@ -2499,47 +4350,643 @@ npx --yes @openapitools/openapi-generator-cli generate \ - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostContractTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAuthorizationTest.kt` -- [ ] endpoint별 ADMIN 이중 인가와 stale claim을 검증한다. -- [ ] target/post/media/fixed-count 오류의 정확한 status, message key, KO/EN/JA envelope를 확정·검증한다. -- [ ] HUMAN/cross-character 게시글 mutation 거부와 legacy/public community 계약을 검증한다. -- [ ] Phase 5 focused test와 `ktlintCheck` 결과를 Progress에 기록한다. +- [x] endpoint별 ADMIN 이중 인가와 stale claim을 검증한다. +- [x] target/post/media/fixed-count 오류의 정확한 status, message key, KO/EN/JA envelope를 확정·검증한다. +- [x] HUMAN/cross-character 게시글 mutation 거부와 legacy/public community 계약을 검증한다. +- [x] Phase 5 focused test와 `ktlintCheck` 결과를 Progress에 기록한다. + +- **TDD 예외/특성화 (2026-07-28):** production 변경 전에 + `AiCharacterAdminCommunityPostContractTest`를 추가하고 + `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostContractTest --rerun-tasks`를 + 실행했다. invalid target/post, paid media, 최대 고정 수, 필수 multipart `request` part의 KO/EN/JA + `ApiResponse.error`, HUMAN target·cross-character mutation의 DB/S3 무변경이 모두 기존 구현에서 통과했다. + 요구 동작이 이미 충족된 순수 특성화이므로 production 코드, dependency, legacy/public endpoint를 변경하지 않았다. +- 검증 기록(focused, 2026-07-28): 무엇: community `GET`/`POST`/`PUT`의 JWT 비ADMIN 및 stale ADMIN claim + 차단, 실제 endpoint 오류·소유권 계약. 왜: prefix 공통 sample/series 검증만으로는 community mapping 전체를 + 보장할 수 없기 때문이다. 어떻게: + `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest --rerun-tasks`, + `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostContractTest --rerun-tasks`를 + 실행했다. 결과: 각각 `BUILD SUCCESSFUL in 4m 45s`(10 actionable tasks 모두 실행), + `BUILD SUCCESSFUL in 4m 6s`(10 actionable tasks 모두 실행)였다. +- 검증 기록(Phase 5 Gate, 2026-07-28): 무엇: legacy 특성화와 v2 community package 회귀, Kotlin lint. + 왜: P5-T1~P5-T6의 목록·생성·수정·고정·soft delete 및 기존 community 계약을 최종 확인하기 위해. 어떻게: + `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*' --rerun-tasks`, + `./gradlew ktlintCheck --rerun-tasks`를 실행했다. 결과: package test는 + `BUILD SUCCESSFUL in 5m 52s`(10 actionable tasks 모두 실행), ktlint는 + `BUILD SUCCESSFUL in 35s`(7 actionable tasks 모두 실행)였다. 전체 `./gradlew test`는 커뮤니티 경계와 + 공통 인가 production 코드가 변경되지 않아 실행하지 않았다. #### Phase 5 Gate **Goal 실행 `P5-GATE`:** Phase 5 community 사용자 흐름과 고정·side-effect·회귀 품질을 최종 판정한다. -- [ ] **`P5-GATE` 완료:** 시작 조건과 완료 증거를 모두 충족하고 현재 상태표와 Progress를 갱신한다. +- [x] **`P5-GATE` 완료:** 시작 조건과 완료 증거를 모두 충족하고 현재 상태표와 Progress를 갱신한다. - **시작 조건:** `P5-T1`~`P5-T6` 완료. - **완료 증거:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*'`와 `./gradlew ktlintCheck` 성공, Progress 기록. - **범위 밖:** 실패와 무관한 Phase 6 구현. +#### Phase 5 후속 리뷰 보완 + +- [x] **Task 5.7: 커뮤니티 JSON 오류와 pagination을 OpenAPI에 정합화** + +**Goal 실행 `P5-R1`:** `REV-025`의 multipart JSON 파싱 실패·미지 필드를 일관된 400으로 처리하고, +`REV-026`의 계약에 없는 목록 `size <= 50` 제한을 제거한다. + +- **추적 review ID:** `REV-025`, `REV-026`. +- **시작 조건:** `P4-R1-GATE` 완료와 `phase5-community-review.md` 판정 존재. +- **완료 증거:** create/update의 malformed·필수 필드 누락·미지 필드 JSON이 400 + `common.error.invalid_request`이고 DB/S3/event 부작용이 0회인 actual endpoint 테스트, `size=51` 요청이 문서 계약대로 + 상한 검증에 막히지 않는 목록 테스트, community/common 회귀와 Progress 기록. +- **범위 밖:** OpenAPI에 pagination 상한 추가, legacy/public community controller 변경, media/fixed/notification 정책 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostUpdateTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostQueryTest.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** create/update `request` part의 malformed JSON, 필수 필드 누락, 미지 필드가 exact 400 envelope과 + DB/S3/event 0회를 반환하는지 확인한다. +- [x] **RED:** `GET .../community-posts?size=51`이 계약 밖 상한 오류 없이 정상 pagination으로 처리되는지 확인한다. +- [x] **GREEN:** legacy service 호출 전에 strict reader로 request DTO를 검증하고 JSON mapping 예외를 + `common.error.invalid_request`로 변환하며 목록의 `size > 50` guard만 제거한다. +- [x] **REFACTOR:** community/common 영향 범위 회귀, `ktlintCheck`, diff check를 실행한다. +- 검증 기록(RED): 무엇: community create/update `request` part의 malformed JSON, create 필수 field 누락, create/update 미지 field와 목록 `size=51` 계약을 actual endpoint로 고정했다. 왜: `REV-025`~`REV-026`이 runtime에서 실제 실패하는지 확인하기 위해. 어떻게: 아래 focused create/update/query 명령을 production 변경 전 실행했다. 결과: 19개 중 3개가 JSON 경계와 `size=51` 상한 때문에 실패해 `BUILD FAILED in 1m 50s`였다. OpenAPI 확인 결과 update request는 required field가 없어 update 필수 field 누락 케이스는 제거했다. +- 검증 기록(GREEN/REFACTOR): 무엇: create/update를 legacy service 호출 전 strict reader로 검증하고 Jackson parse/mapping 오류를 400 `common.error.invalid_request`로 변환했으며 목록의 `size <= 50` 상한만 제거했다. 왜: OpenAPI `additionalProperties: false`와 `Size` maximum 부재 계약을 runtime에 맞추기 위해. 어떻게: focused create/update/query 명령, community/common 회귀, `ktlintCheck`, `git diff --check`를 실행했다. 결과: focused 명령은 `BUILD SUCCESSFUL in 1m 24s`, community/common 회귀는 `BUILD SUCCESSFUL in 2m`, `ktlintCheck`는 `BUILD SUCCESSFUL in 1m 2s`, `git diff --check`는 출력이 없었다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostCreateTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostUpdateTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostQueryTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 5 후속 리뷰 Gate + +**Goal 실행 `P5-R1-GATE`:** `REV-025`~`REV-026` 수정 뒤 Community 3개 operation의 JSON 오류와 pagination 경계를 +재검토한다. + +- [x] **`P5-R1-GATE` 완료:** `P5-R1` 완료 후 actual endpoint no-side-effect와 community/common 회귀, + lint·diff를 fresh 실행하고 리뷰 문서와 Progress를 갱신한다. +- **시작 조건:** `P5-R1` 완료. +- **완료 증거:** 잘못된 JSON의 400 통일, 미지 필드 거부, 계약 밖 size 상한 제거, 영향 범위 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 OpenAPI schema 변경. +- 검증 기록: 무엇: `P5-R1-GATE`에서 Community 3개 operation의 JSON 오류와 pagination 경계를 재판정했다. 왜: `REV-025`~`REV-026` 처리 후 actual endpoint no-side-effect와 영향 범위 회귀를 확인하기 위해. 어떻게: `P5-R1`과 같은 focused/community/common 회귀, `ktlintCheck`, `git diff --check` 증거를 기준으로 리뷰 문서와 Progress를 갱신했다. 결과: 잘못된 JSON의 400 통일, 미지 field 거부, `size=51` 허용과 영향 범위 회귀가 모두 통과했다. + +#### Phase 5 2차 후속 리뷰 보완 + +- [x] **Task 5.8: 최대 고정 3개 동시성 보장** + +**Goal 실행 `P5-R2`:** `REV-033`의 count-then-update 경쟁 조건을 owner 단위로 직렬화해 실제 동시 요청에서도 +활성 고정 게시글이 3개를 초과하지 않도록 한다. + +- **추적 review ID:** `REV-033`. +- **시작 조건:** `P5-R1-GATE` 완료와 `phase5-community-review.md` 2차 리뷰 판정 존재. +- **완료 증거:** 두 독립 transaction의 결정적 동시 요청 RED, owner lock 순서 증거, 최종 고정 수 3개와 + 한 요청 성공·한 요청 400, 실패 요청의 S3/event 무변경, community/common 영향 범위 회귀와 Progress 기록. +- **범위 밖:** 신규 DDL/unique constraint/dependency, 최대 수 정책 변경, legacy/public endpoint 변경, + sleep 또는 반복 확률에 의존하는 flaky test. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostConcurrencyTest.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` + +- [x] **RED:** 활성 고정 2개와 서로 다른 미고정 게시글 2개를 준비하고, 두 독립 transaction을 barrier/lock probe로 + 같은 owner에 동시에 진입시켜 현재 최종 고정 수가 4개가 될 수 있음을 결정적으로 재현한다. +- [x] **RED:** 기존 순차 테스트와 별개로 실제 병렬 요청임을 thread/transaction ID와 barrier 도달 assertion으로 확인하고, + sleep·무작위 반복으로 성공 확률을 높이는 방식은 사용하지 않는다. +- [x] **GREEN:** 기존 `MemberRepository.findByIdForUpdate`를 재사용해 fixed/unfixed count·update 전에 owner row를 + 잠그고, legacy 최대 3개 검증과 mutation을 같은 transaction에서 직렬화한다. +- [x] **GREEN:** 같은 동시성 테스트에서 최종 고정 수 3개, 한 요청의 최대 고정 오류, DB/imagePath/S3/event 결과를 + 확인한다. +- [x] **REFACTOR:** 재고정·해제·soft delete와 순차 세 번째/네 번째 요청을 유지하고 community/common 영향 범위 회귀, + `ktlintCheck`, `git diff --check`를 실행해 Progress에 기록한다. +- 검증 기록(RED): 무엇: 활성 고정 2개 상태에서 서로 다른 미고정 게시글 2개를 병렬 fixed 요청으로 보내 두 요청이 같은 count 경계를 통과하는 race를 고정했다. 왜: `REV-033`의 count-then-update 경쟁 조건을 순차 테스트가 아닌 실제 병렬 요청으로 재현하기 위해. 어떻게: `CreatorCommunityRepository.countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue` 첫 호출을 latch로 지연하고 두 번째 요청을 진입시킨 뒤 `AiCharacterAdminCommunityPostConcurrencyTest`를 production 변경 전 실행했다. 결과: 신규 병렬 테스트가 기대 `[200, 400]` 대비 `[200, 200]`과 최종 4개 고정으로 실패해 RED를 확인했다. +- 검증 기록(GREEN/REFACTOR): 무엇: fixed 변경 요청에서 legacy count/update 전에 `MemberRepository.findByIdForUpdate(creatorMemberId)`로 owner row를 잠그고, 병렬 요청을 직렬화했다. 왜: 신규 DDL 없이 owner 단위 최대 고정 3개 불변식을 같은 transaction 안에서 보장하기 위해. 어떻게: focused concurrency, community/common 영향 범위 회귀, `./gradlew ktlintCheck`, `git diff --check`를 실행했다. 결과: focused는 `BUILD SUCCESSFUL in 46s`, community/common 회귀는 `BUILD SUCCESSFUL in 1m 19s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 25s`, `git diff --check`는 출력이 없었다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostConcurrencyTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 5 2차 후속 리뷰 Gate + +**Goal 실행 `P5-R2-GATE`:** `REV-033`의 owner lock과 최대 고정 수 동시성 불변식을 재검토한다. + +- [x] **`P5-R2-GATE` 완료:** `P5-R2` 완료 후 동시성 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 + 리뷰 문서와 Progress를 갱신한다. +- **시작 조건:** `P5-R2` 완료. +- **완료 증거:** `REV-033` 처리 완료, 결정적 병렬 재현, 최대 3개와 실패 no-side-effect, 순차/soft-delete 회귀 성공. +- **범위 밖:** Gate에서 production code, DB schema 또는 공개 API 계약 변경. +- 검증 기록: 무엇: `P5-R2-GATE`에서 owner lock과 최대 고정 수 동시성 불변식을 재판정했다. 왜: `REV-033` 처리 후 병렬/순차 fixed 정책과 community/common 영향 범위가 모두 유지되는지 확인하기 위해. 어떻게: focused concurrency, community/common 회귀, lint, diff check 결과를 fresh 확인하고 리뷰 문서와 Progress를 갱신했다. 결과: 모든 Gradle 명령이 `BUILD SUCCESSFUL`이고 diff check 출력이 없어 Phase 5 후속 리뷰를 완료로 판정했다. + +#### Phase 5 5차 리뷰 보완 + +- [x] **Task 5.9: 커뮤니티 primitive 필드의 required·null 계약 강제** + +**Goal 실행 `P5-R3`:** 커뮤니티 생성의 필수 boolean 누락·null과 optional `price`의 explicit null, 수정 +`isFixed`의 explicit null을 생략 또는 JVM 기본값으로 보정하지 않고 S3·DB·event 전에 400으로 거부한다. + +- **추적 review ID:** `REV-043`. +- **시작 조건:** `P4-R4-GATE` 완료와 `phase5-community-review.md` 5차 정적 리뷰 판정 존재. +- **완료 증거:** 생성 required boolean 누락·null, `price: null`, 수정 `isFixed: null`의 actual endpoint + RED/GREEN/no-side-effect, optional 생략·정상 mutation 회귀, community/common 영향 범위 회귀와 Progress 기록. +- **범위 밖:** 전역 `ObjectMapper` 설정, 레거시 DTO/service 변경, OpenAPI schema·기본값 변경, community 정책 확장. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostUpdateTest.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreateCommunityPostRequest.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** 생성 `isCommentAvailable`, `isAdult`의 누락·null과 `price: null`, 수정 `isFixed: null`이 현재 + false·0 또는 생략으로 처리되는지 actual endpoint로 고정하고 DB·S3·event 결과를 단언한다. +- [x] **GREEN:** v2 create/update 경계에서 required primitive의 존재와 모든 non-null primitive의 명시적 null을 + 검증해 `common.error.invalid_request` 400으로 변환한다. +- [x] **CONTRACT TEST:** 생성 `price` 생략은 `0`, 수정 `isFixed` 생략은 변경 없음으로 유지하고 정상 + create/update/fix/soft delete와 기존 미지 필드 거부를 확인한다. +- [x] **REFACTOR:** 기존 strict parse 결과를 재사용하는 최소 검증으로 제한하고 community/common 영향 범위 회귀, + `ktlintCheck`, `git diff --check`를 실행해 기록한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostCreateTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostUpdateTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 5 5차 리뷰 Gate + +**Goal 실행 `P5-R3-GATE`:** `REV-043`의 커뮤니티 primitive required/nullability와 생략 기본값·부작용 경계를 재검토한다. + +- [x] **`P5-R3-GATE` 완료:** `P5-R3` 완료 후 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 리뷰 문서와 + Progress를 갱신한다. +- **시작 조건:** `P5-R3` 완료. +- **완료 증거:** review ID 처리 완료, invalid primitive 400/no-side-effect, optional 생략과 정상 mutation 회귀 성공. +- **범위 밖:** Gate에서 production code, 레거시 API 또는 공개 API schema 변경. +- 검증 기록: 무엇: `REV-043`의 커뮤니티 primitive required/nullability 경계를 처리했다. 왜: 생성 required boolean과 + `price`, 수정 `isFixed`의 null/누락이 기본값 또는 생략으로 보정되면 잘못된 mutation이 진행될 수 있기 때문이다. 어떻게: + actual multipart POST/PUT RED/GREEN, create/update focused, community/common 영향 범위 회귀, `ktlintCheck`, `git diff --check`를 + fresh 실행하고 리뷰 문서를 갱신했다. 결과: invalid primitive 요청은 400/no-side-effect로 고정됐고 optional 생략·정상 mutation 회귀는 유지됐다. + +#### Phase 5 목록 계약 변경 보완 + +- [x] **Task 5.10: 커뮤니티 목록 timezone 제거와 pagination metadata 제공** + +**Goal 실행 `P5-R4`:** 커뮤니티 목록을 `timezone` 없이 조회하고 active owner 게시글의 전체 개수와 현재 page/size, +다음 페이지 여부, 기존 item 목록을 반환한다. + +- **추적 근거:** `DEC-P5-LIST-001`. +- **시작 조건:** `P5-R3-GATE` 완료와 PRD·OpenAPI의 승인된 목록 계약 존재. +- **완료 증거:** timezone 없는 actual GET의 RED/GREEN, `totalCount/page/size/hasNext/items` exact response, + 첫·중간·마지막·범위 밖 page와 active owner count 회귀, community/common 영향 범위 회귀와 Progress 기록. +- **범위 밖:** 목록 item 필드·정렬 변경, public/legacy community endpoint 변경, 검색/filter 추가, Spring `Page` 공개, + 신규 dependency·DDL. + +**Interfaces:** + +- Consumes: `characterId`, `page` 기본값 `0`, `size` 기본값 `20`. +- Produces: + `AiCharacterAdminCommunityPostListResponse(totalCount: Long, page: Int, size: Int, hasNext: Boolean, items: List)`. +- `totalCount`: target creatorMember 소유이면서 `isActive=true`인 게시글 전체 개수. +- `hasNext`: `pageable.offset + items.size < totalCount`. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostController.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostDto.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostRepository.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostQueryTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostContractTest.kt` +- Modify: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` +- Modify: `docs/20260724_AI캐릭터_관리자_API/api-contract.md` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/prd.md` + +- [x] **RED:** `timezone` 없이 호출한 GET이 현재 400을 반환하는 것과, timezone을 전달한 정상 호출의 `data`가 + pagination object가 아니라 직접 배열인 계약 차이를 actual endpoint로 고정한다. +- [x] **RED:** active owner 게시글을 `size + 1`개 이상 준비하고 첫 page의 `totalCount`, `page`, `size`, + `hasNext=true`, 마지막 page의 `hasNext=false`, 범위 밖 page의 빈 `items`를 exact JSON으로 고정한다. +- [x] **GREEN:** controller/facade에서 `timezone` parameter와 사용되지 않는 검증을 제거하고 `page`, `size`만 전달한다. +- [x] **GREEN:** repository에 active owner count query 하나를 추가하고 기존 목록 query·고정 우선 정렬은 유지한다. +- [x] **GREEN:** facade가 count와 현재 page items로 `AiCharacterAdminCommunityPostListResponse`를 구성하고 + `hasNext`를 `pageable.offset + items.size < totalCount`로 계산한다. +- [x] **CONTRACT TEST:** item의 기존 18개 필드, owner/inactive 격리, `page < 0`·`size < 1` 400과 + 문서에 없는 size 상한 부재를 유지하고 OpenAPI status를 `implemented`로 갱신한다. +- [x] **REFACTOR:** Spring `Page`나 공용 pagination abstraction을 추가하지 않고 community package와 공통 + authorization/error 회귀, `ktlintCheck`, `git diff --check`를 실행해 기록한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostQueryTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostContractTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +jq -e ' + .paths["/api/v2/admin/ai-characters/{characterId}/community-posts"].get as $operation + | ([$operation.parameters[] | .["$ref"]] | index("#/components/parameters/Timezone") | not) + and ($operation["x-implementation-status"] == "implemented") + and (.components.schemas.CommunityPostListResponse.required + == ["totalCount", "page", "size", "hasNext", "items"]) +' docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json +git diff --check +``` + +#### Phase 5 목록 계약 변경 Gate + +**Goal 실행 `P5-R4-GATE`:** `DEC-P5-LIST-001`의 query 제거와 pagination metadata·owner count 계약을 재검토한다. + +- [x] **`P5-R4-GATE` 완료:** `P5-R4` 완료 후 focused/영향 범위 회귀와 OpenAPI·lint·diff를 fresh 실행하고 + Phase 5 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P5-R4` 완료. +- **완료 증거:** timezone 없는 목록 성공, exact pagination wrapper, active owner total/hasNext와 기존 item·정렬·오류 + 회귀 성공, OpenAPI `implemented` 복구. +- **범위 밖:** Gate에서 production code, item schema 또는 public/legacy API 변경. + +#### Phase 5 후속 기능 보완 + +- [x] **Task 5.11: 커뮤니티 댓글 CRUD** + +**Goal 실행 `P5-R5`:** target AI 캐릭터 소유 활성 커뮤니티 게시글의 원댓글·답글을 조회하고 target AI 명의로 +작성·수정하며, 해당 게시글에 달린 댓글·답글은 작성자와 관계없이 row 단위로 soft delete한다. + +- **추적 review ID:** `REV-048`. +- **시작 조건:** `P4-R6-GATE` 완료와 PRD·OpenAPI의 승인된 댓글 행위자·소유권 계약 존재. +- **완료 증거:** 5개 actual endpoint, root/reply 조회, target AI 작성, 작성자 제한 수정, owner 범위 삭제, + cross-resource/parent/character 격리, idempotent delete와 exact response 회귀. +- **범위 밖:** 캐릭터 직접 댓글 삭제, 댓글 hard delete·cascade, 게시글 CRUD 의미 변경, 레거시/public endpoint 변경. + +**Interfaces:** + +- `GET|POST /api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments` +- `PUT|DELETE /api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}` +- `GET /api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}/replies` +- 조회 query: 필수 `timezone`, `page`, `size`; response `GetCommunityPostCommentListResponse(totalCount, items)`. +- 작성 body: 필수 `comment`, optional/nullable `parentId`, optional `isSecret=false`. +- 수정 body: 필수 `comment`; mutation 성공 `data: null`. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostController.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostDto.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostRepository.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCommentTest.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityCommentRepository.kt` +- Modify: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** root/reply 목록의 `timezone/page/size`, `totalCount/items`, target 소유 활성 게시글 경계를 actual + GET으로 고정한다. +- [x] **RED:** root와 reply 작성 시 저장된 `member`가 target `creatorMember`이고, `parentId`가 같은 게시글의 활성 + root가 아니면 400/no insert/no event인지 고정한다. +- [x] **RED:** target AI가 작성한 활성 댓글/답글만 수정되고 팬 작성, 다른 게시글·캐릭터 댓글 수정은 + 400/no mutation인지 고정한다. +- [x] **RED:** target 소유 게시글의 팬/AI 댓글·답글 삭제는 해당 row만 비활성화하고 하위 답글은 유지하며, 이미 + 비활성인 row는 200 no-op인지 고정한다. +- [x] **GREEN:** 기존 `CreatorCommunityService`의 댓글 조회·작성·수정 의미를 재사용하되 facade에서 target, + active owner, 동일 리소스 root parent, actor 권한을 먼저 검증한다. +- [x] **CONTRACT TEST:** 미지 필드, 잘못된 page/size/timezone, cross-resource ID의 400 envelope와 모든 + mutation의 `data: null`을 확인한다. +- [x] **REFACTOR:** 댓글 전용 공용 abstraction이나 cascade 로직을 추가하지 않고 community/common 영향 범위 회귀, + `ktlintCheck`, OpenAPI 상태, `git diff --check`를 기록한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostCommentTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 5 후속 기능 Gate + +**Goal 실행 `P5-R5-GATE`:** `REV-048`의 댓글 actor·owner·parent·soft delete 경계를 재검토한다. + +- [x] **`P5-R5-GATE` 완료:** `P5-R5` 완료 후 focused/영향 범위 회귀와 OpenAPI·lint·diff를 fresh 실행하고 + Phase 5 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P5-R5` 완료. +- **완료 증거:** 5개 operation, 레거시 목록 parity, AI 작성·수정 제한, owner 범위 row soft delete, + cross-resource no-side-effect 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 public/legacy API 변경. + +#### Phase 5 UTC 날짜 계약 보완 + +- [x] **Task 5.12: 커뮤니티 댓글·답글 timezone 제거와 UTC date 정합화** + +**Goal 실행 `P5-R6`:** 신규 관리자 커뮤니티 댓글·답글 조회에서 `timezone` query를 제거하고 기존 `date` +필드 값을 ISO-8601 UTC(`Z`)로 반환한다. + +- **추적 review ID:** `REV-051`. +- **시작 조건:** `P3-R14-GATE` 완료와 `DEC-UTC-DATE-001` 및 OpenAPI 2.2.0 계약 존재. +- **완료 증거:** 커뮤니티 댓글·답글 2개 actual GET의 query·UTC exact JSON RED/GREEN, 기존 + `totalCount/items`·page/size·ownership·block/secret 의미 보존, legacy/public 회귀와 Progress 기록. +- **범위 밖:** 커뮤니티 게시글 목록 item 날짜 변경, 댓글 mutation 의미 변경, legacy/public request/response 변경, + 신규 pagination wrapper·dependency·DDL. + +**Interfaces:** + +- `GET /api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments` +- `GET /api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}/replies` +- 두 GET의 query는 `page`, `size`만 사용한다. response는 기존 `totalCount`, `items`와 item의 `date` 필드명을 + 유지하며 `date` 값만 ISO-8601 UTC(`Z`)로 고정한다. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostController.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostRepository.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCommentTest.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt` +- Modify: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** `timezone` 없는 root/reply GET이 현재 400이고, timezone별 로컬 문자열을 반환하는 현재 `date` + 계약이 UTC exact JSON과 다른 것을 actual endpoint로 고정한다. +- [x] **GREEN:** controller/facade signature와 timezone 검증을 제거하고 v2 owner-scoped 댓글 query/mapping에서 + 기존 `toUtcIso()`를 재사용해 `createdAt`을 `date`에 UTC로 직렬화한다. +- [x] **CONTRACT TEST:** root/reply `date`, `totalCount/items`, page/size, target/owner/cross-post 경계와 기존 + block/secret 필터가 유지되고, 추가 `timezone` query가 결과에 영향을 주지 않는지 확인한다. +- [x] **REFACTOR:** legacy/public 댓글 repository·service의 timezone 동작은 변경하지 않고 v2 경계의 최소 + query/mapping만 둔다. community/common 및 직접 영향 legacy 회귀, `ktlintCheck`, OpenAPI 상태, + `git diff --check`를 기록한다. + +- **`P5-R6` / `P5-R6-GATE` 검증(2026-07-29):** RED는 production 변경 전 focused 댓글 테스트에서 timezone 없는 + root/reply GET이 기존 필수 query 때문에 400을 반환해 2건 실패했고 `BUILD FAILED in 38s`였다. controller/facade의 + timezone 입력·검증을 제거하고 legacy 조회 결과의 `date`만 `createdAt.toUtcIso()`로 재매핑한 뒤 같은 focused 명령은 + `BUILD SUCCESSFUL in 43s`였다. community/common·legacy 영향 범위 회귀는 `BUILD SUCCESSFUL in 1m 11s`, + `ktlintCheck`는 `BUILD SUCCESSFUL in 26s`였다. OpenAPI 36개 operation은 모두 `implemented`, + `alignment-required`는 0개임을 `jq`로 확인했고, `git diff --check`는 출력 없이 종료했다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostCommentTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest \ + --tests kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest +./gradlew ktlintCheck +jq -e ' + [.paths[] | to_entries[] | select(.key | IN("get", "post", "put", "delete")) | .value] as $operations + | ($operations | length) == 36 + and ([$operations[] | select(.["x-implementation-status"] == "implemented")] | length) == 36 + and ([$operations[] | select(.["x-implementation-status"] == "alignment-required")] | length) == 0 +' docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json +git diff --check +``` + +#### Phase 5 UTC 날짜 계약 Gate + +**Goal 실행 `P5-R6-GATE`:** `REV-051`의 커뮤니티 댓글·답글 UTC 계약과 legacy/public 격리를 재검토한다. + +- [x] **`P5-R6-GATE` 완료:** `P5-R6` 완료 후 focused/영향 범위 회귀와 OpenAPI·lint·diff를 fresh 실행하고 + Phase 5 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P5-R6` 완료. +- **완료 증거:** 커뮤니티 2개 GET의 timezone 제거·UTC `date`, 기존 pagination·ownership·block/secret과 + legacy/public 계약 회귀 성공, OpenAPI 해당 2개 operation `implemented`. +- **범위 밖:** Gate에서 production code 또는 public/legacy API schema 변경. + +#### Phase 5 JSON media type 계약 후속 보완 + +- [x] **Task 5.13: 커뮤니티 댓글 작성·수정의 application/json 강제** + +**Goal 실행 `P5-R7`:** 커뮤니티 댓글 작성·수정 endpoint가 OpenAPI의 유일한 request media type인 +`application/json`만 받고, 그 밖의 media type은 공통 415 계약으로 거부하도록 정합화한다. + +- **추적 review ID:** `REV-053`. +- **시작 조건:** `P4-R7-GATE` 완료와 OpenAPI의 두 JSON requestBody 및 415 response 계약 존재. +- **완료 증거:** POST·PUT actual endpoint가 정상 JSON은 기존처럼 처리하고 `text/plain` 등 미지원 media type은 + localized 415 `ApiResponse.error`, 표준 `Accept` header, DB/event no-side-effect를 반환한다. +- **범위 밖:** JSON schema·댓글 actor/owner/parent 의미, legacy/public endpoint, 공통 exception handler, + 신규 dependency·DDL 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostController.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCommentTest.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** POST·PUT에 유효 JSON 문자열을 `text/plain`으로 보내면 현재 415가 아닌 handler 진입 결과가 나오는지 + actual endpoint와 no-side-effect로 고정한다. +- [x] **GREEN:** 두 mapping에 `consumes = [MediaType.APPLICATION_JSON_VALUE]`만 추가한다. +- [x] **CONTRACT TEST:** KO/EN/JA 415 envelope, `Accept` header, 작성 insert/event 0회와 수정 row 불변, + 정상 JSON 회귀를 확인한다. +- [x] **REFACTOR:** facade/parser와 댓글 도메인 동작을 변경하지 않고 community/common 영향 범위 회귀, + `ktlintCheck`, OpenAPI 정적 대조, `git diff --check`를 기록한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostCommentTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 5 JSON media type 계약 후속 Gate + +**Goal 실행 `P5-R7-GATE`:** `REV-053` 수정 뒤 두 mutation의 JSON-only·415·no-side-effect 경계를 재검토한다. + +- [x] **`P5-R7-GATE` 완료:** `P5-R7` 완료 후 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 + Phase 5 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P5-R7` 완료. +- **완료 증거:** POST·PUT의 정상 JSON과 미지원 media type 415/header/envelope/no-side-effect 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 OpenAPI schema 변경. + +#### Phase 5 multipart request part 계약 후속 보완 + +- [x] **Task 5.14: 커뮤니티 게시글 생성·수정 request part의 application/json 강제** + +**Goal 실행 `P5-R8`:** 커뮤니티 게시글 생성·수정 multipart의 `request` part가 OpenAPI encoding대로 +`application/json`일 때만 handler에 진입하고, 그 밖의 part media type은 공통 415 계약으로 거부되도록 정합화한다. + +- **추적 review ID:** `REV-058`. +- **시작 조건:** `P5-R7-GATE` 완료와 OpenAPI의 두 multipart request encoding 계약 존재. +- **완료 증거:** POST·PUT actual endpoint가 JSON part는 기존 strict parse·media/fixed/owner 의미를 유지하고, + `text/plain`·content type 누락 등은 localized 415 `ApiResponse.error`, 표준 `Accept` header, + S3·DB·event no-side-effect를 반환한다. +- **범위 밖:** JSON schema·strict reader·media/fixed/concurrency 의미, 댓글 endpoint, legacy/public endpoint, + OpenAPI·신규 dependency·DDL 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostController.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostUpdateTest.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** POST·PUT의 유효 JSON 문자열을 `text/plain` request part로 보내 현재 handler에 진입하는지 actual + endpoint와 no-side-effect로 고정한다. +- [x] **GREEN:** v2 controller 경계에서 request part의 `application/json` 호환 여부만 확인하고 기존 String + strict reader에 동일 payload를 전달한다. +- [x] **CONTRACT TEST:** KO/EN/JA 415 envelope, `Accept` header, content type 누락과 정상 JSON, + 필수 part 누락 400 및 기존 media/fixed/owner 회귀를 확인한다. +- [x] **REFACTOR:** community facade/domain 로직을 변경하지 않고 community/common 영향 범위 회귀, + `ktlintCheck`, OpenAPI 정적 대조, `git diff --check`를 기록한다. + +```bash +./gradlew test \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostCreateTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostUpdateTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 5 multipart request part 계약 후속 Gate + +**Goal 실행 `P5-R8-GATE`:** `REV-058` 수정 뒤 Community post POST·PUT의 part-level JSON-only·415 경계를 재검토한다. + +- [x] **`P5-R8-GATE` 완료:** `P5-R8` 완료 후 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 + Phase 5 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P5-R8` 완료. +- **완료 증거:** 정상 JSON·미지원/누락 media type·필수 part·no-side-effect 및 media/fixed/owner 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 OpenAPI schema 변경. + +#### Phase 5 multipart part 이름 계약 후속 보완 + +- [x] **Task 5.15: 커뮤니티 생성·수정의 미정의 multipart part 거부** + +**Goal 실행 `P5-R9`:** Community post 생성은 `audioFile`, `postImage`, `request`, 수정은 +`postImage`, `request` 외 multipart part를 business mutation 전에 400 `common.error.invalid_request`로 거부한다. + +- **추적 review ID:** `REV-063`. +- **시작 조건:** `P4-R8-GATE` 완료와 `phase5-community-review.md` 7차 정적 리뷰 판정 존재. +- **완료 증거:** POST·PUT 미정의 part의 KO/EN/JA 400 envelope와 S3·DB·event no-side-effect, + 수정의 `audioFile` 거부 및 정상 media/fixed/owner·필수 part·415 회귀. +- **범위 밖:** OpenAPI schema, media/fixed/concurrency 의미, 전역 multipart resolver, 댓글·legacy/public endpoint, + 신규 dependency·DDL. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostController.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostUpdateTest.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** 생성·수정에 `unexpected` part를 추가하고, 수정에는 OpenAPI에 없는 `audioFile`을 추가해 현재 + 정상 mutation으로 진행되는 경로와 side effect를 actual endpoint로 고정한다. +- [x] **GREEN:** 실제 part 이름 집합을 생성 `{audioFile, postImage, request}`, 수정 + `{postImage, request}`와 비교해 초과 이름을 `AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, + "common.error.invalid_request")`로 거부한다. +- [x] **CONTRACT TEST:** KO/EN/JA 400 envelope, 정상 생성·수정, request part 누락·415, + media/fixed/owner 경계와 no-side-effect를 확인한다. +- [x] **REFACTOR:** Community controller/test만 최소 변경하고 community/common 영향 범위 회귀, + `ktlintCheck`, OpenAPI 정적 대조와 `git diff --check`를 기록한다. + +#### Phase 5 multipart part 이름 계약 후속 Gate + +**Goal 실행 `P5-R9-GATE`:** `REV-063` 수정 뒤 Community POST·PUT의 operation별 허용 part 이름과 기존 +media type 경계를 재검토한다. + +- [x] **`P5-R9-GATE` 완료:** `P5-R9` 완료 후 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 Phase 5 + 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P5-R9` 완료. +- **완료 증거:** 미정의 part와 수정 `audioFile` 400/no-side-effect, 정상 media/fixed/owner·필수 part·415 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 OpenAPI schema 변경. + +#### Phase 5 multipart 일반 form-field part 후속 보완 + +- [x] **Task 5.16: 커뮤니티 생성·수정의 전체 multipart part 이름 검증** + +**Goal 실행 `P5-R10`:** Community post POST·PUT에서 파일 part뿐 아니라 filename 없는 일반 form-field part를 +포함한 모든 multipart part 이름을 검사해 생성 `{audioFile, postImage, request}`, 수정 `{postImage, request}` 외 +이름을 mutation 전에 400 `common.error.invalid_request`로 거부한다. + +- **추적 review ID:** `REV-069`. +- **시작 조건:** `P4-R10-GATE` 완료와 `phase5-community-review.md` 8차 정적 리뷰 판정 존재. +- **완료 증거:** filename 없는 `unexpected` part의 KO/EN/JA 400 envelope와 S3·DB·event no-side-effect, + 기존 파일형 미정의 part·수정 `audioFile` 거부·정상 media/fixed/owner·필수 part·request part 415 회귀 성공. +- **범위 밖:** OpenAPI schema, media/fixed/concurrency 의미, 전역 multipart resolver, legacy/public endpoint, + 신규 dependency·DDL. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostController.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostUpdateTest.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** filename 없는 `unexpected` 일반 form-field part를 정상 request와 함께 POST·PUT으로 보내 현재 + `fileMap.keys` 검사를 우회하는 경로와 mutation 부작용을 actual endpoint로 고정한다. +- [x] **GREEN:** servlet request의 전체 part 이름 집합을 operation별 허용 집합과 비교해 초과 이름을 facade 진입 + 전에 공통 400으로 거부한다. +- [x] **CONTRACT TEST:** KO/EN/JA 오류 envelope, 기존 파일형 미정의 part, 수정 `audioFile` 거부, + 정상 media/fixed/owner·필수 part·request part 415와 no-side-effect를 확인한다. +- [x] **REFACTOR:** Community controller/test만 최소 변경하고 community/common 영향 범위 회귀, + `ktlintCheck`, OpenAPI 정적 대조와 `git diff --check`를 기록한다. + +#### Phase 5 multipart 전체 part 이름 후속 Gate + +**Goal 실행 `P5-R10-GATE`:** `REV-069` 수정 뒤 Community POST·PUT의 파일·일반 form-field를 포함한 전체 part +이름과 operation별 media type 경계를 재검토한다. + +- [x] **`P5-R10-GATE` 완료:** `P5-R10` 완료 후 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 Phase 5 + 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P5-R10` 완료. +- **완료 증거:** filename 없는 미정의 part 400/no-side-effect와 기존 multipart 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 OpenAPI schema 변경. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostCreateTest --tests '*shouldRejectFilenameLessUndefinedMultipartPartBeforeSideEffects*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostUpdateTest --tests '*shouldRejectFilenameLessUndefinedMultipartPartBeforeSideEffects*' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +- 검증 기록(RED): 무엇: Community POST·PUT filename 없는 `unexpected` multipart part. 왜: `fileMap.keys` 검사가 일반 form-field part를 보지 못하는지 고정하기 위해. 어떻게: Phase 2~5 multipart와 Phase 4 genre focused RED 묶음을 실행했다. 결과: 신규 multipart/genre 36건이 실패했다. +- 검증 기록(GREEN): 무엇: Community multipart 전체 part 이름 검사. 왜: 파일형 part와 일반 form-field part 모두 생성 `{audioFile, postImage, request}`, 수정 `{postImage, request}` allow-list를 따라야 하기 때문이다. 어떻게: controller가 `fileMap.keys`와 servlet `parts` 이름을 모두 검사하도록 바꾸고 같은 focused 묶음을 재실행했다. 결과: `BUILD SUCCESSFUL in 1m 17s`였다. +- 검증 기록(GATE): community/common 영향 범위 회귀, lint, diff 결과는 `P7-R10-GATE`에 통합 기록한다. + --- -### Phase 6: FanTalk 목록·답변 vertical slice +### Phase 6: FanTalk 목록·답변·팬 원글 삭제 vertical slice #### 목표 -선택한 AI 캐릭터의 FanTalk 목록을 관리자 전용 경계에서 조회하고 자신의 활성 root FanTalk에만 creator reply를 작성하는 -v2 관리자 API를 제공한다. +선택한 AI 캐릭터의 FanTalk 목록을 관리자 전용 경계에서 조회하고 자신의 활성 root FanTalk에 creator reply를 작성하며 +팬 작성 root를 soft delete하는 v2 관리자 API를 제공한다. #### 범위와 비범위 -- 포함: 관리자용 root FanTalk/creator reply 목록, root FanTalk 존재/활성/owner 검증, creator reply 저장, 언어 감지와 - 기존 응답 의미 parity. -- 제외: FanTalk 원글 작성, nested reply, 구매/댓글형 기능, AI 캐릭터 일반 사용자 활동. +- 포함: 관리자용 root FanTalk/creator reply 목록, root FanTalk 존재/활성/owner 검증, creator reply 저장, 팬 작성 + root row soft delete, 언어 감지와 기존 응답 의미 parity. +- 제외: FanTalk 원글 작성, nested reply, hard delete·cascade, 구매/댓글형 기능, AI 캐릭터 일반 사용자 활동. #### 선행 Phase 및 의존성 - Phase 1 target resolver. - 기존 FanTalk 저장 엔티티와 응답 DTO 의미 특성화. #### API endpoint와 request/response contract -- 정식 전체 schema는 `api-contract.openapi.json`의 FanTalk operation 2개를 따른다. +- 정식 전체 schema는 `api-contract.openapi.json`의 FanTalk operation 3개를 따른다. - `GET /api/v2/admin/ai-characters/{characterId}/fan-talks?page=0&size=20` - Response: 공개 v2 `CreatorChannelFanTalkTabResponse`와 동일한 `fanTalkCount`, `fanTalks`, `page`, `size`, `hasNext` 및 nested root/reply 필드 - `POST /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies` - Request: `CreateAiCharacterAdminFanTalkReplyRequest(content: String)` - Response: `AiCharacterAdminFanTalkReplyResponse(fanTalkId, replyId, creatorMemberId, content, createdAtUtc)` +- `DELETE /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}`는 팬 작성 root만 soft delete하고 + `data: null`을 반환한다. #### entity, repository, service 변경 - Entity: 변경 없음. @@ -2579,7 +5026,7 @@ v2 관리자 API를 제공한다. #### 권장 commit 경계 - `feat: add ai character admin fan talk slice` -- [ ] **Task 6.1: 기존 FanTalk reply 의미 특성화 baseline 고정** +- [x] **Task 6.1: 기존 FanTalk reply 의미 특성화 baseline 고정** **Goal 실행 `P6-T1`:** 기존 FanTalk의 root 판별, 언어 감지, 응답 의미와 writer/creator 저장 결과를 비교 기준으로 고정한다. @@ -2591,13 +5038,35 @@ v2 관리자 API를 제공한다. - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/LegacyFanTalkReplyCharacterizationTest.kt` -- [ ] valid root reply의 언어 감지, response 의미와 writer/creator 저장 baseline을 작성한다. -- [ ] root/nested/active 판별과 기존 중복 답변 정책을 관찰해 기록한다. -- [ ] Phase 6 domain/client 오류별 정확한 status와 KO/EN/JA message key를 확정해 구현 Goal에 반영한다. -- [ ] production code 변경 없이 기존 구현 대상 특성화 테스트 통과를 확인한다. -- [ ] fixture만 정리하고 test와 `ktlintCheck` 결과를 Progress에 기록한다. +- [x] valid root reply의 언어 감지, response 의미와 writer/creator 저장 baseline을 작성한다. +- [x] root/nested/active 판별과 기존 중복 답변 정책을 관찰해 기록한다. +- [x] Phase 6 domain/client 오류별 정확한 status와 KO/EN/JA message key를 확정해 구현 Goal에 반영한다. +- [x] production code 변경 없이 기존 구현 대상 특성화 테스트 통과를 확인한다. +- [x] fixture만 정리하고 test와 `ktlintCheck` 결과를 Progress에 기록한다. -- [ ] **Task 6.2: FanTalk 관리자 목록 조회 구현** +- **관찰된 legacy baseline:** `ExplorerService.writeCheers`는 조회된 parent가 root인지 또는 active인지 검증하지 않아 + inactive nested parent에도 답변을 연결한다. 조회 결과가 없는 parent ID는 parent 없이 root 글로 저장하고, 같은 root에 대한 + creator 답변 중복도 허용한다. `languageCode`가 blank일 때만 `CREATOR_CHEERS` 언어 감지 이벤트를 발행한다. creator 조회 + 실패는 `SodaException(messageKey = "member.validation.user_not_found")`, 차단은 + `explorer.creator.blocked_cheers`로 조립된 `SodaException.message`를 반환한다. 이 service 경계는 HTTP status를 결정하지 않는다. +- **Phase 6 v2 domain/client 오류 결정:** target 또는 FanTalk missing/inactive/cross-character/nested parent, 빈 content와 + request binding 실패는 저장·이벤트 전에 400 `common.error.invalid_request`로 거부한다. KO `잘못된 요청입니다.`, EN + `Invalid request.`, JA `無効なリクエストです。`를 반환한다. 예상하지 못한 server/infrastructure 오류는 500 + `common.error.unknown`과 공통 KO/EN/JA message를 사용한다. legacy의 root 전환·nested/inactive 허용과 HTTP status + 미결정은 신규 v2에 복제하지 않는다. +- **TDD 예외/특성화 (2026-07-28):** `LegacyFanTalkReplyCharacterizationTest`를 production 변경 없이 추가한 뒤 + `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.LegacyFanTalkReplyCharacterizationTest`를 + 실행했다. valid root reply의 parent/member/creator/response/event, missing parent root 전환과 nonblank 언어 이벤트 미발행, + inactive nested parent 허용, 중복 답변 허용, missing creator key 및 blocked creator message 6건이 첫 실행부터 통과했다. + 기존 구현의 의도된 동작을 고정하는 특성화이므로 production 코드나 dependency를 추가하지 않았다. +- **검증 기록:** 무엇: legacy FanTalk reply 의미 특성화와 Kotlin lint. 왜: Phase 6 v2 저장 전에 재사용할 저장·응답·이벤트 + 동작과 새 경계에서 차단할 legacy 허용 범위를 분리하기 위해. 어떻게: 위 focused test 명령과 `./gradlew ktlintCheck`를 실행했다. + 결과: focused test는 `BUILD SUCCESSFUL in 9s`(10 actionable tasks 중 3 executed, 7 up-to-date)였다. 첫 lint 실행은 새 test의 + import 정렬 1건으로 `BUILD FAILED in 22s`였고, import만 정렬한 뒤 재실행한 `ktlintCheck`는 + `BUILD SUCCESSFUL in 26s`(7 actionable tasks 중 2 executed, 5 up-to-date)였다. production 변경이 없고 focused test가 + 직접 legacy service 경계를 실행하므로 전체 `./gradlew test`는 실행하지 않았다. + +- [x] **Task 6.2: FanTalk 관리자 목록 조회 구현** **Goal 실행 `P6-T2`:** 선택한 AI 캐릭터의 root FanTalk와 creator reply를 공개 v2 응답 필드 형태로 조회한다. @@ -2613,12 +5082,12 @@ v2 관리자 API를 제공한다. - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkQueryTest.kt` -- [ ] **RED:** `fanTalkCount/fanTalks/page/size/hasNext`와 root/reply 전체 필드, target owner, pagination 실패 +- [x] **RED:** `fanTalkCount/fanTalks/page/size/hasNext`와 root/reply 전체 필드, target owner, pagination 실패 test를 작성한다. -- [ ] **GREEN:** 공개 v2 DTO 필드 형태를 유지하면서 관리자 target 정책으로 조회하는 최소 구현을 통과시킨다. -- [ ] **REFACTOR:** 공개 v2 endpoint 계약 불변, viewer/block 필터 비적용과 ADMIN 인가를 회귀하고 기록한다. +- [x] **GREEN:** 공개 v2 DTO 필드 형태를 유지하면서 관리자 target 정책으로 조회하는 최소 구현을 통과시킨다. +- [x] **REFACTOR:** 공개 v2 endpoint 계약 불변, viewer/block 필터 비적용과 ADMIN 인가를 회귀하고 기록한다. -- [ ] **Task 6.3: FanTalk root reply 저장 구현** +- [x] **Task 6.3: FanTalk root reply 저장 구현** **Goal 실행 `P6-T3`:** 선택한 AI 캐릭터의 활성 root FanTalk에 creator reply를 저장하고 전용 응답을 반환한다. @@ -2634,12 +5103,12 @@ v2 관리자 API를 제공한다. - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyCreateTest.kt` -- [ ] 미구현 정상 root reply, 언어 감지와 응답 DTO 실패 test를 작성한다. -- [ ] root 조회와 reply 저장을 같은 transaction에서 수행하는 최소 구현을 통과시킨다. -- [ ] writer/creator가 resolver의 creatorMember와 일치하고 principal impersonation이 없음을 검증한다. -- [ ] focused/legacy characterization test와 `ktlintCheck` 결과를 Progress에 기록한다. +- [x] 미구현 정상 root reply, 언어 감지와 응답 DTO 실패 test를 작성한다. +- [x] root 조회와 reply 저장을 같은 transaction에서 수행하는 최소 구현을 통과시킨다. +- [x] writer/creator가 resolver의 creatorMember와 일치하고 principal impersonation이 없음을 검증한다. +- [x] focused/legacy characterization test와 `ktlintCheck` 결과를 Progress에 기록한다. -- [ ] **Task 6.4: FanTalk target·root·ownership 거부 구현** +- [x] **Task 6.4: FanTalk target·root·ownership 거부 구현** **Goal 실행 `P6-T4`:** cross-character, nested, inactive, missing FanTalk를 저장·이벤트 없이 거부한다. @@ -2649,16 +5118,22 @@ v2 관리자 API를 제공한다. **Files:** -- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyFacade.kt` -- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyRepository.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkFacade.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyOwnershipTest.kt` -- [ ] cross-character/nested/inactive/missing 각각의 실패 test와 의도한 실패를 확인한다. -- [ ] target·root·active·owner를 저장 전에 검증하는 최소 구현을 통과시킨다. -- [ ] 각 실패의 정확한 status/message key/KO·EN·JA와 reply insert/event 0건을 검증한다. -- [ ] focused test와 `ktlintCheck` 결과를 Progress에 기록한다. +- [x] cross-character/nested/inactive/missing FanTalk과 inactive target 거부 테스트를 작성하고 현재 guard 동작을 특성화했다. +- [x] 기존 `resolveActiveTarget`과 owner-scoped `findActiveRoot`가 저장 전에 target·root·active·owner를 검증함을 확인했다. +- [x] 각 실패의 정확한 400 `common.error.invalid_request` KO·EN·JA envelope와 reply insert/event 0건을 검증했다. +- [x] focused, reply create, legacy characterization, FanTalk package test와 `ktlintCheck` 결과를 Progress에 기록했다. -- [ ] **Task 6.5: Phase 6 보안·오류·회귀 검증** +- **TDD 예외/특성화 (2026-07-28):** production 변경 전 `AiCharacterAdminFanTalkReplyOwnershipTest`를 추가해 처음 실행했으나, + cross-character root, nested parent, inactive root, missing FanTalk, inactive target의 KO/EN/JA 400 envelope와 reply row/event + 무변경이 모두 통과했다. 이는 P6-T3의 `resolveActiveTarget`과 `findActiveRoot(creatorMemberId, fanTalkId)`가 이미 target active, + owner, active, root 조건을 저장·이벤트 전에 보장하기 때문이다. 요구 동작이 충족된 특성화이므로 production code, dependency, + legacy/public endpoint, 중복 답변 정책을 변경하지 않았다. + +- [x] **Task 6.5: Phase 6 보안·오류·회귀 검증** **Goal 실행 `P6-T5`:** FanTalk 목록·reply endpoint의 ADMIN·오류 계약과 기존 FanTalk 회귀를 고정한다. @@ -2671,22 +5146,320 @@ v2 관리자 API를 제공한다. - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyContractTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAuthorizationTest.kt` -- [ ] endpoint의 ADMIN 이중 인가와 stale claim을 검증한다. -- [ ] 빈 content/binding/domain 오류의 정확한 status, message key, KO/EN/JA envelope를 확정·검증한다. -- [ ] 기존 FanTalk 조회·작성 계약과 AI 로그인/token/impersonation 부재를 확인한다. -- [ ] Phase 6 focused test와 `ktlintCheck` 결과를 Progress에 기록한다. +- [x] endpoint의 ADMIN 이중 인가와 stale claim을 검증한다. +- [x] 빈 content/binding/domain 오류의 정확한 status, message key, KO/EN/JA envelope를 확정·검증한다. +- [x] 기존 FanTalk 조회·작성 계약과 AI 로그인/token/impersonation 부재를 확인한다. +- [x] Phase 6 focused test와 `ktlintCheck` 결과를 Progress에 기록한다. + +- 검증 기록(RED): 무엇: 빈 문자열·공백 reply content의 저장 전 거부와 malformed/missing JSON binding envelope. 왜: 기존 `createReply`가 + 빈 content를 저장해 Phase 6 오류 계약을 위반했기 때문이다. 어떻게: + `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.AiCharacterAdminFanTalkReplyContractTest --rerun-tasks`를 + 실행했다. 결과: 6개 테스트 중 빈 문자열·공백 content의 KO/EN/JA 3개가 400 기대 대비 200으로 실패해 `BUILD FAILED in 5m 42s`였다. +- 검증 기록(GREEN/REFACTOR): 무엇: `AiCharacterAdminFanTalkReplyContractTest`의 빈/공백 content, malformed/missing body와 + content binding KO/EN/JA `ApiResponse.error` envelope, 실제 FanTalk list/reply의 JWT 비ADMIN·stale ADMIN claim, 기존 + query/write/legacy 회귀. 왜: 신규 관리자 endpoint의 이중 인가와 오류·저장 의미를 함께 고정하기 위해. 어떻게: + `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.AiCharacterAdminFanTalkReplyContractTest`, + `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest`, + `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.*'`, `./gradlew ktlintCheck`를 순차 실행했다. + 결과: contract는 `BUILD SUCCESSFUL in 53s`, authorization은 `BUILD SUCCESSFUL in 46s`, FanTalk package는 + `BUILD SUCCESSFUL in 50s`, 최종 `ktlintCheck`는 `BUILD SUCCESSFUL in 31s`였다. 첫 `ktlintCheck`는 새 contract test의 + unused import 1건으로 `BUILD FAILED in 34s`였고 import 제거 뒤 재실행했다. 기존 + `AiCharacterAdminFanTalkReplyCreateTest`는 관리자 principal이 아닌 target `creatorMember`를 reply의 writer·creator로 + 저장하고 admin과 다름을 단언하므로 AI 로그인/token/impersonation 부재를 중복 없이 확인했다. #### Phase 6 Gate **Goal 실행 `P6-GATE`:** Phase 6 FanTalk 목록·reply의 조회·root·ownership·저장·회귀 품질을 최종 판정한다. -- [ ] **`P6-GATE` 완료:** 시작 조건과 완료 증거를 모두 충족하고 현재 상태표와 Progress를 갱신한다. +- [x] **`P6-GATE` 완료:** 시작 조건과 완료 증거를 모두 충족하고 현재 상태표와 Progress를 갱신한다. - **시작 조건:** `P6-T1`~`P6-T5` 완료. - **완료 증거:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.*'`와 `./gradlew ktlintCheck` 성공, Progress 기록. - **범위 밖:** 실패와 무관한 신규 기능. +#### Phase 6 후속 리뷰 보완 + +- [x] **Task 6.6: FanTalk query policy와 reply JSON 경계를 OpenAPI에 정합화** + +**Goal 실행 `P6-R1`:** `REV-027`의 목록 pagination을 공개 v2와 같은 보정 정책으로 복구하고, +`REV-028`의 reply request에서 `additionalProperties: false`를 실제로 강제한다. + +- **추적 review ID:** `REV-027`, `REV-028`. +- **시작 조건:** `P5-R1-GATE` 완료와 `phase6-fantalk-review.md` 판정 존재. +- **완료 증거:** `page < 0 -> 0`, `size < 20 -> 20`, `size > 50 -> 50` actual endpoint 테스트, + reply 미지 필드 400/no insert/no event 테스트, FanTalk/public policy 정적 대조와 Progress 기록. +- **범위 밖:** 공개 v2 FanTalk query policy 변경, reply 저장/언어 감지/ownership 정책 변경, OpenAPI schema 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkController.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkFacade.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkQueryTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyContractTest.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicy.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** 음수 page, 20 미만 size, 50 초과 size가 400이 아니라 각각 0/20/50으로 보정되는 actual 목록 테스트를 + 작성한다. +- [x] **RED:** reply JSON에 계약 밖 필드가 있으면 400 `common.error.invalid_request`이고 reply insert/event가 + 0회인지 확인한다. +- [x] **GREEN:** 관리자 목록에 공개 v2와 동일한 pagination 정규화를 적용하고 reply body만 strict reader로 역직렬화한다. +- [x] **REFACTOR:** 중복 정책은 기존 query policy의 가시성과 의존 방향을 확인한 뒤 최소한으로 재사용하고, + FanTalk/common 영향 범위 회귀, `ktlintCheck`, diff check를 실행한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.AiCharacterAdminFanTalkQueryTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.AiCharacterAdminFanTalkReplyContractTest +./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 +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 6 후속 리뷰 Gate + +**Goal 실행 `P6-R1-GATE`:** `REV-027`~`REV-028` 수정 뒤 FanTalk 2개 operation의 query/body 경계를 재검토한다. + +- [x] **`P6-R1-GATE` 완료:** `P6-R1` 완료 후 actual endpoint no-side-effect와 FanTalk/common 회귀, + lint·diff를 fresh 실행하고 리뷰 문서와 Progress를 갱신한다. +- **시작 조건:** `P6-R1` 완료. +- **완료 증거:** 공개 v2 pagination 보정과 관리자 목록 일치, reply 미지 필드 거부, 영향 범위 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 OpenAPI schema 변경. + +#### Phase 6 후속 기능 보완 + +- [x] **Task 6.7: 팬 작성 FanTalk 원글 soft delete** + +**Goal 실행 `P6-R2`:** target AI 캐릭터 채널에 팬이 작성한 FanTalk root를 row 단위로 soft delete하고 연결된 creator +reply row는 변경하지 않는다. + +- **추적 review ID:** `REV-049`. +- **시작 조건:** `P5-R5-GATE` 완료와 PRD·OpenAPI의 승인된 FanTalk 삭제 계약 존재. +- **완료 증거:** 팬 작성 활성 root 삭제, 목록·count 제외, creator reply row 유지, already inactive no-op, + creator 작성/root 아닌 row/cross-character no mutation과 Progress 기록. +- **범위 밖:** 캐릭터 직접 댓글 삭제, FanTalk hard delete·cascade, 팬 작성 여부 재정의, 공개 v2 endpoint 변경. + +**Interfaces:** + +- `DELETE /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}` +- Request body 없음; response `ApiResponse.ok(null)`. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkController.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkFacade.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkRepository.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkDeleteTest.kt` +- Modify: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** target 채널의 팬 작성 활성 root 삭제가 root `isActive=false`, creator reply row 불변, + 목록·`fanTalkCount` 제외, `data: null`인지 actual DELETE/GET으로 고정한다. +- [x] **RED:** target AI가 작성한 row, reply row, 다른 채널 root는 400/no mutation이며 같은 target의 이미 비활성인 + 팬 root는 200 no-op인지 고정한다. +- [x] **GREEN:** repository가 target creator, root, fan writer를 함께 식별하고 facade는 활성 row만 + `isActive=false`로 변경한다. reply collection과 row는 수정하지 않는다. +- [x] **CONTRACT TEST:** body 없는 DELETE, ADMIN 이중 인가, target inactive/invalid ID와 공통 오류 envelope를 + 확인한다. +- [x] **REFACTOR:** 기존 `CreatorCheers` soft-delete field와 현재 repository만 사용하고 FanTalk/common 영향 범위 + 회귀, `ktlintCheck`, OpenAPI 상태, `git diff --check`를 기록한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.AiCharacterAdminFanTalkDeleteTest +./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 +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 6 후속 기능 Gate + +**Goal 실행 `P6-R2-GATE`:** `REV-049`의 target root·fan writer·row-only soft delete 경계를 재검토한다. + +- [x] **`P6-R2-GATE` 완료:** `P6-R2` 완료 후 focused/영향 범위 회귀와 OpenAPI·lint·diff를 fresh 실행하고 + Phase 6 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P6-R2` 완료. +- **완료 증거:** 팬 root만 삭제, creator reply row 유지, idempotent delete, cross-target no-side-effect와 + OpenAPI operation `implemented`. +- **범위 밖:** Gate에서 production code 또는 public/legacy API 변경. + +#### Phase 6 JSON media type 계약 후속 보완 + +- [x] **Task 6.8: FanTalk 답변 작성의 application/json 강제** + +**Goal 실행 `P6-R3`:** FanTalk 답변 작성 endpoint가 OpenAPI의 유일한 request media type인 +`application/json`만 받고, 그 밖의 media type은 공통 415 계약으로 거부하도록 정합화한다. + +- **추적 review ID:** `REV-054`. +- **시작 조건:** `P5-R8-GATE` 완료와 OpenAPI의 reply JSON requestBody 및 415 response 계약 존재. +- **완료 증거:** actual POST가 정상 JSON은 기존 축약 응답·저장·event 의미를 유지하고 `text/plain` 등 미지원 + media type은 localized 415 `ApiResponse.error`, 표준 `Accept` header, reply insert/event no-side-effect를 반환한다. +- **범위 밖:** reply JSON schema·strict parser·root/ownership·언어 감지, FanTalk 목록/삭제, + legacy/public endpoint, 신규 dependency·DDL 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkController.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyContractTest.kt` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** 유효 reply JSON 문자열을 `text/plain`으로 보내면 현재 415가 아닌 handler 진입 결과가 나오는지 + actual endpoint와 insert/event no-side-effect로 고정한다. +- [x] **GREEN:** reply POST mapping에 `consumes = [MediaType.APPLICATION_JSON_VALUE]`만 추가한다. +- [x] **CONTRACT TEST:** KO/EN/JA 415 envelope, `Accept` header, reply insert/event 0회와 정상 JSON 축약 응답을 + 확인한다. +- [x] **REFACTOR:** facade/parser와 FanTalk 도메인 동작을 변경하지 않고 FanTalk/common 영향 범위 회귀, + `ktlintCheck`, OpenAPI 정적 대조, `git diff --check`를 기록한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.AiCharacterAdminFanTalkReplyContractTest +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 6 JSON media type 계약 후속 Gate + +**Goal 실행 `P6-R3-GATE`:** `REV-054` 수정 뒤 reply POST의 JSON-only·415·no-side-effect 경계를 재검토한다. + +- [x] **`P6-R3-GATE` 완료:** `P6-R3` 완료 후 focused/영향 범위 회귀와 lint·diff를 fresh 실행하고 + Phase 6 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P6-R3` 완료. +- **완료 증거:** 정상 JSON과 미지원 media type 415/header/envelope/no-side-effect 회귀 성공. +- **범위 밖:** Gate에서 production code 또는 OpenAPI schema 변경. + +#### Phase 6 FanTalk 답변 수정 후속 기능 + +- [x] **Task 6.9: 레거시 계약을 유지하는 FanTalk 답변 수정 API** + +**Goal 실행 `P6-R4`:** +`PUT /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}`에서 target AI가 작성하고 +target의 활성 root에 직접 연결된 reply만 레거시 `PUT /explorer/profile/cheers` 의미로 수정한다. + +- **추적 review ID:** `REV-059`. +- **시작 조건:** `P6-R3-GATE` 완료와 PRD·OpenAPI 2.3.0의 확정 답변 수정 계약 존재. +- **완료 증거:** optional/nullable `content`·`isActive`, 동시 입력, 빈 객체 no-op, 비활성 reply 재활성화, + target/root/direct-reply ownership, JSON-only·strict body, ADMIN/common 오류, 레거시 성공 `data`, no-event를 actual + endpoint와 영향 범위 회귀로 확인하고 OpenAPI 상태를 `implemented`로 갱신. +- **범위 밖:** FanTalk 원글 작성, nested reply 작성, 별도 reply DELETE/hard delete, root cascade, 언어 감지· + `languageCode` 변경, legacy/public endpoint, 신규 dependency·DDL 변경. + +**Interfaces:** + +- `PUT /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}` +- Request JSON: `content?: string | null`, `isActive?: boolean | null`; 두 필드 동시 입력 허용, `{}`와 explicit null은 + 성공 no-op, 미지 필드는 400. +- Response: `ApiResponse`. `data.fanTalkId`는 수정한 `replyId`, + `creatorReplies`는 빈 배열. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkController.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkDto.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkFacade.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkRepository.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyUpdateTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyUpdateContractTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAuthorizationTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminErrorContractTest.kt` +- Modify: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **RED:** content-only, isActive-only, 두 필드 동시 수정, `{}`·explicit null no-op과 응답의 reply ID·빈 + `creatorReplies`를 actual PUT으로 고정한다. 빈 문자열·공백 content도 레거시처럼 non-null 값으로 반영되는지 포함한다. +- [x] **RED:** 비활성 reply의 `isActive=true` 재활성화와 content 수정을 허용하되, 비활성 root, 다른 + character/root의 reply, 팬 작성 row, root row, 잘못된 direct-parent 관계는 400/no mutation인지 고정한다. +- [x] **RED:** malformed JSON, 미지 필드, 미지원 media type, JWT/DB role 조합과 KO/EN/JA 오류에서 DB/event + no-side-effect와 415 `Accept` header를 확인한다. +- [x] **GREEN:** strict request reader와 JSON `consumes`를 사용하고, repository가 reply ID·target + creator/writer·path root ID·활성 root·root parent null을 한 query 경계에서 검증한다. reply의 `isActive`는 조회 조건에 + 넣지 않는다. +- [x] **GREEN:** facade는 non-null `content`와 `isActive`만 entity에 반영하고 `languageCode`와 event를 건드리지 않는다. + 응답은 기존 `CreatorChannelFanTalkResponse.from(reply, cloudFrontHost)`를 재사용한다. +- [x] **REFACTOR:** 신규 응답 DTO·dependency·DDL·추상화를 만들지 않고 FanTalk/common·legacy modifyCheers + 영향 범위 회귀, `ktlintCheck`, OpenAPI 37개 상태와 `git diff --check`를 기록한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.AiCharacterAdminFanTalkReplyUpdateTest +./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 \ + --tests kr.co.vividnext.sodalive.explorer.ExplorerServiceTest +./gradlew ktlintCheck +git diff --check +``` + +#### Phase 6 FanTalk 답변 수정 후속 Gate + +**Goal 실행 `P6-R4-GATE`:** `REV-059` 구현 뒤 레거시 field/state/response parity와 관리자 target/root/reply 경계를 +재검토한다. + +- [x] **`P6-R4-GATE` 완료:** `P6-R4` 완료 후 focused/영향 범위 회귀와 OpenAPI·lint·diff를 fresh 실행하고 + Phase 6 리뷰와 Progress를 갱신한다. +- **시작 조건:** `P6-R4` 완료. +- **완료 증거:** 답변 수정 정상·no-op·재활성화·cross-target no-side-effect, ADMIN/common 오류, + 37번째 controller mapping과 `implemented` 상태 일치. +- **범위 밖:** Gate에서 production code 또는 public/legacy API 변경. + +#### Phase 6 FanTalk 비활성 root 삭제 문서 계약 후속 보완 + +- [x] **Task 6.10: FanTalk 비활성 root 삭제의 no-op 문서 정합화** + +**Goal 실행 `P6-R5`:** `DELETE /fan-talks/{fanTalkId}`의 동일 target·팬 작성 root가 이미 비활성인 경우 성공 +no-op이라는 OpenAPI·구현·회귀 테스트의 현재 계약에 맞춰 PRD와 `api-contract.md`의 상충 설명을 동기화한다. + +- **추적 review ID:** `REV-070`. +- **시작 조건:** `phase6-fantalk-review.md` 8차 정적 리뷰 판정과 OpenAPI/구현/test의 동일한 no-op 근거 존재. +- **완료 증거:** PRD Edge Cases와 `api-contract.md`의 FanTalk delete 설명이 동일 target 비활성 팬 root 200 + `data: null` no-op, 미존재·다른 target·creator root·reply 400으로 일치하고 OpenAPI diff는 없음. +- **범위 밖:** runtime/controller/facade/repository/test 변경, OpenAPI schema/path/status 변경, reply 삭제 의미, + legacy/public endpoint. +- **결정 근거:** OpenAPI를 기계 계약 원본으로 두고 현재 구현·회귀와 일치하는 no-op을 유지한다. PRD의 400 문장이 + 최신 제품 의도라면 이 Goal을 시작하지 않고 OpenAPI·runtime/test 변경 범위를 먼저 재확정한다. +- **TDD 예외 사유:** 실행 동작을 변경하지 않고 상충하는 설명 문서만 현재 기계 계약과 구현 증거에 맞추는 문서 Task다. +- **대체 검증 방법:** OpenAPI DELETE description, facade/repository 분기, + `shouldNoopInactiveFanRootInSameTarget` 테스트 소스와 두 설명 문서를 정적으로 대조한다. + +**Files:** + +- Modify: `docs/20260724_AI캐릭터_관리자_API/prd.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/api-contract.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/plan-task.md` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` +- Confirm: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkDeleteTest.kt` + +- [x] **DOCUMENT:** PRD와 `api-contract.md`의 상충 문장을 OpenAPI·runtime의 비활성 동일-target 팬 root 성공 + no-op 계약으로 최소 수정한다. +- [x] **STATIC:** 미존재·다른 target·creator root·reply는 400이라는 구분과 root row-only soft delete 의미가 + 유지되는지 대조한다. +- [x] **SCOPE:** production/test/OpenAPI diff가 없고 문서 링크·용어·상태가 일치하는지 `git diff --check`와 + 정적 검색으로 확인한다. + +#### Phase 6 FanTalk 삭제 문서 계약 후속 Gate + +**Goal 실행 `P6-R5-GATE`:** `REV-070` 수정 뒤 PRD·plan·OpenAPI·계약 설명·구현 증거의 FanTalk 삭제 의미를 +재검토한다. + +- [x] **`P6-R5-GATE` 완료:** `P6-R5` 완료 후 정적 대조와 diff check를 fresh 실행하고 Phase 6 리뷰와 Progress를 + 갱신한다. +- **시작 조건:** `P6-R5` 완료. +- **완료 증거:** 비활성 동일-target 팬 root no-op와 나머지 거부 경계가 모든 규범 문서에서 일치. +- **범위 밖:** Gate에서 production/test/OpenAPI 변경. + +```bash +rg -n '비활성|no-op|FanTalk.*삭제' \ + docs/20260724_AI캐릭터_관리자_API/prd.md \ + docs/20260724_AI캐릭터_관리자_API/api-contract.md \ + docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json +./gradlew tasks --all +git diff --check +``` + +- 검증 기록(DOCUMENT): 무엇: FanTalk 비활성 root 삭제의 no-op 문서 정합화. 왜: OpenAPI·구현·회귀는 같은 target 비활성 팬 root 삭제를 200 `data:null` no-op으로 고정하지만 PRD 일부 문장이 400 거부로 설명했기 때문이다. 어떻게: PRD와 `api-contract.md`의 삭제 설명을 같은 target 비활성 팬 root no-op, creator root·reply·다른 target·미존재 root 400으로 동기화했다. 결과: runtime/test/OpenAPI 변경 없이 설명 문서만 갱신했다. +- 검증 기록(GATE): 정적 대조와 diff check 결과는 `P7-R10-GATE`에 통합 기록한다. + --- ### Phase 7: Final Integration & Quality Gate @@ -2702,7 +5475,7 @@ v2 관리자 API를 제공한다. - Phase 1~6 완료. #### API endpoint와 request/response contract -- `api-contract.openapi.json`의 23개 operation이 모두 구현되어야 한다. +- `api-contract.openapi.json`의 37개 operation이 모두 구현되어야 한다. - 모든 신규 endpoint가 JWT ADMIN + 현재 DB ADMIN 이중 인가와 공통 오류 envelope/i18n을 공유해야 한다. - legacy/public endpoint URI와 성공·오류 status/body/message diff가 없어야 한다. @@ -2753,7 +5526,7 @@ v2 관리자 API를 제공한다. #### 권장 commit 경계 - `test: verify ai character admin api integration` -- [ ] **Task 7.1: 전체 targeted 및 필요 시 전체 회귀 test 실행** +- [x] **Task 7.1: 전체 targeted 및 필요 시 전체 회귀 test 실행** - **Goal 실행 `P7-T1`:** Phase 1~6 targeted test를 실행하고 위험 근거에 따라 전체 회귀 필요성을 판정해 결과를 확정한다. - **시작 조건:** `P2-GATE`~`P6-GATE` 완료와 Phase 1 완료 증거 확인. - **완료 증거:** targeted 결과와 전체 회귀 실행 또는 생략 판정·근거를 Progress와 하단 검증 기록에 누적. @@ -2764,12 +5537,12 @@ v2 관리자 API를 제공한다. - REFACTOR: 실패가 있으면 관련 Phase Task로 되돌려 최소 수정 후 다시 실행한다. - Verify: 위 targeted command를 실행하고 전체 회귀 필요성을 판정한다. 실행 시 `./gradlew test` 결과를, 생략 시 근거와 대체 회귀 범위를 이 문서 하단 검증 기록에 남긴다. - - [ ] Phase 1~6 targeted test를 실행하고 각 결과를 기록한다. - - [ ] 공통 경계·여러 Phase 변경과 targeted 결과를 근거로 전체 회귀 필요성을 판정한다. - - [ ] 필요하면 `./gradlew test`의 exit code·실패 수를 기록하고, 불필요하면 생략 근거와 대체 회귀 범위를 기록한다. - - [ ] 실패가 있으면 소유 Phase에 별도 회귀 수정 Goal을 추가하고 `P7-T1`을 완료 처리하지 않는다. + - [x] Phase 1~6 targeted test를 실행하고 각 결과를 기록한다. + - [x] 공통 경계·여러 Phase 변경과 targeted 결과를 근거로 전체 회귀 필요성을 판정한다. + - [x] 필요하면 `./gradlew test`의 exit code·실패 수를 기록하고, 불필요하면 생략 근거와 대체 회귀 범위를 기록한다. + - [x] 실패가 있으면 소유 Phase에 별도 회귀 수정 Goal을 추가하고 `P7-T1`을 완료 처리하지 않는다. -- [ ] **Task 7.2: API contract와 변경 범위 점검** +- [x] **Task 7.2: API contract와 변경 범위 점검** - **Goal 실행 `P7-T2`:** API·architecture·dependency·DDL·diff와 문서 추적성을 read-only로 최종 점검한다. - **시작 조건:** `P7-T1` 완료. - **완료 증거:** 아래 점검 체크박스, `ktlintCheck`, source spec acceptance criteria 추적 결과와 Progress 기록. @@ -2779,26 +5552,623 @@ v2 관리자 API를 제공한다. dependency 없음, 신규 DDL 없음, 신규 v2 application/domain에서 기존 controller와 v2 response DTO 역참조 없음. - REFACTOR: 불필요한 import, 역방향 의존, 관련 없는 변경을 제거하고 diff를 다시 확인한다. - Verify: `git diff --name-only`, `./gradlew ktlintCheck` - - [ ] `git diff --name-only`와 `git diff --check`로 변경 범위와 문서/코드 오류를 확인한다. - - [ ] `build.gradle.kts`와 migration/DDL 경로를 확인해 신규 dependency·DDL 0건을 기록한다. - - [ ] legacy/public controller·DTO 외부 계약 diff와 신규 application/domain의 역방향 import 0건을 확인한다. - - [ ] source spec acceptance criteria 25개를 Phase Goal/Gate 완료 증거에 대조한다. - - [ ] `./gradlew ktlintCheck`와 `./gradlew tasks --all` 결과를 기록한다. + - [x] `git diff --name-only`와 `git diff --check`로 변경 범위와 문서/코드 오류를 확인한다. + - [x] `build.gradle.kts`와 migration/DDL 경로를 확인해 신규 dependency·DDL 0건을 기록한다. + - [x] legacy/public controller·DTO 외부 계약 diff와 신규 application/domain의 역방향 import 0건을 확인한다. + - [x] source spec acceptance criteria 25개를 Phase Goal/Gate 완료 증거에 대조한다. + - [x] `./gradlew ktlintCheck`와 `./gradlew tasks --all` 결과를 기록한다. #### Phase 7 Gate **Goal 실행 `P7-GATE`:** 모든 Phase의 완료 증거와 최신 전체 검증을 대조해 AI 캐릭터 관리자 API의 최종 완료 여부를 판정한다. -- [ ] **`P7-GATE` 완료:** 시작 조건과 완료 증거를 모두 충족하고 문서 상태를 `구현 완료`로 갱신한다. +- [x] **`P7-GATE` 완료:** 시작 조건과 완료 증거를 모두 충족하고 문서 상태를 `구현 완료`로 갱신한다. - **시작 조건:** `P7-T1`, `P7-T2` 완료. - **완료 증거:** 미완료 Goal·미처리 review finding·보류 없는 차단 사항 0건, 아래 완료 조건과 최종 Progress 기록. - **범위 밖:** Gate에서 직접 production code 수정, test 삭제·skip·완화. -- [ ] Phase 1~6의 Task/Gate 완료 증거와 하단 검증 기록이 일치한다. -- [ ] 모든 확정 review finding이 수정 완료 또는 근거 있는 제외로 종결됐다. -- [ ] 최신 targeted·ktlint·문서 명령이 성공했고, 전체 회귀는 필요성 판정에 따라 성공 결과 또는 생략 근거가 기록됐다. -- [ ] 남은 항목과 최종 상태를 Progress 및 최종 보고 형식으로 기록한다. +- [x] Phase 1~6의 Task/Gate 완료 증거와 하단 검증 기록이 일치한다. +- [x] 모든 확정 review finding이 수정 완료 또는 근거 있는 제외로 종결됐다. +- [x] 최신 targeted·ktlint·문서 명령이 성공했고, 전체 회귀는 필요성 판정에 따라 성공 결과 또는 생략 근거가 기록됐다. +- [x] 남은 항목과 최종 상태를 Progress 및 최종 보고 형식으로 기록한다. + +#### Phase 7 후속 리뷰 보완 + +- [x] **Task 7.3: 구현 현황 문서와 23개 operation metadata 동기화** + +**Goal 실행 `P7-R1`:** `REV-029`의 계획·계약 설명·OpenAPI 구현 상태 metadata를 모든 Phase 후속 Gate가 완료된 실제 +23개 controller mapping과 동기화한다. + +- **추적 review ID:** `REV-029`. +- **시작 조건:** `P2-R6-GATE`, `P3-R9-GATE`, `P4-R1-GATE`, `P5-R1-GATE`, `P6-R1-GATE` 완료. +- **완료 증거:** plan 상태표/Endpoint Contract Summary와 `api-contract.md`가 23개 구현 완료를 표시하고, + OpenAPI 23개 operation의 `x-implementation-status`가 모두 `implemented`이며 controller mapping도 정확히 23개인 정적 + 대조, OpenAPI validate/client 생성과 Progress 기록. +- **범위 밖:** path/request/response schema 변경, operation 추가·삭제, production 코드 변경, 과거 완료 기록 삭제. +- **TDD 예외 사유:** 실행 코드를 변경하지 않는 구현 현황·계약 metadata 문서 정합성 Task다. +- **대체 검증 방법:** OpenAPI operation/status 개수와 controller mapping을 기계 집계하고 validator/client generator로 + schema 비변경을 확인한다. + +**Files:** + +- Modify: `docs/20260724_AI캐릭터_관리자_API/plan-task.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/api-contract.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/**/*Controller.kt` + +- [x] plan 현재 상태와 Endpoint Contract Summary를 Phase 2~6 후속 Gate 완료 상태로 동기화한다. +- [x] `api-contract.md`의 구현/정합화/예정 operation 수를 실제 23개 구현 완료 상태로 동기화한다. +- [x] OpenAPI 23개 operation의 `x-implementation-status`를 `implemented`로 바꾸고 operation/status 개수를 단언한다. +- [x] 신규 prefix controller mapping이 OpenAPI와 정확히 23개로 일치하고 계약 밖 route가 0개인지 대조한다. +- [x] OpenAPI validate, TypeScript client 생성/compile, `./gradlew tasks --all`, diff check 결과를 Progress에 기록한다. + +```bash +jq -e ' + [.paths[] | to_entries[] | select(.key | IN("get", "post", "put", "delete")) | .value] as $operations + | ($operations | length) == 23 + and ([$operations[] | select(.["x-implementation-status"] == "implemented")] | length) == 23 +' 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 +./gradlew tasks --all +git diff --check +``` + +#### Phase 7 후속 리뷰 Gate + +**Goal 실행 `P7-R1-GATE`:** 모든 후속 리뷰 finding과 23개 operation 구현 현황 문서가 종결됐는지 최종 판정한다. + +- [x] **`P7-R1-GATE` 완료:** `P7-R1` 완료 후 Phase 2~6 후속 Gate와 문서/OpenAPI/controller 집계를 fresh 대조하고 + 상태를 `구현 완료`로 갱신한다. +- **시작 조건:** `P7-R1` 완료. +- **완료 증거:** `REV-021`~`REV-029` 처리 완료, 미완료 Goal 0건, 23개 operation 문서/metadata/mapping 일치, + validator/client/문서 명령과 diff check 성공. +- **범위 밖:** Gate에서 production code 또는 공개 API schema 변경. + +#### Phase 7 2차 통합 재판정 + +- [x] **Task 7.4: Phase 3~5 후속 보완 통합 검증** + +**Goal 실행 `P7-R2`:** `REV-030`~`REV-033` 수정 후 23개 operation과 공통 오류·ownership·동시성 계약을 +다시 통합 검증한다. + +- **시작 조건:** `P3-R10-GATE`, `P4-R2-GATE`, `P5-R2-GATE` 완료. +- **완료 증거:** 네 review ID 처리 완료, Phase 1~6 targeted와 전체 회귀, `ktlintCheck`, OpenAPI/controller 집계, + dependency/DDL/diff 점검과 Progress 기록. +- **범위 밖:** 신규 기능·operation/schema 추가, 완료된 Phase 1·2·6 동작 변경, unrelated refactor. +- **TDD 예외 사유:** 여러 Phase의 회귀 수정 후 검증·판정 전용 Task이며 별도 production 동작을 추가하지 않는다. +- **대체 검증 방법:** 각 소유 Phase Gate의 RED/GREEN 증거를 재사용하지 않고 통합 명령을 fresh 실행한다. + +**Files:** + +- Modify: `docs/20260724_AI캐릭터_관리자_API/plan-task.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase3-audio-content-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase4-series-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase5-community-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase7-integration-review.md` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] `REV-030`~`REV-033`이 모두 처리 완료이고 미완료 소유 Goal이 없는지 확인한다. +- [x] Phase 1~6 targeted 명령과 `./gradlew test` 전체 회귀를 fresh 실행한다. 여러 domain/Phase의 production 수정과 + final Gate이므로 이번에는 전체 회귀를 생략하지 않는다. +- [x] `./gradlew ktlintCheck`, OpenAPI 23개 operation/status, controller mapping 23개, 신규 dependency/DDL 0건과 + `git diff --check`를 확인한다. +- [x] Phase별 리뷰의 수정 후 검증 기록과 현재 상태표·Progress를 동기화한다. +- 검증 기록: 무엇: `REV-030`~`REV-033` 수정 후 Phase 1~6 targeted, 전체 회귀, lint, OpenAPI/controller/dependency/DDL/diff 상태를 fresh 재검증했다. 왜: Phase 3~5 후속 보완이 여러 domain production/test를 변경했으므로 최종 Gate에서 전체 회귀를 생략하지 않기 위해. 어떻게: 아래 targeted 명령, `./gradlew test`, `./gradlew ktlintCheck`, `jq` operation/status assertion, controller mapping 23개 assertion, dependency/DDL diff, `git diff --check`를 실행했다. 결과: targeted는 `BUILD SUCCESSFUL in 2m 45s`, 전체 회귀는 `BUILD SUCCESSFUL in 7m 58s`, ktlint는 `BUILD SUCCESSFUL in 1s`, OpenAPI assertion은 `true`, controller mapping 23개 assertion과 dependency/DDL diff, diff check는 출력 없이 통과했다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest \ + --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' +./gradlew test +./gradlew ktlintCheck +jq -e ' + [.paths[] | to_entries[] | select(.key | IN("get", "post", "put", "delete")) | .value] as $operations + | ($operations | length) == 23 + and ([$operations[] | select(.["x-implementation-status"] == "implemented")] | length) == 23 +' docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json +git diff --check +``` + +#### Phase 7 2차 통합 Gate + +**Goal 실행 `P7-R2-GATE`:** 모든 후속 수정과 검증 증거를 대조해 최종 완료 여부를 재판정한다. + +- [x] **`P7-R2-GATE` 완료:** `P7-R2` 완료 후 미완료 Goal·미처리 finding·차단 사항 0건을 확인하고 문서 상태를 + `구현 완료`로 되돌린다. +- **시작 조건:** `P7-R2` 완료. +- **완료 증거:** `REV-030`~`REV-033` 처리 완료, targeted/전체 회귀/lint 성공, 23개 operation/mapping 유지, + dependency/DDL 추가 없음, Phase별 리뷰와 Progress 동기화. +- **범위 밖:** Gate에서 production code 또는 공개 API schema 변경. +- 검증 기록: 무엇: `P7-R2-GATE`에서 미완료 Goal·미처리 finding·차단 사항 0건과 최종 문서 상태를 재판정했다. 왜: Phase 3~5 후속 Gate 완료 뒤 최종 완료 상태를 복구하기 위해. 어떻게: 하단 finding 표의 `REV-030`~`REV-033` 처리 완료, Phase 3~5 review 문서 판정, targeted/전체 회귀/lint/OpenAPI/controller/diff 결과를 대조했다. 결과: 후속 finding은 모두 처리 완료이고 23개 operation/mapping/status가 유지되어 문서 상태를 `구현 완료`로 갱신했다. + +#### Phase 7 3차 통합 재판정 + +- [x] **Task 7.5: Phase 2~4 후속 보완 통합 검증** + +**Goal 실행 `P7-R3`:** `REV-034`~`REV-037` 수정 후 23개 operation과 레거시 parity, 공통 오류·ownership 계약을 +다시 통합 검증한다. + +- **시작 조건:** `P2-R7-GATE`, `P3-R11-GATE`, `P4-R3-GATE` 완료. +- **완료 증거:** 네 review ID 처리 완료, Phase 1~6 targeted와 전체 회귀, `ktlintCheck`, OpenAPI/controller 집계, + dependency/DDL/diff 점검과 Progress 기록. +- **범위 밖:** 신규 기능·operation/schema 추가, 완료된 Phase 1·5·6 동작 변경, unrelated refactor. +- **TDD 예외 사유:** 여러 Phase의 회귀 수정 후 검증·판정 전용 Task이며 별도 production 동작을 추가하지 않는다. +- **대체 검증 방법:** 각 소유 Phase Gate의 증거를 재사용하지 않고 통합 명령을 fresh 실행한다. + +**Files:** + +- Modify: `docs/20260724_AI캐릭터_관리자_API/plan-task.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase2-character-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase3-audio-content-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase4-series-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase7-integration-review.md` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] `REV-034`~`REV-037`이 모두 처리 완료이고 미완료 소유 Goal이 없는지 확인한다. +- [x] Phase 1~6 targeted 명령과 `./gradlew test` 전체 회귀를 fresh 실행한다. 세 domain의 production 수정과 final + Gate이므로 전체 회귀를 생략하지 않는다. +- [x] `./gradlew ktlintCheck`, OpenAPI 23개 operation/status, controller mapping 23개, 신규 dependency/DDL 0건과 + `git diff --check`를 확인한다. +- [x] Phase별 리뷰의 수정 후 검증 기록과 현재 상태표·Progress를 동기화한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest \ + --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' +./gradlew test +./gradlew ktlintCheck +jq -e ' + [.paths[] | to_entries[] | select(.key | IN("get", "post", "put", "delete")) | .value] as $operations + | ($operations | length) == 23 + and ([$operations[] | select(.["x-implementation-status"] == "implemented")] | length) == 23 +' docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json +git diff --check +``` + +#### Phase 7 3차 통합 Gate + +**Goal 실행 `P7-R3-GATE`:** 모든 후속 수정과 검증 증거를 대조해 최종 완료 여부를 재판정한다. + +- [x] **`P7-R3-GATE` 완료:** `P7-R3` 완료 후 미완료 Goal·미처리 finding·차단 사항 0건을 확인하고 문서 상태를 + `구현 완료`로 되돌린다. +- **시작 조건:** `P7-R3` 완료. +- **완료 증거:** `REV-034`~`REV-037` 처리 완료, targeted/전체 회귀/lint 성공, 23개 operation/mapping 유지, + dependency/DDL 추가 없음, Phase별 리뷰와 Progress 동기화. +- **범위 밖:** Gate에서 production code 또는 공개 API schema 변경. + + - 검증 기록: 무엇: `REV-034`~`REV-037` 통합 재판정. 왜: 세 domain production 수정 후 23개 operation과 공통 오류·ownership 계약이 유지되는지 확인하기 위해. 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*'`, `./gradlew test`, `./gradlew ktlintCheck`, OpenAPI 23개 operation/status `jq`, controller mapping `rg` 23개, `git diff --check`, 변경 파일명 기반 dependency/DDL 점검을 실행했다. 결과: targeted와 전체 test, lint, jq가 모두 성공했고 mapping은 23개, `git diff --check`는 출력 없음, 신규 dependency/DDL 파일 변경은 없었다. + +#### Phase 7 4차 리뷰 보완 + +- [x] **Task 7.6: 완료 Task 상태와 Phase 4 DELETE 계약 설명 동기화** + +**Goal 실행 `P7-R4`:** 완료 증거가 존재하는 후속 Task 헤더와 현재 상태표를 동기화하고, Phase 4 시리즈 콘텐츠 해제 설명을 +OpenAPI와 실제 controller route에 맞춘다. + +- **추적 review ID:** `REV-038`, `REV-039`. +- **시작 조건:** `P7-R3-GATE` 완료와 `phase7-integration-review.md` 4차 정적 리뷰 판정 존재. +- **완료 증거:** `Task 2.13`, `Task 3.21`, `Task 4.9`, `Task 7.5` 헤더가 기존 완료 증거와 같은 `[x]` 상태이고, + Phase 4 DELETE 설명이 `/contents/{contentId}` path·request body 없음으로 정정되며 상태표·Progress·리뷰 문서가 + 다시 완료 상태로 동기화된다. +- **범위 밖:** production/test/OpenAPI 변경, 기존 완료 증거 삭제·덮어쓰기, API operation 추가·삭제. +- **TDD 예외 사유:** 실행 동작이 아닌 구현 계획의 완료 상태와 이미 확정된 API 설명을 정정하는 문서 전용 Task다. +- **대체 검증 방법:** 미완료 Task 헤더 집계, OpenAPI 23개 operation/status, controller mapping과 Phase 4 DELETE route, + 문서 명령 및 diff를 정적으로 대조한다. + +**Files:** + +- Modify: `docs/20260724_AI캐릭터_관리자_API/plan-task.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase7-integration-review.md` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesController.kt` + +- [x] 기존 후속 Gate와 2026-07-29 검증 기록을 근거로 완료된 네 Task 헤더만 `[x]`로 동기화한다. +- [x] Phase 4 endpoint 설명의 시리즈 콘텐츠 해제를 + `DELETE /series/{seriesId}/contents/{contentId}`와 request body 없음으로 정정한다. +- [x] 상단 상태표, Goal Progress, 발견된 문제 표와 Phase 7 리뷰 판정을 `구현 완료` 상태로 동기화한다. +- [x] `./gradlew tasks --all`, OpenAPI 23개 operation/status `jq`, controller mapping 집계, + 미완료 Task header `rg`, `git diff --check` 결과를 누적 기록한다. + +```bash +./gradlew tasks --all +jq -e ' + [.paths[] | to_entries[] | select(.key | IN("get", "post", "put", "delete")) | .value] as $operations + | ($operations | length) == 23 + and ([$operations[] | select(.["x-implementation-status"] == "implemented")] | length) == 23 +' docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json +rg -n '^- \[ \] \*\*Task' docs/20260724_AI캐릭터_관리자_API/plan-task.md +rg -n '@(Get|Post|Put|Delete)Mapping' \ + src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter +git diff --check +``` + +#### Phase 7 5차 통합 보완 + +- [x] **Task 7.7: Phase 2~5 후속 보완 통합 재판정** + +**Goal 실행 `P7-R5`:** Phase 2~5의 primitive required/nullability와 커뮤니티 목록 계약 보완 뒤 23개 관리자 +operation의 계약, 공통 보안·오류, ownership과 legacy 회귀를 통합 재판정한다. + +- **추적 근거:** `REV-040`~`REV-043`, `DEC-P5-LIST-001`. +- **시작 조건:** `P2-R8-GATE`, `P3-R12-GATE`, `P4-R4-GATE`, `P5-R3-GATE`, `P5-R4-GATE` 완료. +- **완료 증거:** Phase 2~5 focused/영향 범위 회귀와 통합 회귀·lint 성공, OpenAPI operation/status와 controller mapping + 23개 유지, dependency/DDL 추가 없음, Phase별 리뷰·상태표·Progress 동기화. +- **범위 밖:** Gate 목적과 무관한 production refactor, 전역 Jackson 정책 변경, 공개 API schema 변경. + +**Files:** + +- Modify: `docs/20260724_AI캐릭터_관리자_API/plan-task.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase2-character-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase3-audio-content-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase4-series-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase5-community-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase7-integration-review.md` + +- [x] Phase 2~5의 focused와 각 package/common 영향 범위 회귀가 fresh 성공했는지 Gate 증거를 대조한다. +- [x] 커뮤니티 목록의 timezone 제거, pagination wrapper와 active owner count·hasNext 계약을 확인한다. +- [x] JWT ADMIN 이중 인가, target/owner 오류, JSON 오류 envelope과 no-side-effect 회귀를 통합 범위에서 확인한다. +- [x] OpenAPI 23개 operation/status와 controller mapping 23개, dependency/DDL 무변경을 확인한다. +- [x] Phase별 review의 finding 상태와 상단 상태표·Progress를 최종 판정에 맞게 동기화한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest \ + --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' +./gradlew test +./gradlew ktlintCheck +jq -e ' + [.paths[] | to_entries[] | select(.key | IN("get", "post", "put", "delete")) | .value] as $operations + | ($operations | length) == 23 + and ([$operations[] | select(.["x-implementation-status"] == "implemented")] | length) == 23 +' docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json +git diff --check +``` + +#### Phase 7 5차 통합 Gate + +**Goal 실행 `P7-R5-GATE`:** 모든 Phase 2~5 후속 수정과 검증 증거를 대조해 최종 완료 여부를 판정한다. + +- [x] **`P7-R5-GATE` 완료:** `P7-R5` 완료 후 미완료 Goal·미처리 finding·차단 사항 0건을 확인하고 문서 상태를 + `구현 완료`로 갱신한다. +- **시작 조건:** `P7-R5` 완료. +- **완료 증거:** `REV-040`~`REV-043`과 `DEC-P5-LIST-001` 처리 완료, focused/통합/전체 회귀와 lint 성공, + 23개 operation/mapping 및 23개 `implemented` 유지, dependency/DDL 추가 없음, Phase별 리뷰와 Progress 동기화. +- **범위 밖:** Gate에서 production code 또는 공개 API schema 변경. +- 검증 기록: 무엇: Phase 2~5 후속 보완과 Community 목록 계약 변경 뒤 최종 통합 상태를 재판정했다. 왜: + primitive required/nullability와 목록 wrapper 변경이 23개 관리자 operation, 공통 보안·오류, ownership, legacy 회귀를 + 깨뜨리지 않는지 확인해야 했기 때문이다. 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*'`, + `./gradlew test`, `./gradlew ktlintCheck`, OpenAPI 23개 operation/status `jq`, controller mapping `rg`, + dependency/DDL 경로 diff 검색, `git diff --check`를 fresh 실행했다. 결과: targeted는 `BUILD SUCCESSFUL in 2m 21s`, + 전체 test는 `BUILD SUCCESSFUL in 5m 46s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 881ms`, OpenAPI assertion은 `true`, + controller mapping은 23개, dependency/DDL 검색과 `git diff --check`는 출력이 없었다. 미완료 Goal·미처리 finding·차단 사항은 0건이다. + +#### Phase 7 후속 기능 통합 보완 + +- [x] **Task 7.8: 36개 operation 후속 기능 통합 재판정** + +**Goal 실행 `P7-R6`:** Phase 2~6의 후속 기능과 시리즈 상세 정합화가 끝난 뒤 36개 관리자 operation의 계약, +공통 보안·오류, actor·ownership, soft delete와 legacy/public 회귀를 통합 재판정한다. + +- **추적 근거:** `REV-044`~`REV-049`, `DEC-COMMENT-001`, `DEC-CHAR-COMMENT-001`, + `DEC-FANTALK-DELETE-001`, `DEC-REGISTRATION-REFERENCE-001`, `DEC-SERIES-DETAIL-001`. +- **시작 조건:** `P2-R9-GATE`, `P3-R13-GATE`, `P4-R5-GATE`, `P4-R6-GATE`, `P5-R5-GATE`, + `P6-R2-GATE` 완료. +- **완료 증거:** Phase 2~6 focused/영향 범위 회귀와 통합·전체 회귀·lint 성공, OpenAPI 36개 + `implemented`와 controller mapping 36개 일치, 미구현 캐릭터 직접 댓글 route 없음, dependency/DDL 추가 없음, + Phase별 리뷰·상태표·Progress 동기화. +- **범위 밖:** 승인 범위 밖 기능, 캐릭터 직접 댓글 API, 전역 refactor, public/legacy API schema 변경. + +**Files:** + +- Modify: `docs/20260724_AI캐릭터_관리자_API/plan-task.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase2-character-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase3-audio-content-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase4-series-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase5-community-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase6-fantalk-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase7-integration-review.md` + +- [x] Phase 2~6 신규 Goal의 focused와 각 package/common 영향 범위 회귀가 fresh 성공했는지 Gate 증거를 대조한다. +- [x] 댓글 actor·parent·owner 경계, row-only soft delete, FanTalk fan root 삭제와 reply row 유지 결과를 통합 확인한다. +- [x] 원작·장르 참조 조회와 시리즈 목록/상세 item parity, JWT ADMIN 이중 인가와 공통 오류 계약을 확인한다. +- [x] OpenAPI operation/status와 controller mapping이 36개이며 캐릭터 직접 댓글 operation/mapping이 없는지 확인한다. +- [x] 신규 dependency/DDL 무변경과 legacy/public 회귀를 확인하고 Phase별 review·상태표·Progress를 최종 동기화한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest \ + --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' +./gradlew test +./gradlew ktlintCheck +jq -e ' + [.paths[] | to_entries[] | select(.key | IN("get", "post", "put", "delete")) | .value] as $operations + | ($operations | length) == 36 + and ([$operations[] | select(.["x-implementation-status"] == "implemented")] | length) == 36 +' docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json +git diff --check +``` + +#### Phase 7 후속 기능 통합 Gate + +**Goal 실행 `P7-R6-GATE`:** 모든 후속 기능·정합화 Goal과 검증 증거를 대조해 최종 완료 여부를 판정한다. + +- [x] **`P7-R6-GATE` 완료:** `P7-R6` 완료 후 미완료 Goal·미처리 finding·차단 사항 0건을 확인하고 문서 상태를 + `구현 완료`로 갱신한다. +- **시작 조건:** `P7-R6` 완료. +- **완료 증거:** `REV-044`~`REV-049` 처리 완료, focused/통합/전체 회귀와 lint 성공, 36개 + operation/mapping/`implemented` 일치, 범위 제외 route 0개, dependency/DDL 추가 없음, Phase별 문서 동기화. +- **범위 밖:** Gate에서 production code 또는 public/legacy API 변경. + +#### Phase 7 UTC 날짜 계약 통합 보완 + +- [x] **Task 7.9: UTC 날짜 계약 36개 operation 통합 재판정** + +**Goal 실행 `P7-R7`:** Phase 3·5 UTC 날짜 계약 구현 뒤 36개 관리자 operation의 OpenAPI·runtime·보안·legacy/public +격리를 통합 재판정한다. + +- **추적 근거:** `REV-050`, `REV-051`, `DEC-UTC-DATE-001`. +- **시작 조건:** `P3-R14-GATE`, `P5-R6-GATE` 완료. +- **완료 증거:** 36개 operation과 controller mapping 유지, 36개 `implemented`, OpenAPI에서 `timezone` parameter/schema + 0건, 영향 6개 operation의 UTC 계약·legacy/public 회귀·lint 성공, Phase별 리뷰·상태표·Progress 동기화. +- **범위 밖:** 승인된 6개 operation 밖 날짜 schema 변경, 전역 timezone/Jackson 정책 변경, dependency·DDL 추가, + public/legacy API 변경. + +**Files:** + +- Modify: `docs/20260724_AI캐릭터_관리자_API/plan-task.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` +- Modify: `docs/20260724_AI캐릭터_관리자_API/api-contract.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase3-audio-content-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase5-community-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase7-integration-review.md` + +- [x] Phase 3 오디오 4개와 Phase 5 커뮤니티 2개 operation의 focused/Gate 증거를 대조한다. +- [x] OpenAPI 36개 operation·36개 `implemented`, controller mapping 36개와 timezone parameter/schema 0건을 확인한다. +- [x] 오디오 생성·상세·댓글과 커뮤니티 댓글의 UTC exact JSON, 기존 ownership/인가/오류·legacy/public 회귀를 확인한다. +- [x] 신규 dependency/DDL·범위 밖 날짜 schema 변경이 없고 Phase별 review finding·상태표·Progress가 일치하는지 확인한다. + +- **`P7-R7` 검증(2026-07-29):** `P3-R14-GATE`와 `P5-R6-GATE` 증거를 대조했다. OpenAPI는 36개 operation, + 36개 `implemented`, 0개 `alignment-required`, query `timezone` parameter 0개, `components.parameters.Timezone` + 0개였고, 신규 prefix controller mapping도 36개였다. 영향 6개 operation의 UTC exact JSON은 오디오 focused + `BUILD SUCCESSFUL in 52s`, 커뮤니티 댓글 focused `--rerun-tasks` 재실행 `BUILD SUCCESSFUL in 4m 33s`와 각 Gate의 + legacy/public 영향 범위 회귀로 확인했다. 내부 legacy 재사용을 위한 `timezone = UTC` 상수 호출 외 신규 관리자 + 외부 계약의 timezone query/body는 남지 않았다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest \ + --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' \ + --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest \ + --tests kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest +./gradlew ktlintCheck +jq -e ' + [.paths[] | to_entries[] | select(.key | IN("get", "post", "put", "delete")) | .value] as $operations + | ($operations | length) == 36 + and ([$operations[] | select(.["x-implementation-status"] == "implemented")] | length) == 36 + and ([.. | objects | select(.name? == "timezone" and .in? == "query")] | length) == 0 + and (.components.parameters.Timezone? == null) +' docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json +git diff --check +``` + +#### Phase 7 UTC 날짜 계약 통합 Gate + +**Goal 실행 `P7-R7-GATE`:** `P3-R14-GATE`, `P5-R6-GATE`와 통합 증거를 대조해 최신 계약 구현 완료를 판정한다. + +- [x] **`P7-R7-GATE` 완료:** `P7-R7` 완료 후 미완료 Goal·미처리 finding·차단 사항 0건을 확인하고 문서 상태를 + `구현 완료`로 갱신한다. +- **시작 조건:** `P7-R7` 완료. +- **완료 증거:** `REV-050`~`REV-051` 처리 완료, 영향 범위 회귀와 lint 성공, 36개 + operation/mapping/`implemented` 일치, timezone parameter/schema 0건, dependency/DDL 추가 없음, Phase별 문서 동기화. +- **범위 밖:** Gate에서 production code 또는 public/legacy API 변경. + +#### Phase 7 HTTP 경계 통합 재판정 + +- [x] **Task 7.10: optional pagination과 JSON·multipart media type 계약 통합 재판정** + +**Goal 실행 `P7-R8`:** `P2-R10-GATE`, `P3-R15-GATE`, `P3-R16-GATE`, `P4-R7-GATE`, `P5-R7-GATE`, +`P5-R8-GATE`, `P6-R3-GATE`, `P6-R4-GATE`의 결과를 대조해 37개 관리자 operation의 query 기본값, request media +type과 FanTalk 답변 수정 계약을 최종 재판정한다. + +- **추적 review ID:** `REV-052`~`REV-059`. +- **시작 조건:** Phase 2~6의 여덟 소유 Gate 완료. +- **완료 증거:** OpenAPI 37개 operation/고유 operationId와 controller 37개 mapping 일치, 영향 14개 operation의 + optional pagination, JSON-only 또는 multipart part-level JSON/415, FanTalk 답변 수정 계약 및 공통 오류 + header/envelope 회귀 성공, 미처리 finding 0건, + dependency·DDL 추가 없음과 Phase별 리뷰·Progress 동기화. +- **범위 밖:** 확정된 FanTalk 답변 수정 외 신규 route/schema/기능, legacy/public API 변경, 관련 없는 refactor. + +- [x] **STATIC:** OpenAPI 문법·내부 `$ref`·operationId·request media type·pagination parameter와 controller mapping을 대조한다. +- [x] **REGRESSION:** Phase 3·5·6 focused와 공통 error/authorization 영향 범위 결과를 대조하고 전체 회귀 필요성을 판정한다. +- [x] **SCOPE:** dependency/DDL/legacy-public 변경이 없고 37개 route가 일치하는지 확인한다. +- [x] **DOCUMENT:** `REV-052`~`REV-059`, 상태표, Phase별 리뷰, Goal Progress와 검증 기록을 동기화한다. + +#### Phase 7 HTTP 경계 통합 Gate + +**Goal 실행 `P7-R8-GATE`:** `P7-R8` 증거와 미처리 finding을 대조해 구현 완료 복구 여부를 판정한다. + +- [x] **`P7-R8-GATE` 완료:** 미완료 Goal·미처리 finding·차단 사항 0건일 때만 문서 상태를 `구현 완료`로 갱신한다. +- **시작 조건:** `P7-R8` 완료. +- **완료 증거:** 영향 14개 operation을 포함한 37개 계약/mapping 정합성, 회귀·lint·diff 성공, 문서 동기화. +- **범위 밖:** Gate에서 production code 또는 OpenAPI schema 변경. + +#### Phase 7 multipart part 이름·문서 상태 통합 재판정 + +- [x] **Task 7.11: exact multipart part 계약과 37개 구현 상태 통합 정합화** + +**Goal 실행 `P7-R9`:** `P2-R11-GATE`, `P3-R17-GATE`, `P4-R8-GATE`, `P5-R9-GATE` 결과를 대조해 +8개 multipart operation의 허용 part 이름을 OpenAPI와 일치시키고, 완료 상태가 오래된 계획·계약 설명을 실제 +37개 구현 상태와 동기화한다. + +- **추적 review ID:** `REV-060`~`REV-064`. +- **시작 조건:** Phase 2~5의 네 소유 Gate 완료. +- **완료 증거:** 8개 multipart operation의 미정의 part 400/no-side-effect와 기존 part/media type 회귀, + OpenAPI 37개 operation/37개 `implemented`와 controller 37개 mapping 일치, `plan-task.md`와 + `api-contract.md`의 route/완료/예정 수·FanTalk 답변 수정 상태 동기화, dependency·DDL 무변경. +- **범위 밖:** OpenAPI path/schema 변경, 신규 route, legacy/public API, 전역 multipart resolver, 관련 없는 문서 이력 삭제. +- **TDD 예외 사유:** production 동작은 Phase 2~5 소유 Task에서 TDD로 수정하며 이 Task는 통합 검증과 현황 문서 + 동기화만 수행한다. +- **대체 검증 방법:** 소유 Gate의 actual endpoint 증거를 대조하고 OpenAPI status와 controller mapping을 기계 집계한다. + +**Files:** + +- Modify: `docs/20260724_AI캐릭터_관리자_API/plan-task.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/api-contract.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase2-character-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase3-audio-content-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase4-series-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase5-community-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase7-integration-review.md` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **STATIC:** OpenAPI의 8개 multipart schema `additionalProperties: false`와 operation별 허용/필수 part를 + controller 및 actual endpoint 회귀와 대조한다. +- [x] **REGRESSION:** Phase 2~5 focused와 공통 error/authorization 영향 범위, 전체 회귀 필요성을 판정해 실행 결과 + 또는 생략 근거를 기록한다. +- [x] **DOCUMENT:** Endpoint Contract Summary, `api-contract.md` 상단 집계·endpoint 표·client 생성 설명을 + 37개 route/37개 구현 완료/예정 0개로 동기화하고 과거 완료 이력은 보존한다. +- [x] **SCOPE:** OpenAPI 37개 operationId/status와 controller mapping, dependency·DDL·legacy/public 무변경, + `ktlintCheck`, `git diff --check`를 확인한다. + +#### Phase 7 multipart part 이름·문서 상태 통합 Gate + +**Goal 실행 `P7-R9-GATE`:** `REV-060`~`REV-064`의 소유 Gate와 통합 증거를 대조해 최신 계약 구현 완료 복구 여부를 +판정한다. + +- [x] **`P7-R9-GATE` 완료:** 미정의 part 회귀와 문서 상태가 모두 정합하고 미처리 finding·차단 사항이 0건일 때만 + 문서 상태를 `구현 완료`로 갱신한다. +- **시작 조건:** `P7-R9` 완료. +- **완료 증거:** 8개 multipart contract, 37개 operation/mapping/status, 문서 집계, 회귀·lint·diff, + dependency·DDL·legacy/public 무변경 확인. +- **범위 밖:** Gate에서 production code 또는 OpenAPI schema 변경. + +#### Phase 7 8차 리뷰 후속 통합과 finding 상태 동기화 + +- [x] **Task 7.12: Phase 2~6 후속 Gate 통합 및 리뷰 상태 정합화** + +**Goal 실행 `P7-R10`:** `REV-065`~`REV-070` 소유 Gate의 완료 증거를 통합 대조하고, 이미 완료된 +`REV-060`~`REV-064`와 신규 finding의 상태·상단 Phase 집계·현재 Goal을 실제 결과에 맞게 동기화한다. + +- **추적 review ID:** `REV-071`. +- **시작 조건:** `P2-R12-GATE`, `P3-R18-GATE`, `P4-R9-GATE`, `P4-R10-GATE`, `P5-R10-GATE`, + `P6-R5-GATE` 완료. +- **완료 증거:** 파일·일반 form-field를 포함한 8개 multipart operation 회귀, Series 장르 0 이하 경계, + FanTalk 비활성 root 삭제 문서 계약, 37개 OpenAPI operation/controller mapping/status, finding 표와 Phase 집계가 + 모두 일치. +- **범위 밖:** 신규 route/schema/기능, legacy/public API, 관련 없는 완료 이력 삭제, 신규 dependency·DDL. +- **TDD 예외 사유:** production 수정은 각 소유 Phase Task에서 수행하고 이 Task는 통합 검증과 상태 문서 동기화만 + 담당한다. +- **대체 검증 방법:** 각 소유 Gate의 actual endpoint 증거를 대조하고 OpenAPI operation/status, controller mapping, + 문서 finding 상태를 기계 집계한다. + +**Files:** + +- Modify: `docs/20260724_AI캐릭터_관리자_API/plan-task.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase2-character-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase3-audio-content-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase4-series-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase5-community-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase6-fantalk-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase7-integration-review.md` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **INTEGRATION:** `REV-065`~`REV-070` 소유 Gate의 focused/영향 범위 또는 문서 정적 검증 증거를 대조한다. +- [x] **STATIC:** OpenAPI 37개 operationId/status와 controller 37개 mapping, 8개 multipart schema, + dependency·DDL·legacy/public 무변경을 확인한다. +- [x] **DOCUMENT:** `REV-060`~`REV-071` 상태, 상단 Phase 완료 수, 현재 Phase/Goal과 Phase별 리뷰 결론을 + 실제 완료 상태에 맞춘다. +- [x] **SCOPE:** 필요한 범위의 회귀·`ktlintCheck`·`git diff --check` 결과 또는 생략 근거를 기록한다. + +#### Phase 7 8차 리뷰 후속 통합 Gate + +**Goal 실행 `P7-R10-GATE`:** 8차 리뷰 finding과 문서 상태가 모두 종결됐는지 최종 판정한다. + +- [x] **`P7-R10-GATE` 완료:** 미처리 finding·차단 사항이 0건이고 최신 계약·구현·문서가 일치할 때만 문서 상태를 + `구현 완료`로 되돌린다. +- **시작 조건:** `P7-R10` 완료. +- **완료 증거:** Phase 2~6 소유 Gate, OpenAPI/controller 집계, Phase별 리뷰·finding 표·상단 상태 정합. +- **범위 밖:** Gate에서 production code 또는 OpenAPI schema 변경. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*' \ + --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*' \ + --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.*' \ + --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*' \ + --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 +./gradlew ktlintCheck +./gradlew tasks --all +jq empty docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json +git diff --check +``` + +- 검증 기록(RED): Phase 2~5 multipart 일반 form-field와 Phase 4 `genreId <= 0` focused RED 묶음에서 신규 multipart/genre 36건 실패를 확인했다. 최초 compile error 2회는 community test helper의 `MockPart` 연결 방식 문제였고, production 변경 전 테스트 구성만 고쳐 재실행했다. +- 검증 기록(GREEN): 같은 focused 묶음을 재실행해 `BUILD SUCCESSFUL in 1m 17s`를 확인했다. +- 검증 기록(INTEGRATION): `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.*' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*' --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 --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest` → `BUILD SUCCESSFUL in 4m 11s`. +- 검증 기록(STATIC): OpenAPI 집계 `operations=37 uniqueOperationIds=37 implemented=37 alignmentRequired=0 planned=0`, controller mapping 37개, FanTalk 삭제 no-op 문서·OpenAPI·테스트 정적 대조 완료. +- 검증 기록(SCOPE): `./gradlew ktlintCheck` → `BUILD SUCCESSFUL in 51s`; `git diff --check` → 출력 없음. 전체 `./gradlew test`는 Phase 2~6 후속 변경의 직접 영향 범위를 위 focused 통합 명령이 포함하므로 생략했다. + +#### Phase 7 9차 리뷰 후속 통합 재판정 + +- [x] **Task 7.13: preview 검증 복구 후 37개 operation 통합 재판정** + +**Goal 실행 `P7-R11`:** `P3-R19-GATE`의 preview 검증 복구 증거를 포함해 Phase 1~7 리뷰 결론, +OpenAPI 37개 operation과 controller mapping, finding·상태 문서를 다시 대조한다. + +- **추적 review ID:** `REV-072`. +- **시작 조건:** `P3-R19-GATE` 완료. +- **완료 증거:** `REV-072` 처리 완료, Phase 3 preview actual endpoint/legacy 회귀 증거, OpenAPI 37개 + operationId와 controller 37개 mapping, 미처리 finding·차단 사항 0건, 상태표·Phase별 리뷰·Progress 동기화. +- **범위 밖:** 신규 route/schema/기능, legacy/public 계약 변경, 관련 없는 완료 이력 삭제, 신규 dependency·DDL. +- **TDD 예외 사유:** production 보완은 Phase 3에서 TDD로 수행하며 이 Task는 통합 증거와 문서 상태만 재판정한다. +- **대체 검증 방법:** `P3-R19-GATE` 결과를 대조하고 OpenAPI operation/status, controller mapping, + Phase별 review/finding 상태를 기계 집계한다. + +**Files:** + +- Modify: `docs/20260724_AI캐릭터_관리자_API/plan-task.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase3-audio-content-review.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/reviews/phase7-integration-review.md` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/prd.md` +- Confirm: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + +- [x] **INTEGRATION:** `P3-R19-GATE`의 actual endpoint·legacy 회귀와 no-side-effect 증거를 대조한다. +- [x] **STATIC:** OpenAPI 37개 operationId/status와 controller 37개 mapping, dependency·DDL·legacy/public + 무변경을 확인한다. +- [x] **DOCUMENT:** `REV-072`, 상단 Phase 완료 수, 현재 Phase/Goal, Phase 3·7 리뷰와 Progress를 실제 결과에 + 맞춘다. +- [x] **SCOPE:** 필요한 범위의 회귀·`ktlintCheck`·`git diff --check` 결과 또는 생략 근거를 기록한다. +- 검증 기록: 무엇: `P3-R19-GATE` 증거를 포함해 37개 operation 통합 상태를 재판정했다. 왜: `REV-072` 처리 전에는 + route/schema 집계만으로 Phase 7 완료 판정을 유지할 수 없었기 때문이다. 어떻게: Phase 3 actual endpoint·legacy 회귀, + OpenAPI implemented count, controller mapping count, dependency/DDL diff, `ktlintCheck`, `git diff --check` 결과를 대조했다. + 결과: OpenAPI `x-implementation-status=implemented` 37개, controller mapping 37개, 신규 dependency·DDL 변경 없음, + 미처리 finding 0건으로 재판정했다. + +#### Phase 7 9차 리뷰 후속 통합 Gate + +**Goal 실행 `P7-R11-GATE`:** `REV-072`와 문서 상태가 모두 종결됐는지 최종 판정한다. + +- [x] **`P7-R11-GATE` 완료:** 미처리 finding·차단 사항이 0건이고 최신 계약·구현·문서가 일치할 때만 + 문서 상태를 `구현 완료`로 되돌린다. +- **시작 조건:** `P7-R11` 완료. +- **완료 증거:** Phase 3 소유 Gate, OpenAPI/controller 집계, Phase별 리뷰·finding 표·상단 상태 정합. +- **범위 밖:** Gate에서 production code 또는 OpenAPI schema 변경. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*' \ + --tests 'kr.co.vividnext.sodalive.content.*AudioContent*Test' +./gradlew ktlintCheck +jq empty docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json +git diff --check +``` + +- Gate 검증 기록: 무엇: Phase 3 소유 Gate와 Phase 7 통합 문서 상태를 최종 대조했다. 왜: 최신 계약·구현·문서가 모두 일치할 + 때만 최종 상태를 `구현 완료`로 되돌릴 수 있기 때문이다. 어떻게: `jq` JSON 문법 확인, OpenAPI implemented 37개 집계, + controller mapping 파일별 4/5/10/9/8/1 합계 37개 집계, dependency·DDL diff, 영향 범위 회귀와 lint·diff 결과를 확인했다. + 결과: `REV-072`는 처리 완료이고 Phase 1~7 미처리 finding·차단 사항은 0건이므로 Phase 7 Gate를 완료로 판정했다. --- @@ -2825,6 +6195,16 @@ v2 관리자 API를 제공한다. | 17 | `P5-T1`~`P5-T6` → `P5-GATE` | `P4-GATE` | 아니요 | 실패 소유 Task로 되돌림 | | 18 | `P6-T1`~`P6-T5` → `P6-GATE` | `P5-GATE` | 아니요 | 실패 소유 Task로 되돌림 | | 19 | `P7-T1` → `P7-T2` → `P7-GATE` | Phase 1~6 최신 Gate | 아니요 | 실패 소유 Phase에 회귀 수정 Goal 추가 | +| 20 | `P2-R6` → `P2-R6-GATE` → `P3-R9` → `P3-R9-GATE` → `P4-R1` → `P4-R1-GATE` → `P5-R1` → `P5-R1-GATE` → `P6-R1` → `P6-R1-GATE` → `P7-R1` → `P7-R1-GATE` | 2026-07-28 Phase 1~7 정적 리뷰 | 아니요 | 확정 finding 소유 Goal에서 최소 수정·검증 후 다음 Gate 수행 | +| 21 | `P3-R10` → `P3-R10-GATE` → `P4-R2` → `P4-R2-GATE` → `P5-R2` → `P5-R2-GATE` → `P7-R2` → `P7-R2-GATE` | 2026-07-28 Phase별 후속 정적 리뷰 | 아니요 | `REV-030`~`REV-033` 소유 Goal에서 최소 수정·검증 후 통합 재판정 | +| 22 | `P2-R7` → `P2-R7-GATE` → `P3-R11` → `P3-R11-GATE` → `P4-R3` → `P4-R3-GATE` → `P7-R3` → `P7-R3-GATE` | 2026-07-28 3차 Phase별 정적 리뷰 | 아니요 | `REV-034`~`REV-037` 소유 Goal에서 최소 수정·검증 후 통합 재판정 | +| 23 | `P2-R8` → `P2-R8-GATE` → `P3-R12` → `P3-R12-GATE` → `P4-R4` → `P4-R4-GATE` → `P5-R3` → `P5-R3-GATE` → `P5-R4` → `P5-R4-GATE` → `P7-R5` → `P7-R5-GATE` | 2026-07-29 5차 Phase별 정적 리뷰와 Community 목록 계약 확정 | 아니요 | `REV-040`~`REV-043`과 `DEC-P5-LIST-001`을 최소 보완한 뒤 통합 재판정 | +| 24 | `P2-R9` → `P2-R9-GATE` → `P3-R13` → `P3-R13-GATE` → `P4-R5` → `P4-R5-GATE` → `P4-R6` → `P4-R6-GATE` → `P5-R5` → `P5-R5-GATE` → `P6-R2` → `P6-R2-GATE` → `P7-R6` → `P7-R6-GATE` | 2026-07-29 승인 후속 기능과 시리즈 상세 정합화 | 아니요 | `REV-044`~`REV-049` 소유 Goal에서 최소 구현·검증 후 다음 Phase Gate 수행 | +| 25 | `P3-R14` → `P3-R14-GATE` → `P5-R6` → `P5-R6-GATE` → `P7-R7` → `P7-R7-GATE` | 2026-07-29 UTC 날짜 계약 확정 | 아니요 | `REV-050`~`REV-051` 소유 Goal에서 6개 operation만 최소 정합화한 뒤 통합 재판정 | +| 26 | `P2-R10` → `P2-R10-GATE` → `P3-R15` → `P3-R15-GATE` → `P3-R16` → `P3-R16-GATE` → `P4-R7` → `P4-R7-GATE` → `P5-R7` → `P5-R7-GATE` → `P5-R8` → `P5-R8-GATE` → `P6-R3` → `P6-R3-GATE` → `P6-R4` → `P6-R4-GATE` → `P7-R8` → `P7-R8-GATE` | 2026-07-29 6차 Phase별 정적 리뷰와 FanTalk 답변 수정 계약 확정 | 아니요 | `REV-052`~`REV-059` 소유 HTTP 경계·신규 답변 수정만 최소 보완한 뒤 통합 재판정 | +| 27 | `P2-R11` → `P2-R11-GATE` → `P3-R17` → `P3-R17-GATE` → `P4-R8` → `P4-R8-GATE` → `P5-R9` → `P5-R9-GATE` → `P7-R9` → `P7-R9-GATE` | 2026-07-29 7차 Phase별 정적 리뷰 | 아니요 | `REV-060`~`REV-064`의 exact multipart part와 문서 상태만 최소 보완한 뒤 통합 재판정 | +| 28 | `P2-R12` → `P2-R12-GATE` → `P3-R18` → `P3-R18-GATE` → `P4-R9` → `P4-R9-GATE` → `P4-R10` → `P4-R10-GATE` → `P5-R10` → `P5-R10-GATE` → `P6-R5` → `P6-R5-GATE` → `P7-R10` → `P7-R10-GATE` | 2026-07-29 8차 Phase별 정적 리뷰 | 아니요 | `REV-065`~`REV-071` 소유 전체 multipart part·장르 ID·FanTalk 문서·상태만 최소 보완한 뒤 통합 재판정 | +| 29 | `P3-R19` → `P3-R19-GATE` → `P7-R11` → `P7-R11-GATE` | 2026-07-29 9차 Phase별 정적 리뷰 | 아니요 | `REV-072` preview 검증을 Phase 3에서 복구한 뒤 37개 operation 상태를 통합 재판정 | ## 변경 금지·중단 규칙 @@ -2839,6 +6219,215 @@ v2 관리자 API를 제공한다. 기존 기록을 삭제하거나 덮어쓰지 않고 Goal 실행 결과를 차수별로 누적한다. +### Phase 1~7 10차 정적 리뷰 완료 — 2026-07-30 + +- 상태: 판정 완료, `구현 완료` 유지 +- 무엇을: PRD·plan·OpenAPI 37개 operation과 Phase 1~7의 현재 production/test 소스를 Phase별로 다시 대조했다. +- 왜: 기존 컴파일·테스트 통과 기록과 별개로 `REV-072` 처리 뒤 계약·소유권·부작용 경계와 최종 문서 상태가 유지되는지 + 확인하기 위해서다. +- 어떻게: security/CORS/target resolver, Character, AudioContent·댓글, Series, Community·댓글, FanTalk의 + controller/facade/service/repository/test를 `rg`·`sed`·`jq`로 정적 검토했다. 사용자 지시에 따라 Gradle 컴파일·테스트는 + 실행하지 않았다. +- Phase별 결과: Phase 1~6은 신규 확정 finding이 없고, Phase 7도 추가 통합 보완이 필요하지 않다. 기존 `REV-001`~`REV-072` + 72건은 모두 `처리 완료` 상태다. +- 정적 검증: OpenAPI JSON과 내부 `$ref`, operation 37개·고유 operationId 37개·`implemented` 37개, + controller mapping Character 5 + AudioContent 10 + Series 10 + Community 8 + FanTalk 4 = 37을 확인했다. +- 계획 전환: 신규 회귀 수정 Task/Goal/Gate 없음. Phase별 완료 수 7/7, 18/18, 29/29, 16/16, 16/16, 10/10, + 13/13과 `구현 완료` 상태를 유지한다. + +### `P3-R19` / `P3-R19-GATE` / `P7-R11` / `P7-R11-GATE` 완료 — 2026-07-30 + +- 상태: 완료, 최종 `구현 완료` 재판정 +- 무엇을: v2 오디오 생성의 preview 쌍·형식·최소 15초 검증을 legacy creator 생성과 같은 공유 parsed request overload로 + 복구하고, Phase 3·7 리뷰와 문서 상태를 동기화했다. +- 왜: `REV-072`처럼 v2 생성 경로가 문자열 request overload의 `validatePreviewTime`을 우회하면 잘못된 preview 입력이 + DB/S3/event 경계로 진행될 수 있기 때문이다. +- 어떻게: `AiCharacterAdminAudioContentCreateTest`에 한쪽만 입력·형식 오류·15초 미만 KO/EN/JA actual endpoint 테스트와 정상 + preview metadata 검증을 추가했고, `AudioContentService.createAudioContent(CreateAudioContentRequest, ...)`에 검증 호출을 이동했다. +- RED: production 변경 전 invalid preview actual endpoint 9건이 400 기대 대비 200/부작용 경로로 실패했다. 테스트 JSON 조립 오류 + 수정 전에는 400 공통 `invalid_request`가 먼저 발생해 테스트를 바로잡은 뒤 GREEN을 재확인했다. +- 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.LegacyCreatorAdminAudioContentCharacterizationTest` + → `BUILD SUCCESSFUL in 39s`; `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*' --tests 'kr.co.vividnext.sodalive.content.*AudioContent*Test'` + → `BUILD SUCCESSFUL in 1m 22s`; `./gradlew ktlintCheck` → `BUILD SUCCESSFUL in 32s`; `jq empty docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` + → 출력 없음; OpenAPI implemented count → `37`; controller mapping count → 파일별 4/5/10/9/8/1 합계 37; + `git diff --check` → 출력 없음. +- 결과: `REV-072` 처리 완료. Phase 3은 29/29 완료, Phase 7은 13/13 완료이며 미처리 finding·차단 사항은 0건이다. + +### Phase 1~7 9차 정적 리뷰 완료 — 2026-07-29 + +- 상태: 판정 완료, `P3-R19` 후속 구현 대기 +- 무엇을: PRD·plan·OpenAPI 37개 operation과 Phase 1~7의 controller/facade/service/repository/test 소스를 + 대조해 Phase별로 리뷰 결과를 기록했다. +- 왜: 컴파일·테스트 통과와 별개로 신규 v2 오디오 생성의 preview 시간 검증이 기존 creator 생성과 동일한지 확인하고, + 확정된 회귀만 기존 완료 이력을 보존한 신규 Goal로 전환하기 위해서다. +- 어떻게: `AiCharacterAdminAudioContentFacade.create`의 호출 대상과 `AudioContentService` 두 overload의 검증 위치, + v2 actual endpoint 테스트, OpenAPI operation/status를 `rg`·`sed`·`jq`로 정적으로 대조했다. 사용자 지시에 따라 + Gradle·컴파일·테스트는 실행하지 않았다. +- 결과: 문자열 request overload에만 preview 검증이 있고 v2가 호출하는 parsed request overload에는 검증이 없는 + `REV-072`를 High로 확정했다. Phase 1·2·4·5·6은 신규 finding이 없으며 Phase 7은 `P3-R19-GATE` 뒤 통합 재판정이 + 필요하다. +- 계획 전환: `Task 3.29` / `P3-R19` / `P3-R19-GATE`, 이어서 `Task 7.13` / `P7-R11` / + `P7-R11-GATE`. +- 정적 검증: OpenAPI JSON 문법 정상, operation 37개·고유 operationId 37개·`implemented` 37개를 확인했다. +- 다음 Goal: `P3-R19`. + +### Phase 1~7 8차 정적 리뷰 완료 — 2026-07-29 + +- 상태: 판정 완료, 후속 구현 대기 +- 무엇을: PRD·plan·OpenAPI 37개 operation과 Phase 1~7의 controller/facade/repository/test 소스를 대조해 + `REV-065`~`REV-071`을 확정하고 Phase별 신규 Task/Gate로 전환했다. +- 왜: 기존 완료 기록을 되돌리지 않으면서 실제 구현과 multipart/장르/FanTalk 문서/상태 계약의 잔여 불일치를 + 이어서 수정할 수 있는 goal 단위로 남기기 위해서다. +- 어떻게: `rg`·`jq`·`nl`, Spring Web 5.3.29 및 기존 compile output의 `javap` 정적 증거를 사용했다. 사용자 + 지시에 따라 컴파일과 테스트는 실행하지 않았다. +- 검증: OpenAPI operation 37개·고유 ID 37개·`implemented` 37개, controller mapping 37개, Phase Task 수 + 7/18/28/16/16/10/12, 신규 Task 정의 각 1개, Markdown code fence 짝을 확인했다. `./gradlew tasks --all`은 + task 목록만 조회해 `BUILD SUCCESSFUL in 1s`, `git diff --check`는 출력 없이 성공했다. +- 다음 Goal: `P2-R12`. + +### `P3-R15` / `P3-R15-GATE` 완료 — 2026-07-29 + +- 상태: 완료 +- 무엇을: 오디오 댓글·답글 목록 두 GET이 `page`, `size` 전체 또는 부분 생략 시 OpenAPI 기본값 `0`, `20`을 적용하도록 복구했다. +- 왜: `REV-052`가 controller의 필수 binding과 facade의 정확한 query-name 집합 검증이 optional pagination 계약을 함께 막는다고 확정했기 때문이다. +- 어떻게: controller `@RequestParam`에 기본값을 지정하고 facade는 `page`, `size`의 부분집합만 허용하면서 미지 query·음수 page·1 미만 size는 기존 400 경계를 유지했다. actual endpoint 테스트는 댓글·답글 각각의 전체 생략, `page`만, `size`만 요청을 20/1 item 경계로 확인했고 기존 UTC·오류 회귀를 함께 실행했다. +- 검증: RED 2건 후 focused 9건 `BUILD SUCCESSFUL in 40s`, content/common error 영향 범위 `BUILD SUCCESSFUL in 2m 58s`, `ktlintCheck` `BUILD SUCCESSFUL in 37s`. +- 전체 회귀: `./gradlew test`는 변경이 v2 오디오 댓글 controller/facade와 focused actual endpoint에 한정되고 영향 범위 회귀가 이를 포함하므로 생략했다. +- 다음 Goal: `P3-R16`. + +### `P3-R16` / `P3-R16-GATE` 완료 — 2026-07-29 + +- 상태: 완료 +- 무엇을: AudioContent POST·PUT multipart `request` part를 `application/json` 호환 값으로만 제한했다. +- 왜: `REV-056`가 `@RequestPart String` binding이 text/plain과 Content-Type 누락을 수용해 OpenAPI 415 계약을 위반한다고 확정했기 때문이다. +- 어떻게: Character `P2-R10`과 같은 multipart header 검사와 `HttpMediaTypeNotSupportedException`을 controller 경계에만 적용했다. POST·PUT actual endpoint는 KO/EN/JA의 text/plain·누락 media type 415 `ApiResponse.error`, `Accept: application/json`, DB/S3/event 무변경을 검증했고, 기존 JSON strict parse·필수 part 400·UTC/file/series 회귀는 JSON fixture로 유지했다. +- 검증: RED 13건 뒤 focused 83건 `BUILD SUCCESSFUL in 56s`, content/common error 영향 범위 `BUILD SUCCESSFUL in 1m 55s`, `ktlintCheck` `BUILD SUCCESSFUL in 16s`, OpenAPI encoding 정적 대조과 `git diff --check` 출력 없음을 확인했다. +- 전체 회귀: `./gradlew test`는 controller와 실제 AudioContent endpoint test에 한정된 변경을 content/common 영향 범위 회귀가 포함하므로 실행하지 않았다. + +### `P4-R7` / `P4-R7-GATE` 완료 — 2026-07-29 + +- 상태: 완료 +- 무엇을: Series POST·PUT multipart `request` part를 `application/json` 호환 값으로만 제한했다. +- 왜: `REV-057`가 `@RequestPart String` binding이 `text/plain`과 Content-Type 누락을 수용해 OpenAPI 415 계약을 위반한다고 확정했기 때문이다. +- 어떻게: Character·AudioContent와 같은 multipart header 검사와 `HttpMediaTypeNotSupportedException`을 Series controller 경계에만 적용했다. POST·PUT actual endpoint는 KO/EN/JA의 `text/plain`·누락 media type 415 `ApiResponse.error`, `Accept: application/json`, S3/DB/event 무변경을 검증했고, 필수 `request` part 누락 400 및 기존 JSON strict parse·image/genre/owner 회귀는 유지했다. +- 검증: RED 12건 후 focused 36건 `BUILD SUCCESSFUL in 43s`, series/common error 영향 범위 `BUILD SUCCESSFUL in 1m 4s`, `ktlintCheck` `BUILD SUCCESSFUL in 20s`, OpenAPI Series create/update encoding 정적 대조와 `git diff --check` 출력 없음을 확인했다. +- 전체 회귀: `./gradlew test`는 v2 Series controller와 실제 Series endpoint test에 한정된 변경이고 series/common error 영향 범위 회귀가 이를 포함하므로 실행하지 않았다. + +### FanTalk 답변 수정 계약·구현 계획 보완 — 2026-07-29 + +- 상태: 계약 확정, 구현 대기 +- 무엇을: 레거시 `PUT /explorer/profile/cheers`의 FanTalk 답변 수정 계약을 신규 관리자 경계의 + `PUT /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}`로 이관했다. +- 왜: 기존 V2 관리자 FanTalk에는 목록·답변 작성·팬 원글 삭제만 있고, 선택한 AI 캐릭터가 작성한 답변의 내용·활성 상태를 + 수정할 operation이 없기 때문이다. +- 어떻게: `cheersId`만 path `replyId`로 이동하고 optional/nullable `content`, `isActive`, 동시 입력, 빈 객체 no-op, + 비활성 reply 재활성화와 레거시 `CreatorChannelFanTalkResponse` 성공 `data`를 유지했다. target AI가 작성하고 path의 + 활성 root에 직접 연결된 reply만 허용하는 관리자 ownership 경계를 추가했다. +- 결과: PRD, OpenAPI 2.3.0, 계약 설명, Phase 6 `Task 6.9` / `P6-R4`·Gate와 Phase 7 `P7-R8` 종결 조건을 + 동기화했다. 전체 계약은 37개이며 기존 36개는 `implemented`, 신규 답변 수정 1개는 `planned`다. +- 검증: 문서와 OpenAPI만 변경했다. JSON 문법·내부 `$ref`·operationId·상태 집계와 diff를 정적으로 검증했고 + 문서 절차의 `./gradlew tasks --all`만 `BUILD SUCCESSFUL in 742ms`로 확인했다. 사용자 지시에 따라 컴파일·테스트· + lint는 실행하지 않았다. +- 다음 Goal: 기존 실행 순서의 `P2-R10`; FanTalk 기능 순서는 `P6-R3` → `P6-R4`. + +### Phase 1~7 6차 정적 리뷰 완료 — 2026-07-29 + +- 상태: 후속 보완 Task 등록 완료, 구현 대기 +- 무엇을: PRD, OpenAPI 36개 operation, controller/facade와 관련 계약 테스트를 현재 working tree 기준으로 + Phase별 정적 대조했다. +- Phase 1 결과: 공통 ADMIN 이중 인가, target resolver, 오류/CORS 경계에서 신규 finding 없음. +- Phase 2 결과: Character 생성·수정의 `request` part가 OpenAPI의 `application/json` encoding과 달리 + `@RequestPart String`으로 media type을 강제하지 않는 `REV-055`를 확정했다. `Task 2.16` / `P2-R10`으로 전환했다. +- Phase 3 결과: 오디오 댓글·답글 목록이 OpenAPI optional `page`/`size`와 달리 두 query를 필수로 요구하고, + facade도 실제 query 이름을 정확히 두 개 요구하는 `REV-052`를 확정했다. `Task 3.25` / `P3-R15`로 전환했다. + 생성·수정 `request` part의 같은 불일치 `REV-056`은 `Task 3.26` / `P3-R16`으로 전환했다. +- Phase 4 결과: Series 생성·수정 `request` part의 같은 불일치 `REV-057`을 `Task 4.13` / `P4-R7`로 전환했다. +- Phase 5 결과: 커뮤니티 댓글 작성·수정 mapping에 JSON `consumes`가 없어 OpenAPI 415 계약을 보장하지 못하는 + `REV-053`을 `Task 5.13` / `P5-R7`로 전환했다. 게시글 생성·수정 `request` part의 같은 불일치 `REV-058`은 + `Task 5.14` / `P5-R8`로 전환했다. +- Phase 6 결과: FanTalk 답변 작성 mapping에 JSON `consumes`가 없어 OpenAPI 415 계약을 보장하지 못하는 + `REV-054`를 확정했다. `Task 6.8` / `P6-R3`으로 전환했다. +- Phase 7 결과: 일곱 소유 Phase Gate 뒤 36개 operation을 재판정하는 `Task 7.10` / `P7-R8`을 추가했다. +- 검증: OpenAPI JSON 문법, 36개 operationId 고유성, requestBody media type, 공통 pagination parameter, + multipart encoding, controller mapping/`consumes`/`@RequestPart`와 dependency·DDL 변경 범위를 정적으로 + 대조했다. 사용자 지시에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다. +- 다음 Goal: `P2-R10`. + +### `P5-R6` / `P5-R6-GATE` 완료 — 2026-07-29 + +- 상태: 완료 +- 무엇을: v2 관리자 커뮤니티 댓글·답글 GET에서 필수 `timezone` query를 제거하고, 기존 `date` 필드만 `createdAt` 기반 ISO-8601 UTC(`Z`)로 정합화했다. +- 왜: `REV-051`이 최신 OpenAPI의 page/size-only query 및 UTC date 계약과 실제 timezone 표시 문자열의 불일치를 확정했기 때문이다. +- 어떻게: controller/facade의 timezone 입력·검증만 제거하고 legacy service/repository 계약은 유지했다. 기존 `toUtcIso()`로 owner-scoped 조회 결과를 재매핑하고, actual endpoint 테스트로 root/reply UTC exact JSON 및 추가 timezone query 무영향을 고정했다. +- 결과: RED 2건 실패 후 focused GREEN, community/common·legacy 영향 범위 회귀, `ktlintCheck`, OpenAPI 36개 `implemented`/0개 `alignment-required`, `git diff --check` 검증을 통과했다. 전체 `./gradlew test`는 v2 커뮤니티 controller/facade/test와 해당 legacy service 경계에 변경을 한정했고 직접 영향 회귀가 이를 포함하므로 실행하지 않았다. +- 남은 항목: 없음. 후속 `P7-R7` 통합 재판정도 완료했다. + +### `P7-R7` / `P7-R7-GATE` 완료 — 2026-07-29 + +- 상태: 완료 +- 무엇을: UTC 날짜 계약 변경 뒤 36개 관리자 operation의 OpenAPI 상태, controller mapping, timezone parameter/schema 제거, Phase 3·5 Gate 증거를 통합 재판정했다. +- 왜: `REV-050`과 `REV-051` 처리 뒤 최신 계약 기준으로 Phase 7 완료 상태를 복구해야 했기 때문이다. +- 어떻게: OpenAPI `jq` 집계, controller mapping 정적 집계, 오디오·커뮤니티 focused 재실행 및 각 Gate의 영향 범위 회귀·lint·diff 기록을 대조하고 문서 상태를 동기화했다. +- 결과: 36개 operation 모두 `implemented`, `alignment-required` 0개, query `timezone` parameter 0개, `components.parameters.Timezone` 0개, controller mapping 36개를 확인했다. 오디오 focused는 `BUILD SUCCESSFUL in 52s`, 커뮤니티 댓글 focused `--rerun-tasks`는 `BUILD SUCCESSFUL in 4m 33s`였다. +- 남은 항목: 없음. + +### `P5-R8` / `P5-R8-GATE` 완료 — 2026-07-29 + +- 상태: 완료 +- 무엇을: 커뮤니티 게시글 생성·수정 multipart의 `request` part를 `application/json` 호환 media type으로 제한했다. +- 왜: `REV-058`이 OpenAPI multipart encoding과 `@RequestPart String` permissive binding의 불일치를 확정했기 때문이다. +- 어떻게: create/update actual endpoint에 KO/EN/JA `text/plain` 및 Content-Type 누락 415 matrix를 먼저 추가해 RED를 확인한 뒤, controller에서 part header만 검사하고 기존 facade strict reader와 domain 로직은 유지했다. +- 결과: RED focused는 12개 invocation이 415 기대 실패로 `BUILD FAILED in 56s`, GREEN focused는 `BUILD SUCCESSFUL in 59s`, community/common 영향 범위 회귀는 `BUILD SUCCESSFUL in 55s`였다. 전체 `./gradlew test`는 변경 범위가 v2 community 게시글 multipart request part 경계와 공통 오류 계약에 한정되고 focused/영향 범위 회귀가 이를 포함하므로 실행하지 않았다. +- 남은 항목: 없음. 다음 Goal은 `P6-R3`이다. + +### `P5-R9` / `P5-R9-GATE` 완료 — 2026-07-29 + +- 상태: 완료 +- 무엇을: Community post 생성·수정 multipart의 실제 part 이름을 operation별 OpenAPI 허용 집합으로 제한했다. +- 왜: `REV-063`이 생성과 수정의 허용 part 집합이 다른데 controller가 전체 part 이름을 검사하지 않아 수정 `audioFile` 등 미정의 part를 무시하고 mutation을 진행할 수 있다고 확정했기 때문이다. +- 어떻게: controller 경계에서 생성 `{audioFile, postImage, request}`, 수정 `{postImage, request}` allow-list를 적용하고 초과 part는 400 `common.error.invalid_request`로 거부했다. actual endpoint 테스트로 생성 `unexpected`, 수정 `unexpected`·`audioFile`의 DB/S3 no-side-effect를 고정했다. +- 검증: RED 3건 `BUILD FAILED in 3m 23s`, focused GREEN `BUILD SUCCESSFUL in 2m 30s`, community/common 영향 범위 `BUILD SUCCESSFUL in 1m 44s`, `ktlintCheck` `BUILD SUCCESSFUL in 55s`, `git diff --check` 출력 없음을 확인했다. +- 전체 회귀: `./gradlew test`는 변경이 v2 Community post controller와 실제 Community endpoint test에 한정되고 community/common 영향 범위 회귀가 이를 포함하므로 실행하지 않았다. +- 다음 Goal: `P7-R9`. + +### `P6-R3` / `P6-R3-GATE` 완료 — 2026-07-29 + +- 상태: 완료 +- 무엇을: FanTalk 답변 작성 POST를 `application/json` request만 받도록 제한했다. +- 왜: `REV-054`가 OpenAPI requestBody media type과 controller mapping의 불일치를 확정했기 때문이다. +- 어떻게: `text/plain` actual endpoint 415 matrix를 먼저 추가해 RED를 확인한 뒤, reply POST mapping에 JSON `consumes`만 추가했다. +- 결과: RED focused는 3개 invocation이 415 기대 실패로 `BUILD FAILED in 33s`, GREEN focused는 `BUILD SUCCESSFUL in 41s`, FanTalk/common 영향 범위 회귀는 `BUILD SUCCESSFUL in 47s`였다. 전체 `./gradlew test`는 변경 범위가 v2 FanTalk reply POST media type 경계와 공통 오류 계약에 한정되고 focused/영향 범위 회귀가 이를 포함하므로 실행하지 않았다. +- 남은 항목: 없음. 다음 Goal은 `P6-R4`다. + +### `P6-R4` / `P6-R4-GATE` 완료 — 2026-07-29 + +- 상태: 완료 +- 무엇을: target AI가 작성하고 path의 활성 root에 직접 연결된 FanTalk reply만 수정하는 관리자 PUT endpoint를 추가했다. +- 왜: `REV-059`가 레거시 `PUT /explorer/profile/cheers`의 optional/nullable `content`, `isActive`, no-op, 재활성화와 `CreatorChannelFanTalkResponse` 성공 `data` 계약을 V2 관리자 경계에 이관해야 한다고 확정했기 때문이다. +- 어떻게: `PUT /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}`에 JSON `consumes`와 strict body reader를 추가하고, repository에서 reply ID·target creator/writer·active root·direct-parent를 한 query로 검증했다. facade는 non-null `content`와 `isActive`만 반영하고 `languageCode`와 event는 변경하지 않는다. +- 결과: RED focused는 15건이 미구현 route 404로 `BUILD FAILED in 49s`, GREEN 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`는 출력이 없었다. +- 전체 회귀: `./gradlew test`는 변경 범위가 v2 FanTalk reply update와 FanTalk/common/legacy 영향 범위에 포함되므로 실행하지 않았다. +- 남은 항목: 없음. 다음 Goal은 `P7-R8`이다. + +### `P7-R8` / `P7-R8-GATE` 완료 — 2026-07-29 + +- 상태: 완료 +- 무엇을: `REV-052`~`REV-059` 처리 뒤 37개 관리자 operation의 HTTP 경계와 FanTalk 답변 수정 계약을 최종 통합 재판정했다. +- 왜: Phase 2~6의 여덟 소유 Gate가 모두 완료되어 OpenAPI, controller mapping, 회귀, lint, diff와 문서 상태를 하나의 최종 Gate에서 대조해야 했기 때문이다. +- 어떻게: OpenAPI operationId/status, controller mapping 수, 미처리 finding, dependency/DDL 변경 여부를 정적으로 확인하고, 영향 범위 focused 회귀와 전체 `./gradlew test`, `ktlintCheck`, `git diff --check`를 fresh 실행했다. +- 결과: OpenAPI는 operationId 37개/unique 37개/status `implemented` 37개였고 controller mapping은 37개였다. `REV-052`~`REV-059` 미처리 항목과 선행 Gate 미체크 항목은 없었다. 영향 범위 focused 회귀는 `BUILD SUCCESSFUL in 1m 26s`, 전체 `./gradlew test`는 `BUILD SUCCESSFUL in 8m 8s`, `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 1s`, `git diff --check`는 출력이 없었다. 신규 dependency·DDL 변경도 없다. +- 남은 항목: 없음. + +### `P7-R9` / `P7-R9-GATE` 완료 — 2026-07-29 + +- 상태: 완료 +- 무엇을: `REV-060`~`REV-064` 처리 뒤 8개 multipart operation의 part 이름 계약과 37개 operation 구현 상태 문서를 통합 재판정했다. +- 왜: Phase 2~5 Gate에서 operation별 multipart allow-list 보완이 끝났고, `api-contract.md`의 route/완료/예정 수와 FanTalk 답변 수정 상태가 과거 36개 구현/1개 planned로 남아 있었기 때문이다. +- 어떻게: 8개 multipart schema의 `additionalProperties: false`, required/props 집계와 Phase 2~5 Gate 증거를 대조하고, OpenAPI operation/status와 controller mapping을 새로 집계한 뒤 `api-contract.md` 상단 집계·endpoint 표·client 생성 설명을 37개 구현 완료로 동기화했다. +- 검증: OpenAPI는 operation 37개, 고유 operationId 37개, `implemented` 37개, `alignmentRequired` 0개, `planned` 0개였다. controller mapping은 Character 5 + AudioContent 10 + Series 10 + Community 8 + FanTalk 4로 총 37개였다. 8개 multipart schema는 모두 `additionalProperties=false`이고 허용 part 집합은 Character/Series `{image, request}`, AudioContent 생성 `{contentFile, coverImage, request}`·수정 `{coverImage, request}`, Community 생성 `{audioFile, postImage, request}`·수정 `{postImage, request}`로 확인했다. `ktlintCheck`는 `P5-R9-GATE`의 `BUILD SUCCESSFUL in 55s` 기록을 대조했고, 문서 수정 후 `git diff --check`를 재실행했다. +- 전체 회귀: production 동작은 `P2-R11-GATE`, `P3-R17-GATE`, `P4-R8-GATE`, `P5-R9-GATE`에서 focused와 영향 범위 회귀로 검증 완료했으므로 이 문서 동기화 Task에서는 실행하지 않았다. +- 남은 항목: 없음. + ### `P2-R1` 실행 준비 — 2026-07-27 - 상태: 대기 @@ -2958,7 +6547,7 @@ v2 관리자 API를 제공한다. ### Phase 2·3 4차 재리뷰 완료 — 2026-07-27 -- 상태: 리뷰 완료, 후속 보완 대기 +- 상태: 리뷰 완료, 후속 처리 요청(당시 판정) - 무엇을: `P2-R2`, `P3-R2`~`P3-R3` 반영분과 완료 체크리스트를 production 흐름·test method 단위로 다시 대조했다. - 왜: 회귀 통과만으로 `REV-009`~`REV-011`의 선언된 실패·ownership 증거 전체가 충족됐다고 판정할 수 없기 때문이다. - 어떻게: 변경된 test 5개, character/content facade와 legacy service failure order를 확인하고 targeted 전체를 `--rerun-tasks`로 실행했다. @@ -3081,11 +6670,467 @@ v2 관리자 API를 제공한다. 않았다. `P23-CONTRACT-1`의 일회성 생성·컴파일 검증 기록은 유지한다. - 다음 행동: production 변경 없이 문서 보완만 완료했다. 구현 시작 Goal은 `P23-CONTRACT-2`다. +### `P23-CONTRACT-2` 완료 — 2026-07-28 + +- 상태: 완료 +- 무엇을: 캐릭터 목록·검색은 레거시 `totalCount/content`와 list item 필드를, 상세는 `id/characterUUID`와 전체 nested 필드를 + 반환하도록 기존 레거시 DTO mapper를 재사용했다. 생성은 `image`를 필수로 받고 생성·수정 성공은 exact `data: null`을 반환하며, + `isActive=false`와 다른 optional JSON field의 혼합 요청은 받아 비활성화만 반영하도록 했다. +- 왜: 현재 v2 전용 축약 DTO, optional 생성 이미지, mutation 상세 응답과 단독 soft-delete 제한이 + `api-contract.openapi.json`의 확정 레거시 runtime 계약과 달랐기 때문이다. +- 어떻게: 지정된 두 actual endpoint 테스트에 list/detail exact field, 전체 create/update request, 필수 image, null mutation envelope, + mixed soft-delete 미반영 assertion을 RED로 추가한 뒤 controller/DTO/facade/mapper만 최소 변경했다. +- 결과: RED는 59건 중 의도한 9건 실패였고, GREEN focused 59건과 character/authorization/error 회귀 170건이 모두 통과했다. + `./gradlew ktlintCheck`도 `BUILD SUCCESSFUL in 48s`였다. +- 독립 리뷰 보완: Character list의 OpenAPI `size` minimum 1을 실제 pagination에 반영했다. 추가 RED는 focused 60건 중 1건 + 실패였고, 보완 후 focused 60건과 character/authorization/error 회귀 171건 및 `ktlintCheck`가 모두 통과했다. mixed + soft-delete는 미존재 `originalWorkId`까지 검증·반영 없이 무시함을 actual endpoint test로 강화했다. +- 남은 항목: `P23-CONTRACT-3`은 시작하지 않았다. Task 3.18과 Phase 4 이후 범위는 변경하지 않았다. + +### `P23-CONTRACT-3` 완료 — 2026-07-28 + +- 상태: 완료 +- 무엇을: 테마·오디오 콘텐츠 5개 endpoint를 `id/theme/image`, `search_word`와 legacy 목록 item, 필수 `timezone`과 + `GetAudioContentDetailResponse`, 생성 `contentFile`·`CreateAudioContentRequest`·`data.contentId`, 수정 + `UpdateCreatorAdminContentRequest`·`data: null` 계약으로 정합화했다. +- 왜: 기존 v2 전용 `description`, `releaseDateUtc`, `audioSignedUrl`, `status`, `seriesIds`, `themeName`, `imageUrl` alias와 + 생성·수정 상세 응답이 `api-contract.openapi.json`의 확정 레거시 runtime 계약과 달랐기 때문이다. +- 어떻게: exact JSON·query·multipart RED를 먼저 실행한 뒤 기존 legacy DTO와 목록 service를 재사용하고, 상세 owner guard, + create/update service 위임, signed URL 만료 계산과 기존 series row 보존을 유지했다. +- 결과: RED는 focused 61건 중 21건이 의도대로 실패했다. GREEN focused 61건과 content·authorization·error 10개 suite + 206건이 failure/error/skipped 0으로 통과했고 `ktlintCheck`와 문서 변경 후 `./gradlew tasks --all`도 성공했다. +- 남은 항목: `P23-CONTRACT-GATE`. Phase 4 이후 범위는 변경하지 않았다. + +### `P4-T1` 완료 — 2026-07-28 + +- 상태: 완료 +- 무엇을: `LegacyCreatorAdminSeriesCharacterizationTest`로 기존 creator-admin series 생성·활성 목록·inactive 상세·혼합 + 수정/soft delete, 콘텐츠 부분 연결·무해한 해제·owner 전체 count·미연결 검색과 owner-less 순서 변경을 고정했다. +- 왜: Phase 4 v2가 재사용할 legacy 동작과 그대로 복제하면 안 되는 ownership·부분 성공·오류 status 경계를 production 변경 전에 + 분리해야 하기 때문이다. +- 어떻게: 실제 Spring/JPA/QueryDSL repository와 production service를 사용하고 S3 client와 event publisher만 격리한 focused + characterization을 첫 실행했으며, 이어 `ktlintCheck`를 실행했다. +- 결과: focused test는 `BUILD SUCCESSFUL in 47s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 19s`였다. production, DB schema, + dependency, security와 Task 3.17/3.18 코드는 변경하지 않았다. +- 오류 결정: legacy 입력 validation key는 신규 prefix에서 400으로 보존하고, missing/inactive/cross-owner 및 order/link 사전 + 검증 실패는 400 `common.error.invalid_request`, 예상하지 못한 오류는 500 `common.error.unknown`과 KO/EN/JA envelope로 + 고정했다. invalid ownership은 모든 DB/S3/event보다 먼저 실패해야 하며 legacy owner-less order path는 신규 v2에서 재사용하지 않는다. +- 남은 항목: `P4-T2` 시리즈 목록·상세 조회 구현. 전체 `./gradlew test`는 production 변경이 없는 test-only baseline이고 실제 + service/repository를 통과한 focused 검증으로 직접 범위를 확인했으므로 실행하지 않았다. +- 테스트 결과 확인: JUnit XML 기준 7건, failure/error/skipped 0건이며 재실행도 `BUILD SUCCESSFUL in 2s`였다. +- 문서 명령 유효성: 문서 갱신 후 `./gradlew tasks --all`을 실행해 `test`, `ktlintCheck`, `tasks` 존재와 + `BUILD SUCCESSFUL in 940ms`를 확인했다. +- 최종 fresh 검증: `./gradlew test --rerun-tasks --tests + kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.LegacyCreatorAdminSeriesCharacterizationTest`는 10개 task를 모두 실행해 + `BUILD SUCCESSFUL in 4m 19s`, JUnit 7건 failure/error/skipped 0건이었다. `./gradlew ktlintCheck --rerun-tasks`는 7개 task를 + 모두 실행해 `BUILD SUCCESSFUL in 25s`였다. 컴파일의 기존 deprecated API warning 외 신규 경고·실패는 없었다. + +### `P4-T2` 완료 — 2026-07-28 + +- 상태: 완료 +- 무엇을: `/api/v2/admin/ai-characters/{characterId}/series` 목록과 + `/api/v2/admin/ai-characters/{characterId}/series/{seriesId}` 상세를 추가했다. 목록은 target owner의 활성 시리즈만 legacy + `totalCount/items`와 전체 item 필드로 반환하고, 상세는 활성 owner 리소스만 legacy 문자열 필드로 반환한다. +- 왜: AI 캐릭터 creator member로 로그인하지 않고도 관리자가 target resolver를 통해 해당 캐릭터의 시리즈를 읽되, legacy의 + inactive 상세 허용과 cross-owner 접근을 신규 v2 경계로 가져오면 안 되기 때문이다. +- 어떻게: `AiCharacterAdminTargetResolver`를 먼저 실행하고 기존 creator series 목록 서비스와 legacy DTO를 재사용했다. 상세는 + owner 조회 뒤 `isActive`를 확인하고 기존 entity의 detail mapping을 사용했으며, OpenAPI 최소값대로 `page>=0`, `size>=1`만 + 허용했다. +- 결과: production 전 RED 5건은 모두 미구현 404로 실패했다. 구현 후 focused 5건과 Task 4.1 포함 series 12건이 모두 + failure/error/skipped 0으로 통과했고 `ktlintCheck`도 성공했다. mutation, 콘텐츠 연결·검색과 순서 변경은 구현하지 않았다. +- 남은 항목: `P4-T3` 시리즈 생성·수정·soft delete 구현. 전체 `./gradlew test`는 변경 범위가 신규 series 조회 slice에 한정되고 + 실제 Spring MVC/JPA 경계를 통과한 focused·legacy series 12건으로 직접 범위를 확인했으므로 실행하지 않았다. +- 문서 명령 유효성: Task 4.2 체크박스와 Progress 갱신 후 `./gradlew tasks --all`을 실행해 `test`, `ktlintCheck`, `tasks`가 + 존재하고 `BUILD SUCCESSFUL in 930ms`임을 확인했다. +- 독립 리뷰 보완: Phase 4 결정에 맞춰 inactive AI character target의 목록·상세를 400 `common.error.invalid_request`로 + 차단했다. 보완 RED 2건은 200으로 실패했고 공통 active target guard 적용 후 focused 7건, series 전체 14건과 + `ktlintCheck`가 모두 통과했다. list item은 동일 DTO serializer를 공유하고 요일 배열 순서는 OpenAPI/legacy에서 고정하지 않으므로 + item별 field set 반복과 임의 순서 고정은 추가하지 않았으며, pagination은 최소값·다음 page·잘못된 하한을 이미 직접 검증한다. + +### `P4-T3` 완료 — 2026-07-28 + +- 상태: 완료 +- 무엇을: target owner의 시리즈 POST 생성, multipart PUT 수정과 DELETE soft delete를 추가하고 성공 `data: null`을 유지했다. +- 왜: legacy creator mutation을 재사용하면서도 inactive target/series와 missing·cross-owner 요청은 mutation 전에 같은 400으로 + 차단해야 했기 때문이다. +- 어떻게: 활성 target과 활성 owned series를 먼저 확인하고, 양수 `genreId`는 active genre 존재를 사전 확인한 뒤 + `CreateSeriesRequest`, `ModifySeriesRequest`로 `CreatorAdminContentSeriesService.createSeries`/`modifySeries`를 호출했다. + keyword·S3·event·entity 갱신은 복제하지 않았다. +- 결과: RED 12건은 미구현 405로 실패했고, GREEN focused 12건과 fresh series 회귀 26건이 통과했다. 독립 리뷰에서 누락 genre + 사전 검증을 보완해 focused 14건과 series 회귀 28건 및 `ktlintCheck`, `git diff --check`가 통과했다. invalid mutation의 + DB/S3/event 0건과 legacy validation key 400을 실제 endpoint에서 확인했다. +- 남은 항목: `P4-T4` 시리즈 콘텐츠 조회·검색·연결·해제. Task 4.4 이후 범위는 변경하지 않았다. + +### `P4-T4` 완료 — 2026-07-28 + +- 상태: 완료 +- 무엇을: `/series/{seriesId}/contents` 연결 목록, `/contents/search` 미연결 검색, JSON `contentIdList` 연결과 JSON `contentId` + 해제를 추가했다. 조회는 legacy DTO/owner 전체 link `totalCount`를 유지하고 mutation은 `data: null`을 반환한다. +- 왜: legacy 연결은 foreign/missing ID를 건너뛰고 해제 없는 link를 no-op 처리하므로, 신규 v2 경계에서 target·active owned series와 + 모든 content/link 상태를 mutation 전에 검증해 부분 반영을 막아야 했기 때문이다. +- 어떻게: legacy 목록/검색/연결/해제 service를 재사용하되, v2 repository에서 owner의 processed 또는 reserved eligible content를 + 확인하고 이미 연결된 content, duplicate ID, 없는 link를 `common.error.invalid_request`로 차단했다. 빈 목록은 legacy와 같이 + `creator.admin.series.no_content_added`를 유지했다. +- 결과: production 전 RED 7건은 미구현 route의 404/405로 실패했고, 구현 후 focused 7건, series 회귀, Phase 3 content 회귀와 + `ktlintCheck`가 모두 통과했다. Task 4.5 순서 변경과 Task 4.6 보안 matrix는 변경하지 않았다. + +### `P4-T6` 완료 — 2026-07-28 + +- 상태: 완료 +- 무엇을: Phase 4 series endpoint의 ADMIN 이중 인가/stale claim matrix, target·series·content·pagination·order·binding 오류 envelope, + cross-owner mutation no-side-effect와 legacy series 회귀를 고정했다. +- 왜: Phase 4 series API가 legacy creator endpoint를 재사용하더라도 신규 관리자 prefix에서는 owner-first validation, KO/EN/JA + `common.error.invalid_request` envelope와 legacy regression이 endpoint 단위로 증명되어야 하기 때문이다. +- 어떻게: `AiCharacterAdminAuthorizationTest`에 series list/detail/contents/search/link/unlink/order/create/update/delete endpoint matrix를 + 추가했고, `AiCharacterAdminSeriesContractTest`에 inactive target과 active target의 missing/inactive/cross-owner series 오류를 + 분리해 검증했다. Cross-owner PUT은 DB title, event publisher, S3 `putObject`가 변하지 않음을 단언한다. +- 결과: contract focused test는 `BUILD SUCCESSFUL in 3m 41s`, authorization focused test는 순차 재실행에서 + `BUILD SUCCESSFUL in 3m 3s`, Phase 4 series 회귀는 `BUILD SUCCESSFUL in 3m 23s`, `ktlintCheck`는 + `BUILD SUCCESSFUL in 25s`, `git diff --check`는 출력이 없었다. 병렬 Gradle 실행 중 한 authorization run은 unrelated + `DefaultHomeRecommendationQueryRepository` QueryDSL 참조 compile 오류로 실패했으나 동일 명령 순차 재실행은 통과했다. +- 남은 항목: `P4-GATE`. + +### `P4-GATE` 완료 — 2026-07-28 + +- 상태: 완료 +- 무엇을: Phase 4 series vertical slice의 조회·mutation·content link·order·보안/오류 계약과 legacy 회귀를 최종 판정했다. +- 왜: Phase 5 community 구현으로 넘어가기 전에 `P4-T1`~`P4-T6`의 완료 증거와 Gate 명령 성공을 문서와 실제 검증으로 맞춰야 하기 때문이다. +- 어떻게: Gate에 명시된 Phase 4 series 전체 focused 회귀와 `ktlintCheck`를 `--rerun-tasks`로 fresh 실행하고 `git diff --check`를 확인했다. +- 결과: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.*' --rerun-tasks`는 `BUILD SUCCESSFUL in 3m 21s`, `./gradlew ktlintCheck --rerun-tasks`는 `BUILD SUCCESSFUL in 24s`, `git diff --check`는 출력이 없었다. +- 남은 항목: `P5-T1` 커뮤니티 기존 parity 특성화 baseline. Phase 5 production 구현은 아직 시작하지 않았다. + +### `P5-T1` 완료 — 2026-07-28 + +- 상태: 완료 +- 무엇을: 기존 community 생성 validation, media upload, FCM/recent-news side effect, 최대 고정 3개와 soft delete fixed clearing을 + `LegacyCommunityPostCharacterizationTest`로 고정했다. +- 왜: 신규 v2 community 관리자 구현 전에 legacy parity와 신규 owner-first 오류 정책을 분리해 Phase 5 구현 기준을 흔들리지 않게 하기 위해서다. +- 어떻게: 기존 `CreatorCommunityService`를 mock dependency로 직접 실행하는 characterization test를 추가하고 production code는 변경하지 않았다. +- 결과: focused characterization은 `BUILD SUCCESSFUL in 2m 22s`, community package targeted test는 `BUILD SUCCESSFUL in 2m 22s`, + `ktlintCheck`는 unused import 2건 정리 후 `BUILD SUCCESSFUL in 21s`, `git diff --check`는 출력이 없었다. +- 남은 항목: `P5-T2` 관리자 게시글 목록 조회 구현. + +### `P6-T2` 완료 — 2026-07-28 + +- 상태: 완료 +- 무엇을: `/api/v2/admin/ai-characters/{characterId}/fan-talks`를 추가해 target owner의 활성 root FanTalk와 활성 creator + reply를 공개 v2 `CreatorChannelFanTalkTabResponse` 필드 형태로 반환했다. +- 왜: 공개 v2 조회는 viewer/block 조건을 적용하므로, 관리자 endpoint는 target resolver가 해석한 `creatorMember` 기준의 + 별도 query가 필요하다. +- 어떻게: root는 `createdAt desc, id desc`, reply는 `createdAt asc, id asc`으로 조회하고 page/size/hasNext/count를 + 전용 facade에서 조립했다. test는 차단 관계가 있는 관리자 fixture에서도 writer root가 포함되는지, 다른 target·inactive·fan + reply·inactive/nested reply가 제외되는지 확인했다. +- 결과: RED는 4건 모두 미매핑 404로 `BUILD FAILED in 1m 6s`였고, 구현 후 focused 재실행은 + `BUILD SUCCESSFUL in 1m 3s`였다. legacy characterization은 `BUILD SUCCESSFUL in 9s`, FanTalk package 회귀는 + `BUILD SUCCESSFUL in 51s`, 최종 `ktlintCheck`는 `BUILD SUCCESSFUL in 24s`였다. +- 남은 항목: `P6-T3` FanTalk root reply 저장 구현. + +### `P6-T3` 완료 — 2026-07-28 + +- 상태: 완료 +- 무엇을: `POST /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies`가 선택 target의 활성 root에만 creator reply를 저장하고 축약 응답을 반환하도록 구현했다. +- 왜: 관리자 principal이 아닌 target의 `creatorMember`를 writer/creator로 저장하고, 레거시와 같은 `CREATOR_CHEERS` 언어 감지를 유지해야 하기 때문이다. +- 어떻게: `AiCharacterAdminFanTalkFacade`의 write transaction 안에서 active target과 owner-scoped active root를 조회한 뒤 `CreatorCheers(languageCode = null)`를 저장하고 `LanguageDetectEvent`를 발행했다. `AiCharacterAdminFanTalkReplyCreateTest`는 response, parent/member/creator row, blank languageCode와 event payload를 실제 MVC/JPA 경계에서 검증했다. +- 검증 기록(RED): production 변경 전 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.AiCharacterAdminFanTalkReplyCreateTest`를 실행했다. 기대한 200 대신 미구현 endpoint 때문에 `AiCharacterAdminFanTalkReplyCreateTest.kt:98`에서 실패했고 `BUILD FAILED in 48s`였다. +- 검증 기록(GREEN): 같은 focused 명령을 다시 실행해 `BUILD SUCCESSFUL in 49s`를 확인했다. +- 검증 기록(legacy): `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.LegacyFanTalkReplyCharacterizationTest`를 실행해 `BUILD SUCCESSFUL in 8s`를 확인했다. +- 검증 기록(package): `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.*'`를 최종 실행해 `BUILD SUCCESSFUL in 11s`를 확인했다. +- 검증 기록(lint): `./gradlew ktlintCheck`를 실행해 `BUILD SUCCESSFUL in 32s`를 확인했다. +- 검증 기록(diff): `git diff --check`를 실행해 출력 없이 exit code 0을 확인했다. +- 전체 `./gradlew test`는 신규 관리자 FanTalk reply slice에 변경을 한정했고 focused, legacy characterization, FanTalk package 회귀가 직접 범위를 포함하므로 실행하지 않았다. +- 남은 항목: `P6-T4` target/root/ownership 거부 구현. + +### `P6-T4` 완료 — 2026-07-28 + +- 상태: 완료 +- 무엇을: `AiCharacterAdminFanTalkReplyOwnershipTest`로 cross-character root, nested parent, inactive root, missing FanTalk, + inactive target의 답변 거부를 KO/EN/JA로 고정했다. +- 왜: P6-T3의 owner-scoped active-root 조회와 target active guard가 reply 저장과 `LanguageDetectEvent` 발행보다 앞서는지 실제 + MVC/JPA 경계에서 증명하기 위해서다. +- 어떻게: 각 거부 요청에서 `ApiResponse.error` 400 `common.error.invalid_request` locale envelope, `CreatorCheers` row count + 무변경, reflection으로 교체한 실제 facade `ApplicationEventPublisher`의 무호출을 단언했다. +- TDD 예외/특성화: production 변경 전 새 ownership test의 첫 실행이 `BUILD SUCCESSFUL in 1m 1s`였고, 10 actionable tasks 중 + 3 executed, 7 up-to-date였다. 이는 요구한 거부 분기가 이미 P6-T3에 존재함을 확인한 결과이므로 production code를 변경하지 + 않았다. +- 검증 기록(focused): import 정리 후 `./gradlew test --tests + kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.AiCharacterAdminFanTalkReplyOwnershipTest`를 재실행해 + `BUILD SUCCESSFUL in 55s`, 10 actionable tasks 중 3 executed, 7 up-to-date를 확인했다. +- 검증 기록(reply create/legacy): `./gradlew test --tests + kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.AiCharacterAdminFanTalkReplyCreateTest --tests + kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.LegacyFanTalkReplyCharacterizationTest`는 + `BUILD SUCCESSFUL in 56s`, 10 actionable tasks 중 1 executed, 9 up-to-date였다. +- 검증 기록(package): `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.*'`는 + `BUILD SUCCESSFUL in 55s`, 10 actionable tasks 중 1 executed, 9 up-to-date였다. +- 검증 기록(lint): 첫 `./gradlew ktlintCheck`는 새 test의 unused import 1건으로 `BUILD FAILED in 16s`였고, 해당 import만 + 제거한 뒤 재실행은 `BUILD SUCCESSFUL in 26s`, 7 actionable tasks 중 2 executed, 5 up-to-date였다. +- 검증 기록(문서 명령): `./gradlew tasks --all`은 `BUILD SUCCESSFUL in 1s`, 1 actionable task executed였고, + `test`, `ktlintCheck`, `tasks`가 존재함을 확인했다. +- 검증 기록(diff): `git diff --check`는 출력 없이 종료했다. +- 전체 `./gradlew test`는 production 변경이 없고 focused, reply create, legacy characterization, FanTalk package 회귀가 직접 + 범위를 포함하므로 실행하지 않았다. +- 남은 항목: `P6-T5` FanTalk 보안·오류·회귀 검증. + +### Phase 1~7 정적 리뷰 완료 — 2026-07-28 + +- 상태: 리뷰 완료, 후속 처리 요청(당시 판정) +- 무엇을: PRD, plan-task, OpenAPI 23개 operation과 현재 production/test 구현을 Phase별로 정적 대조했다. +- 왜: 완료 기록과 실제 HTTP 경계·내부 책임·구현 현황 metadata가 같은 계약을 가리키는지 확인하고 확정 finding을 이어서 + 실행 가능한 Task로 전환하기 위해서다. +- 어떻게: controller/facade/repository/DTO와 관련 테스트의 호출·mapping·JSON parsing·pagination을 정적 추적하고, + `rg`, `jq`, `git diff` 기반으로 문서/operation/변경 범위를 대조했다. 사용자 요청에 따라 Gradle, 컴파일, 테스트는 + 실행하지 않았다. +- Phase 1 결과: 공통 target resolver, ADMIN 이중 인가, 오류/security 경계에서 신규 확정 finding 0건. +- Phase 2 결과: mutation 성공 응답이 `data: null`인데 사용하지 않는 facade response mapping 1건을 `REV-021`로 확정했다. +- Phase 3 결과: 호출되지 않는 관리자 content repository 확장과 전용 enum 1건을 `REV-022`로 확정했다. +- Phase 4 결과: OpenAPI에 없는 시리즈 DELETE route와 두 JSON body의 미지 필드 허용을 `REV-023`~`REV-024`로 확정했다. +- Phase 5 결과: multipart JSON parse 실패의 500 가능성·미지 필드 허용과 계약 밖 `size <= 50` 제한을 + `REV-025`~`REV-026`으로 확정했다. +- Phase 6 결과: 공개 v2와 다른 pagination 거부 정책과 reply 미지 필드 허용을 `REV-027`~`REV-028`로 확정했다. +- Phase 7 결과: plan/api-contract/OpenAPI 구현 상태 metadata가 완료 구현과 불일치하는 문제를 `REV-029`로 확정했다. +- 다음 Goal: `P2-R6`. +- 보완 결과: `P2-R6`, `P2-R6-GATE`를 완료했고 `REV-021`을 처리 완료로 동기화했다. +- 다음 Goal: `P3-R9`. +- 보완 결과: `P3-R9`, `P3-R9-GATE`를 완료했고 `REV-022`를 처리 완료로 동기화했다. +- 다음 Goal: `P4-R1`. +- 보완 결과: `P4-R1`을 완료했고 `REV-023`~`REV-024`를 처리 완료로 동기화했다. +- 다음 Goal: `P4-R1-GATE`. +- 보완 결과: `P4-R1-GATE`를 완료했고 Phase 4 후속 리뷰를 종료했다. +- 다음 Goal: `P5-R1`. +- 보완 결과: `P5-R1`, `P5-R1-GATE`를 완료했고 `REV-025`~`REV-026`을 처리 완료로 동기화했다. +- 다음 Goal: `P6-R1`. +- 보완 결과: `P6-R1`을 완료했다. FanTalk 목록은 공개 v2 `CreatorChannelFanTalkQueryPolicy`를 재사용해 `page < 0 -> 0`, `size < 20 -> 20`, `size > 50 -> 50`으로 보정하고, reply body는 strict reader로 미지 필드 400/no insert/no event를 고정했다. +- 다음 Goal: `P6-R1-GATE`. +- 보완 결과: `P6-R1-GATE`를 완료했고 Phase 6 후속 리뷰를 종료했다. +- 다음 Goal: `P7-R1`. +- 보완 결과: `P7-R1`을 완료했다. plan/API 설명/OpenAPI status를 23개 구현 완료로 동기화했고 controller mapping 23개와 validator/client 생성·compile을 확인했다. +- 다음 Goal: `P7-R1-GATE`. +- 보완 결과: `P7-R1-GATE`를 완료했고 `REV-021`~`REV-029` 전체를 처리 완료로 종결했다. +- 다음 Goal: 없음. + +### Phase 1~7 후속 정적 리뷰 완료 — 2026-07-28 + +- 상태: 리뷰 완료, `P3-R10` 시작 대기 +- 무엇을: PRD, plan-task, OpenAPI와 현재 Phase 1~7 production/test를 완료 기록 이후 상태 기준으로 다시 정적 대조했다. +- 왜: 통과한 컴파일·테스트가 다루지 않은 의미 검증, 상태 전이, 필수 multipart binding과 실제 동시성 불변식을 확인하고 + 확정 항목을 실행 가능한 후속 Task로 전환하기 위해서다. +- 어떻게: 요청값에서 controller/facade/legacy service/repository/예외 handler까지 호출 흐름을 역추적하고, + 관련 테스트가 실제 병렬·경계 상태를 검증하는지 `rg`, `sed`, `jq`, `git diff`로 확인했다. 사용자 요청에 따라 + 컴파일과 테스트는 실행하지 않았다. +- Phase 1 결과: 공통 target resolver, 보안, 오류 handler에서 신규 확정 finding 없음. +- Phase 2 결과: Character 4개 operation과 mutation pipeline에서 신규 확정 finding 없음. +- Phase 3 결과: 생성 `releaseDate`/`timezone`의 의미 오류가 500으로 분류되는 `REV-030`을 확정하고 + `Task 3.20` / `P3-R10`으로 전환했다. +- Phase 4 결과: soft-delete된 linked content를 해제할 수 없는 `REV-031`, 생성 필수 `image`가 nullable binding인 + `REV-032`를 확정하고 `Task 4.8` / `P4-R2`로 전환했다. +- Phase 5 결과: 순차 테스트만으로 완료 처리되어 실제 병렬 요청에서 최대 고정 3개를 보장하지 못하는 `REV-033`을 + 확정하고 `Task 5.8` / `P5-R2`로 전환했다. +- Phase 6 결과: FanTalk 2개 operation에서 신규 확정 finding 없음. +- Phase 7 결과: 23개 operation/mapping/status는 유지되지만 네 finding 처리 전 최종 완료 판정을 유지할 수 없어 + `Task 7.4` / `P7-R2` 통합 재판정을 추가했다. +- 다음 Goal: `P3-R10`. + +### Phase 1~7 3차 정적 리뷰 완료 — 2026-07-28 + +- 상태: 리뷰 완료, `P2-R7` 시작 대기 +- 무엇을: PRD, plan-task, OpenAPI와 현재 Phase 1~7 production/test를 최신 완료 상태 기준으로 다시 정적 대조했다. +- 왜: 통과한 컴파일·테스트가 다루지 않은 empty multipart와 optional boolean, locale별 예약일 표시 의미를 확인하고, + 확정 항목을 해당 Phase의 실행 가능한 후속 Task로 전환하기 위해서다. +- 어떻게: actual controller에서 facade, mapper, 레거시 controller/service, S3·DB·event 경계까지 호출 흐름을 + `rg`, `sed`, `jq`, `git diff`로 추적했다. 사용자 요청에 따라 컴파일과 테스트는 실행하지 않았다. +- Phase 1 결과: 공통 resolver, 보안, 오류/CORS 경계에서 신규 확정 finding 없음. +- Phase 2 결과: 생성의 빈 필수 image가 통과하는 `REV-034`, `isActive=true` 단독 수정이 레거시와 달리 거부되는 + `REV-035`를 확정하고 `Task 2.13` / `P2-R7`으로 전환했다. +- Phase 3 결과: 미래 예약 콘텐츠 상세의 `releaseDate`가 항상 null인 `REV-036`을 확정하고 + `Task 3.21` / `P3-R11`로 전환했다. +- Phase 4 결과: 생성·수정의 빈 image가 0-byte upload를 유발하는 `REV-037`을 확정하고 + `Task 4.9` / `P4-R3`으로 전환했다. +- Phase 5 결과: Community 3개 operation과 owner lock에서 신규 확정 finding 없음. +- Phase 6 결과: FanTalk 2개 operation에서 신규 확정 finding 없음. +- Phase 7 결과: 23개 operation/mapping/status는 유지되지만 네 finding 처리 전 최종 완료 판정을 유지할 수 없어 + `Task 7.5` / `P7-R3` 통합 재판정을 추가했다. +- 다음 Goal: `P2-R7`. + +### Phase 1~7 4차 정적 리뷰 완료 — 2026-07-29 + +- 상태: 리뷰 완료, `P7-R4` 시작 대기 +- 무엇을: PRD, plan-task, OpenAPI와 최신 Phase 1~7 production/test를 후속 Gate 완료 상태 기준으로 정적 대조했다. +- 왜: 컴파일·테스트 통과 이후에도 남을 수 있는 route/schema 의미와 완료 상태 기록 불일치를 확인하기 위해서다. +- 어떻게: controller/facade/mapper/repository에서 legacy service까지 호출 경로를 추적하고 `sed`, `rg`, `jq`, + `git diff --check`로 문서·23개 operation·controller mapping·dependency/DDL 변경 범위를 확인했다. 사용자 요청에 따라 + Gradle, 컴파일, 테스트는 실행하지 않았다. +- Phase 1 결과: resolver, ADMIN 이중 인가, 오류/CORS/firewall 경계에서 신규 확정 finding 없음. +- Phase 2 결과: Character 4개 operation의 최신 empty image·`isActive=true` 보완을 확인했고 기능 finding 없음. +- Phase 3 결과: AudioContent 5개 operation의 예약 공개일·signed URL·ownership 경계를 확인했고 기능 finding 없음. +- Phase 4 결과: Series 9개 runtime operation은 일치하지만 Phase 4 endpoint 설명의 DELETE path/body가 현재 계약과 다른 + 문서 문제를 `REV-039`로 확정했다. +- Phase 5 결과: Community 3개 operation과 owner lock·soft delete에서 신규 확정 finding 없음. +- Phase 6 결과: FanTalk 2개 operation과 pagination/root ownership에서 신규 확정 finding 없음. +- Phase 7 결과: 완료 증거가 있는 네 Task 헤더가 `[ ]`로 남아 상단 완료 상태와 모순되는 `REV-038`을 확정했다. +- plan 전환: 두 문서 정합성 finding을 `Task 7.6` / `P7-R4`로 묶었다. +- 다음 Goal: `P7-R4`. + +### `P7-R4` 완료 — 2026-07-29 + +- 상태: 완료 +- 무엇을: 완료 증거가 있는 `Task 2.13`, `Task 3.21`, `Task 4.9`, `Task 7.5` 헤더와 상단 상태표를 완료 상태로 동기화하고, Phase 4 시리즈 콘텐츠 해제 설명을 path `contentId`와 request body 없음 계약으로 정정했다. +- 왜: `REV-038`~`REV-039`가 기능 문제가 아니라 후속 작업 판단을 오도하는 문서 정합성 문제로 확정됐기 때문이다. +- 어떻게: 기존 Gate와 2026-07-29 검증 기록은 보존하고 문서 상태만 갱신한 뒤 `./gradlew tasks --all`, OpenAPI 23개 operation/status `jq`, 미완료 Task header `rg`, controller mapping `rg`, `git diff --check`를 실행했다. +- 결과: `./gradlew tasks --all`은 `BUILD SUCCESSFUL in 767ms`, OpenAPI assertion은 `true`, 미완료 Task header와 `git diff --check`는 출력이 없었고 controller mapping은 23개였다. `REV-038`~`REV-039`를 처리 완료로 판정했다. +- 남은 항목: 없음. + +### `P2-R8` / `P2-R8-GATE` 완료 — 2026-07-29 + +- 상태: 완료 +- 무엇을: 캐릭터 생성 관계 `importance` 누락·null을 v2 request 경계에서 400 `common.error.invalid_request`로 거부하도록 보완했다. +- 왜: OpenAPI required non-null integer가 Jackson/Kotlin primitive 기본값 `0`으로 보정되면 잘못된 관계 입력이 외부 API·DB·S3·event 부작용으로 이어질 수 있기 때문이다. +- 어떻게: production 변경은 `AiCharacterAdminCharacterFacade.readRequest()`의 strict reader에 `FAIL_ON_NULL_FOR_PRIMITIVES`를 추가하는 최소 범위로 제한하고, actual multipart POST RED/GREEN과 no-side-effect를 추가했다. +- 결과: RED 명령은 신규 2건이 `status().isBadRequest` 기대에서 실패했고, 보완 후 같은 명령과 `AiCharacterAdminCharacterControllerMutationTest`, character/common 영향 범위 회귀, `ktlintCheck`가 모두 `BUILD SUCCESSFUL`이었다. `git diff --check`는 출력이 없었다. +- 남은 항목: Phase 3 `P3-R12`. + +### `P3-R12` / `P3-R12-GATE` 완료 — 2026-07-29 + +- 상태: 완료 +- 무엇을: 오디오 생성 `price` 누락·null과 primitive boolean explicit null을 v2 request 경계에서 400 `common.error.invalid_request`로 거부하도록 보완했다. +- 왜: OpenAPI required/non-null primitive가 Jackson/Kotlin 기본값 `0`/`false`로 보정되면 잘못된 생성 요청이 파일 업로드·DB·event 부작용으로 이어질 수 있기 때문이다. +- 어떻게: production 변경은 `AiCharacterAdminAudioContentFacade.readRequest()`의 strict reader에 `FAIL_ON_NULL_FOR_PRIMITIVES`와 `FAIL_ON_MISSING_CREATOR_PROPERTIES`를 추가하는 최소 범위로 제한하고, actual multipart POST RED/GREEN과 no-side-effect를 추가했다. +- 결과: RED 명령은 신규 8개 invocation이 `status().isBadRequest` 기대에서 실패했고, `themeId:null`은 기존 missing-theme guard로 이미 400이었다. 보완 후 같은 명령과 `AiCharacterAdminAudioContentCreateTest`, content/common 영향 범위 회귀, `ktlintCheck`가 모두 `BUILD SUCCESSFUL`이었다. `git diff --check`는 출력이 없었다. +- 남은 항목: Phase 4 `P4-R4`. + +### Phase 1~7 5차 정적 리뷰 완료 — 2026-07-29 + +- 상태: 리뷰 완료, `P2-R8` 시작 대기 +- 무엇을: PRD, plan-task, OpenAPI와 최신 Phase 1~7 production/test의 JSON request 경계를 primitive + required/non-null/default 의미까지 정적 대조했다. +- 왜: Kotlin non-null primitive도 Jackson 2.13.5 기본 설정에서는 누락·null이 JVM 기본값으로 보정될 수 있어, + 컴파일·기존 테스트 통과만으로 OpenAPI nullability를 보장하지 못하기 때문이다. +- 어떻게: 각 multipart `request`의 strict reader 설정, Kotlin DTO primitive 타입, OpenAPI + required/nullable/default와 로컬 Jackson Kotlin/databind 2.13.5 source를 역추적했다. 사용자 요청에 따라 Gradle, + 컴파일, 테스트는 실행하지 않았다. +- Phase 1 결과: 공통 resolver, security, 오류 handler에서 신규 확정 finding 없음. 전역 mapper 변경도 후속 범위에서 + 제외했다. +- Phase 2 결과: 관계 필수 `importance` 누락·null이 `0`으로 보정될 수 있는 `REV-040`을 확정하고 + `Task 2.14` / `P2-R8`로 전환했다. +- Phase 3 결과: 생성 필수 `price` 누락·null과 non-null primitive의 explicit null이 기본값으로 보정될 수 있는 + `REV-041`을 확정하고 `Task 3.22` / `P3-R12`로 전환했다. +- Phase 4 결과: 생성 `genreId`, `isAdult`의 explicit null이 기본값으로 보정될 수 있는 `REV-042`를 확정하고 + `Task 4.10` / `P4-R4`로 전환했다. +- Phase 5 결과: 생성 필수 boolean·`price`와 수정 `isFixed`의 null/누락이 거부되지 않는 `REV-043`을 확정하고 + `Task 5.9` / `P5-R3`로 전환했다. +- Phase 6 결과: FanTalk reply는 primitive 요청 필드가 없고 기존 문자열 null/blank 경계가 유지되어 신규 finding 없음. +- Phase 7 결과: 23개 operation/mapping/status는 유지되지만 네 finding 처리 전 최종 완료 판정을 유지할 수 없어 + `Task 7.7` / `P7-R5` 통합 재판정을 추가했다. +- 다음 Goal: `P2-R8`. + +### Community 목록 계약 변경 확정 — 2026-07-29 + +- 상태: `P5-R4` / `P5-R4-GATE` 완료 +- 무엇을: Community 목록에서 사용되지 않는 `timezone` query를 제거하고 응답 `data`를 + `totalCount`, `page`, `size`, `hasNext`, `items` wrapper로 변경했다. +- 왜: 관리자 UI가 active owner 게시글 전체 개수와 다음 page 추가 로딩 필요 여부를 판단해야 하기 때문이다. +- 어떻게: 기존 item 필드와 고정 우선 정렬은 유지하고 active owner count query 하나를 추가하는 최소 설계로 PRD, + OpenAPI, API 설명과 Phase 5 신규 Task/Gate를 동기화했다. +- runtime 상태: controller/facade의 `timezone` query를 제거하고 `data` pagination wrapper를 반환하도록 정합화했다. +- plan 전환: `Task 5.10` / `P5-R4`, `P5-R4-GATE`를 완료하고 `P7-R5` 시작 조건을 충족했다. +- 다음 Goal: `P7-R5`. + +### `P7-R5` / `P7-R5-GATE` 완료 — 2026-07-29 + +- 상태: 완료 +- 무엇을: Phase 2~5 primitive required/nullability 보완과 Community 목록 wrapper 계약을 23개 관리자 operation 기준으로 통합 재판정했다. +- 왜: 후속 production/API 계약 변경 뒤 공통 JWT ADMIN 이중 인가, target/owner 오류, JSON 오류 envelope, no-side-effect와 legacy 회귀가 유지되는지 확인하기 위해서다. +- 어떻게: targeted 통합, 전체 회귀, lint, OpenAPI 23개 `implemented`, controller mapping 23개, dependency/DDL 무변경, diff whitespace를 fresh 검증했다. +- 결과: targeted `BUILD SUCCESSFUL in 2m 21s`, 전체 test `BUILD SUCCESSFUL in 5m 46s`, `ktlintCheck` `BUILD SUCCESSFUL in 881ms`, OpenAPI assertion `true`, mapping 23개, dependency/DDL 검색과 `git diff --check` 출력 없음이었다. +- 남은 항목: 없음. + +### 후속 기능 계획 확정 — 2026-07-29 + +- 상태: `P5-R5` / `P5-R5-GATE` 완료 +- Phase 2 결과: 캐릭터 등록용 원작 검색을 `Task 2.15` / `P2-R9`로 추가했다. +- Phase 3 결과: 오디오 콘텐츠 댓글 CRUD 5개 operation을 `Task 3.23` / `P3-R13`으로 추가했다. +- Phase 4 결과: 시리즈 등록용 장르 목록을 `Task 4.11` / `P4-R5`, 상세 `data`의 목록 item 정합화를 + `Task 4.12` / `P4-R6`으로 추가했다. +- Phase 5 결과: 커뮤니티 댓글 CRUD 5개 operation을 `Task 5.11` / `P5-R5`로 추가했다. +- Phase 6 결과: 팬 작성 FanTalk 원글 soft delete를 `Task 6.7` / `P6-R2`로 추가했다. +- Phase 7 결과: 기존 23개와 신규 13개를 합한 36개 operation 통합 재판정을 `Task 7.8` / `P7-R6`으로 추가했다. +- 제외 결과: 캐릭터에 직접 달리는 레거시 댓글 삭제는 v2 전환 뒤 미사용이라는 사용자 확정에 따라 operation과 Task를 + 추가하지 않았다. +- 검증: 사용자 지시에 따라 Gradle·컴파일·테스트는 실행하지 않고 JSON 문법, operation/status 집계, 내부 `$ref`, + 시리즈 상세 schema 참조와 문서 diff만 정적으로 확인한다. +- P2-R9 결과: 캐릭터 등록용 원작 검색 endpoint를 기존 `AdminOriginalWorkService.searchOriginalWorksAll`과 + `OriginalWorkResponse.from` 재사용으로 구현하고 OpenAPI 상태를 `implemented`로 갱신했다. +- P2-R9 검증: focused RED 3개 실패 확인 후 GREEN `BUILD SUCCESSFUL`, Character/common 영향 범위 회귀 + `BUILD SUCCESSFUL`, `ktlintCheck` `BUILD SUCCESSFUL`, `git diff --check` 출력 없음. +- P3-R13 결과: 오디오 콘텐츠 댓글 CRUD 5개 endpoint를 기존 `AudioContentCommentService` 조회·작성·수정 의미 재사용과 + v2 facade의 target/owner/parent/actor 선검증으로 구현하고 OpenAPI 상태를 `implemented`로 갱신했다. +- P3-R13 검증: focused RED 7건은 미구현 route의 404/405로 실패했고, GREEN focused는 `BUILD SUCCESSFUL in 3m 16s`, + content/common 영향 범위 회귀는 `BUILD SUCCESSFUL in 3m 47s`, `ktlintCheck`는 import 순서 1건 수정 후 + `BUILD SUCCESSFUL in 32s`, `git diff --check`는 출력 없음이었다. +- P4-R5 결과: 시리즈 등록용 장르 목록 endpoint를 기존 `AdminContentSeriesGenreService.getSeriesGenreList` 재사용으로 + 구현하고 OpenAPI 상태를 `implemented`로 갱신했다. +- P4-R5 검증: focused RED 2건은 미구현 route로 실패했고, GREEN focused는 `BUILD SUCCESSFUL in 1m 24s`, + series/common 영향 범위 회귀는 `BUILD SUCCESSFUL in 1m 38s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 29s`, + `git diff --check`는 출력 없음이었다. +- P4-R6 결과: 시리즈 상세 `data`를 목록 `items` 단일 항목과 동일한 `GetCreatorAdminContentSeriesListItem` + schema로 반환하도록 정합화하고 OpenAPI 상태를 `implemented`로 갱신했다. +- P4-R6 검증: focused RED는 레거시 상세 필드 차이로 실패했고, GREEN focused는 `BUILD SUCCESSFUL in 43s`였다. + focused query/contract 회귀는 `BUILD SUCCESSFUL in 38s`, series/common 영향 범위 회귀는 `BUILD SUCCESSFUL in 1m 19s`, + `ktlintCheck`는 `BUILD SUCCESSFUL in 23s`, `git diff --check`는 출력 없음이었다. +- P5-R5 결과: 커뮤니티 댓글 CRUD 5개 endpoint를 기존 `CreatorCommunityService` 조회·작성·수정 의미 재사용과 + v2 facade의 target/owner/parent/actor 선검증으로 구현하고 OpenAPI 상태를 `implemented`로 갱신했다. +- P5-R5 검증: focused RED 7건은 미구현 route로 실패했고, GREEN focused는 `BUILD SUCCESSFUL in 2m`였다. + community/common 영향 범위 회귀는 `BUILD SUCCESSFUL in 1m 26s`, `ktlintCheck`는 import 순서 1건 수정 후 + `BUILD SUCCESSFUL in 22s`, `git diff --check`는 출력 없음이었다. +- P6-R2 결과: 팬 작성 FanTalk 원글 삭제 endpoint를 `CreatorCheers.isActive` row 단위 soft delete로 구현하고 + OpenAPI 상태를 `implemented`로 갱신했다. +- P6-R2 검증: focused RED 6건은 DELETE route 미구현으로 실패했고, GREEN 이후 누락 ID를 포함한 focused 7건은 + `BUILD SUCCESSFUL in 29s`였다. FanTalk/common 영향 범위 회귀는 DELETE 인가 matrix 보강 후 + `BUILD SUCCESSFUL in 58s`였다. +- P6-R2-GATE 검증: `AiCharacterAdminAuthorizationTest` 단독은 `BUILD SUCCESSFUL in 29s`, 최종 `./gradlew ktlintCheck`는 + `BUILD SUCCESSFUL in 14s`, `git diff --check`는 출력 없음이었다. +- P7-R6 결과: Phase 2~6 후속 기능과 시리즈 상세 정합화 뒤 36개 관리자 operation의 계약·route·문서 상태를 + 통합 재판정했고, `Task 7.8` / `P7-R6-GATE`를 완료했다. +- P7-R6 검증: targeted `./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*'`는 + `BUILD SUCCESSFUL in 2m 24s`, 전체 `./gradlew test`는 `BUILD SUCCESSFUL in 8m 9s`, `./gradlew ktlintCheck`는 + `BUILD SUCCESSFUL in 1s`였다. OpenAPI 36개 operation/36개 `implemented` assertion은 `true`, controller mapping은 + 36개, 캐릭터 직접 댓글 route 검색은 0개, dependency/DDL 추가 검색과 `git diff --check`는 출력 없음이었다. +- 다음 Goal: 없음. + +### UTC 날짜 계약 변경 확정 — 2026-07-29 + +- 상태: 구현 완료 +- 무엇을: 신규 관리자 오디오 생성 request의 `timezone` body와 오디오 상세·오디오 댓글/답글·커뮤니티 댓글/답글 GET의 + `timezone` query를 제거했다. 생성 `releaseDate`는 클라이언트가 보내는 nullable ISO-8601 UTC(`Z`), 상세 + `releaseDate`와 댓글 `date`는 기존 필드명·null/노출 조건을 유지한 ISO-8601 UTC(`Z`)로 문서 계약을 확정했다. +- 왜: 서버가 클라이언트별 timezone을 받아 표시 문자열을 만들 필요 없이 절대 시각은 UTC로 교환하고 표시 변환은 + 클라이언트가 담당하도록 단일 계약을 유지하기 위해서다. +- 어떻게: production/test는 변경하거나 실행하지 않고 PRD, OpenAPI 2.2.0, 계약 설명, 구현 계획과 Phase 3·5·7 리뷰에 + 신규 `P3-R14`, `P5-R6`, `P7-R7` Task/Gate를 누적했다. +- 결과: 전체 route 36개는 유지된다. 최신 OpenAPI 상태는 `implemented` 36개, + `alignment-required` 0개, `planned` 0개다. +- 다음 Goal: 없음. + ## Decision Log | 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 | |---|---|---|---|---|---| | 2026-07-27 | `DEC-GOAL-001` | 확정 | 기존 Phase 2·3 완료 Task는 이력으로 보존하고 심층 리뷰·세부 보완·Gate Goal을 추가한다. | 기존 검증 이후 후속 보완이 반복됐고 Phase 완료 Gate가 없었다. | `P2-R1`~`P3-GATE` | +| 2026-07-28 | `DEC-REVIEW-007` | 확정 | Phase 1~7 정적 리뷰의 확정 finding 9건은 기존 완료 Task를 다시 열지 않고 각 소유 Phase의 신규 Task/Gate로 직렬 처리한다. | 완료 이력을 보존하면서 OpenAPI 단일 원본과 실제 HTTP 경계의 불일치를 최소 범위로 수정해야 한다. | `P2-R6`~`P7-R1-GATE`, Phase별 review 문서 | +| 2026-07-28 | `DEC-REVIEW-008` | 확정 | 후속 정적 리뷰의 `REV-030`~`REV-033`은 기존 완료 Task를 다시 열지 않고 Phase 3~5 신규 Task/Gate로 처리한 뒤 Phase 7에서 통합 재판정한다. | 정상·순차 경로의 테스트 통과와 별개로 의미 오류 500, soft-delete link 상태 전이, 필수 multipart binding, count-then-update 경쟁 조건이 코드·문서 근거로 확정됐다. | `P3-R10`~`P7-R2-GATE`, Phase별 review 문서 | +| 2026-07-28 | `DEC-REVIEW-009` | 확정 | 3차 정적 리뷰의 `REV-034`~`REV-037`은 기존 완료 Task를 다시 열지 않고 Phase 2~4 신규 Task/Gate로 처리한 뒤 Phase 7에서 통합 재판정한다. | required/optional multipart는 part 존재만으로 파일 유효성을 보장하지 않고, optional boolean과 예약일 표시의 레거시 의미가 현재 mapper/facade에서 소실되는 코드 경로가 확정됐다. | `P2-R7`~`P7-R3-GATE`, Phase별 review 문서 | +| 2026-07-29 | `DEC-REVIEW-010` | 확정 | 4차 정적 리뷰의 `REV-038`~`REV-039`는 production/OpenAPI를 변경하지 않고 Phase 7 문서 정합성 Task 하나로 처리한다. | 네 후속 Task는 하위 체크리스트·Gate·검증 기록상 완료됐지만 헤더가 미완료이고, Phase 4 DELETE 설명은 OpenAPI/controller와 달라 후속 작업 상태와 route 판단을 오도한다. | `P7-R4`, Phase 7 review 문서 | +| 2026-07-29 | `DEC-REVIEW-011` | 확정 | 5차 정적 리뷰의 `REV-040`~`REV-043`은 전역 Jackson 또는 레거시 DTO를 변경하지 않고 각 v2 request 경계의 신규 Task/Gate로 처리한 뒤 Phase 7에서 통합 재판정한다. | Jackson Kotlin/databind 2.13.5 기본 동작은 Kotlin primitive의 누락·null을 JVM 기본값으로 보정할 수 있고, 현재 strict reader는 미지 필드만 거부해 OpenAPI required/non-null 계약을 강제하지 못한다. | `P2-R8`~`P7-R5-GATE`, Phase별 review 문서 | +| 2026-07-29 | `DEC-REVIEW-012` | 확정 | 6차 정적 리뷰의 `REV-052`~`REV-058`은 OpenAPI를 변경하지 않고 각 v2 HTTP 경계의 신규 Task/Gate로 직렬 처리한 뒤 Phase 7에서 통합 재판정한다. | canonical OpenAPI의 optional pagination, JSON-only/415, multipart `request` part `application/json` 계약이 controller binding·mapping과 직접 불일치하며, domain 의미 변경 없이 소유 Phase의 최소 수정으로 해결할 수 있다. | `P2-R10`~`P7-R8-GATE`, Phase별 review 문서 | +| 2026-07-29 | `DEC-REVIEW-013` | 확정 | 7차 정적 리뷰의 `REV-060`~`REV-063`은 OpenAPI를 변경하지 않고 Phase 2~5 multipart controller 경계에서 operation별 허용 part 이름을 강제하며, `REV-064` 문서 상태와 함께 Phase 7에서 통합 재판정한다. | 8개 canonical multipart schema는 모두 `additionalProperties: false`지만 controller는 선언된 인자만 binding하고 실제 전체 part 이름을 검증하지 않아 미정의 part를 무시한다. OpenAPI는 37개 모두 `implemented`인데 계획 요약과 계약 설명은 36개 구현·1개 planned로 남아 있다. | `P2-R11`~`P7-R9-GATE`, Phase별 review 문서 | +| 2026-07-29 | `DEC-REVIEW-014` | 확정 | 9차 정적 리뷰의 `REV-072`는 preview 규칙을 재구현하지 않고 두 오디오 생성 경로가 공유하는 parsed request overload에서 기존 검증을 정확히 한 번 수행하도록 Phase 3에서 최소 보완한 뒤 Phase 7에서 통합 재판정한다. | v2 facade는 신규 overload를 호출하지만 기존 preview 쌍·형식·최소 15초 검증은 문자열 request overload에만 남아 있어 잘못된 preview 입력이 DB/S3/event 경계로 진행된다. | `P3-R19`~`P7-R11-GATE`, Phase 3·7 review 문서 | +| 2026-07-29 | `DEC-FANTALK-REPLY-UPDATE-001` | 확정 | FanTalk 답변 수정은 레거시 `PUT /explorer/profile/cheers`의 optional/nullable `content`, `isActive`, 빈 객체 no-op, 비활성 reply 재활성화와 `CreatorChannelFanTalkResponse` 성공 `data`를 유지한다. 신규 path ID와 target AI·활성 root·direct reply 검증만 추가한다. | 사용자 요청과 “기존 계약과 동일” 확정, `ExplorerService.modifyCheers`의 상태 전이와 응답 mapper | `P6-R4`, `P6-R4-GATE`, `P7-R8`, PRD, OpenAPI 2.3.0 | +| 2026-07-29 | `DEC-P5-LIST-001` | 확정 | Community 관리자 목록은 `timezone` query를 제거하고 `data`를 `totalCount`, `page`, `size`, `hasNext`, `items`로 반환한다. `totalCount`와 `hasNext`는 target creatorMember의 active 게시글만 기준으로 계산한다. | 목록 응답의 상대 시간은 timezone을 사용하지 않으며 관리자 UI가 전체 개수와 다음 page 추가 로딩 여부를 판단해야 한다는 사용자 확정 요구사항을 반영한다. | PRD, OpenAPI, `Task 5.10`, `P5-R4`~`P5-R4-GATE`, `P7-R5` | +| 2026-07-29 | `DEC-COMMENT-001` | 확정 | 오디오 콘텐츠·커뮤니티 댓글은 target AI 캐릭터 명의로 작성하고 target 작성 댓글만 수정하며 target 소유 자산의 댓글은 작성자와 관계없이 row 단위 soft delete한다. | 사용자 승인과 기존 콘텐츠·게시글 소유자의 댓글 비활성화 동작을 유지한다. | `P3-R13`, `P5-R5`, OpenAPI | +| 2026-07-29 | `DEC-CHAR-COMMENT-001` | 제외 | 사용하지 않는 레거시 캐릭터 직접 댓글 API는 v2로 전환하거나 관리자 삭제 기능을 추가하지 않는다. | 사용자 확인 결과 v2 전환 뒤 사용하지 않는다. | Non-Goals, `P7-R6` | +| 2026-07-29 | `DEC-UTC-DATE-001` | 확정 | 신규 관리자 오디오 생성의 `timezone` body와 오디오 상세·오디오 댓글/답글·커뮤니티 댓글/답글 GET의 `timezone` query를 제거한다. 생성 `releaseDate`는 클라이언트가 UTC로 변환해 보내고, 상세 `releaseDate`와 댓글 `date`는 기존 필드명을 유지한 ISO-8601 UTC(`Z`)로 반환한다. 로컬 시각+timezone 입력은 병행 지원하지 않는다. | 서버가 클라이언트 표시 timezone을 해석하지 않고 단일 절대 시각 계약을 유지한다는 사용자 승인 | `P3-R14`, `P5-R6`, `P7-R7`, PRD, OpenAPI 2.2.0 | +| 2026-07-29 | `DEC-FANTALK-DELETE-001` | 확정 | 팬 작성 FanTalk root 삭제는 원글만 soft delete하고 연결 creator reply row는 변경하지 않는다. | 사용자 승인과 기존 `CreatorCheers.isActive` 상태 전이를 유지한다. | `P6-R2`, OpenAPI | +| 2026-07-29 | `DEC-REGISTRATION-REFERENCE-001` | 확정 | 캐릭터 등록용 원작 검색과 시리즈 등록용 장르 목록을 target 없는 신규 v2 관리자 endpoint로 제공한다. | 캐릭터 관리자 frontend가 동일한 v2 ADMIN/CORS 경계에서 등록 참조 정보를 조회해야 한다. | `P2-R9`, `P4-R5`, OpenAPI | +| 2026-07-29 | `DEC-SERIES-DETAIL-001` | 확정 | 시리즈 상세 `data`를 목록 `items`의 단일 항목과 동일한 schema로 변경하고 기존 상세 전용 `genre`, `keywords`를 제거한다. | 사용자 확정과 관리자 목록·상세 DTO 일관성을 반영한다. | `P4-R6`, OpenAPI | +| 2026-07-28 | `DEC-P4-R2-001` | 확정 | 시리즈 생성 `image` 누락은 PRD 공통 binding 계약과 OpenAPI required part를 따라 exact `MissingServletRequestPartException`, 400 `common.error.invalid_request`로 처리한다. | nullable binding을 통한 legacy `creator.admin.series.cover_image_required`는 필수 part가 MVC를 통과한 결과이며 PRD §8의 명시적 누락 part 계약과 충돌한다. | `P4-R2`, `REV-032` | | 2026-07-27 | `DEC-REVIEW-001` | 확정 | 코드 리뷰의 8개 확정 finding은 기존 미실행 범주형 Goal에 `REV-001`~`REV-008`로 귀속하고 Phase 안에서 직렬 실행한다. | 새 Goal을 중복 추가하거나 기존 완료 이력을 다시 열지 않으면서 각 finding의 재현·완료 증거를 독립 추적하기 위해. | `P2-R1`~`P2-GATE`, `P3-R1`~`P3-GATE` | | 2026-07-27 | `DEC-P2-T4-001` | 확정 | POST 생성은 외부 API 필수 입력인 `systemPrompt`를 받고, `externalCharacterId`는 외부 API가 반환하는 response 전용 값이며, `isActive`는 서버가 `true`로 생성하는 response 상태다. `characterType`은 생략 시 `Character`, 잘못된 값은 외부 부작용 전 400이다. 중복 이름은 legacy와 같은 `findByName` 선검증만 적용하며 신규 DDL 없이 동시 요청의 DB unique 보장은 추가하지 않는다. 원작·중복·타입 검증은 외부 생성 전에 수행한다. 외부 API 실패는 DB/S3/event를 남기지 않고, S3 실패는 DB transaction과 event를 롤백하지만 legacy에 삭제 API가 없으므로 이미 생성된 외부 캐릭터는 보상하지 않는다. | Endpoint Contract Summary의 축약 JSON이 외부 API 반환값을 입력처럼 표기하지만, legacy 등록과 v2 external client 모두 `systemPrompt`로 외부 생성을 요청하고 ID를 응답에서 받는다. PRD의 external API 계약·DDL 변경 금지와 legacy failure order를 유지한다. | `P2-T4`, `REV-002`, `REV-003`, `REV-007` | | 2026-07-27 | `DEC-P2-T5-001` | 확정 | PUT의 `externalCharacterId`는 response 전용으로 명시 거부한다. `isActive=false`는 image와 일반 수정 field를 섞지 않는 단독 soft delete다. 일반 수정은 image를 생략하면 기존 경로를 유지하고, 존재하지 않는 `originalWorkId`는 외부 수정 전에 거부한다. 외부 수정 실패는 S3/DB/event를 남기지 않으며, S3 실패는 DB/event를 롤백하지만 legacy와 같은 external update restore 계약이 없어 성공한 외부 수정은 보상하지 않는다. 응답 `updatedAtUtc`는 DB flush 후 매핑한다. | 기존 service의 soft delete는 다른 field를 무시해 이미지 업로드 고아를 남겼고, `@PreUpdate` timestamp는 flush 전에는 이전 값을 반환했다. 외부 API delete/restore 추가와 DDL은 범위 밖이다. | `P2-T5`, `REV-002`, `REV-003`, `REV-007` | @@ -3124,8 +7169,243 @@ v2 관리자 API를 제공한다. | `REV-018` | High | 처리 완료 | 캐릭터 생성 Endpoint Contract Summary가 필수 `systemPrompt`를 누락하고 request 금지 `externalCharacterId`, `isActive`를 포함해 확정 계약과 반대다. | `P2-R5`, `P2-R5-GATE` | 생성 JSON을 `DEC-P2-T4-001`과 동기화하고 기존 actual endpoint 계약 회귀로 확인했다. | | `REV-019` | Medium | 처리 완료 | 생성·수정의 빈 multipart 파일이 null/non-empty 검사 사이를 통과해 0-byte upload, cover 교체 또는 수정 `audioFile` 미지원 계약을 우회한다. | `P3-R7`, `P3-R5-GATE` | v2 facade에서 생성 empty-file 거부, 수정 empty cover 정규화와 모든 audio part 거부를 RED/GREEN으로 고정했다. | | `REV-020` | Low | 처리 완료 | Phase 3 ownership/domain test의 event no-interaction mock이 실제 `AudioContentService`·`CreatorAdminContentService` publisher field에 연결되지 않았다. | `P3-R8`, `P3-R5-GATE` | 실제 두 service proxy target의 publisher를 mock으로 교체·복원하고 identity/no-interaction을 단언했다. | +| `REV-021` | Low | 처리 완료 | Character POST/PUT이 `data: null`만 반환하는데 facade가 전체 response DTO를 매핑해 controller가 버린다. | `P2-R6`, `P2-R6-GATE` | facade 반환형을 `Unit`으로 축소하고 미사용 mapping을 제거한 뒤 mutation 계약을 회귀했다. | +| `REV-022` | Low | 처리 완료 | AudioContent 관리자 repository에 실제 호출되지 않는 조회·series 교체 helper와 그 전용 status enum이 남아 있다. | `P3-R9`, `P3-R9-GATE` | 사용 중인 owner-scoped 상세 조회만 남기고 호출 0건 코드를 제거한 뒤 content 회귀를 실행했다. | +| `REV-023` | High | 처리 완료 | OpenAPI Series 9개 operation에 없는 `DELETE /series/{seriesId}`가 구현·테스트되어 공개 API 표면이 계약보다 넓다. | `P4-R1`, `P4-R1-GATE` | 계약 밖 route/facade를 제거하고 DELETE 성공 기대를 405와 `PUT isActive=false` 계약으로 교정했다. | +| `REV-024` | Medium | 처리 완료 | Series 콘텐츠 추가·순서 변경 JSON body가 permissive ObjectMapper binding으로 `additionalProperties: false`를 강제하지 않는다. | `P4-R1`, `P4-R1-GATE` | 두 body를 strict parse하고 미지 필드 400/no-side-effect를 actual endpoint로 고정했다. | +| `REV-025` | High | 처리 완료 | Community create/update의 수동 JSON parse 오류가 공통 400으로 변환되지 않아 malformed payload가 500이 될 수 있고 미지 필드도 허용된다. | `P5-R1`, `P5-R1-GATE` | legacy 호출 전 strict parse와 mapping 예외 변환을 적용하고 400/no-side-effect를 고정했다. | +| `REV-026` | Medium | 처리 완료 | Community 목록이 OpenAPI에 없는 `size <= 50` 상한을 적용해 계약상 유효한 `size=51`을 400으로 거부한다. | `P5-R1`, `P5-R1-GATE` | 문서에 없는 상한 guard를 제거하고 `size=51` actual endpoint 계약을 고정했다. | +| `REV-027` | High | 처리 완료 | FanTalk 목록이 공개 v2 계약의 page/size 보정 대신 범위 밖 값을 400으로 거부하고 `size=1`도 허용한다. | `P6-R1`, `P6-R1-GATE` | 공개 v2 query policy를 재사용하고 경계값 actual test를 교정했다. | +| `REV-028` | Medium | 처리 완료 | FanTalk reply JSON body가 permissive binding으로 OpenAPI의 `additionalProperties: false`를 강제하지 않는다. | `P6-R1`, `P6-R1-GATE` | reply body를 strict parse하고 미지 필드 400/no insert/no event를 고정했다. | +| `REV-029` | Medium | 처리 완료 | plan/API 계약 설명/OpenAPI `x-implementation-status`가 완료된 구현을 여전히 정합화 필요·예정으로 표시한다. | `P7-R1`, `P7-R1-GATE` | 23개 operation metadata를 모두 `implemented`로 동기화하고 controller mapping 23개와 validator/client 생성을 확인했다. | +| `REV-030` | Medium | 처리 완료 | AudioContent 생성의 잘못된 `releaseDate` 형식·`timezone`이 legacy Java time 예외로 빠져 500이 된다. | `P3-R10`, `P3-R10-GATE` | strict parse 결과의 날짜·시간대를 legacy 호출 전에 검증하고 400/no-side-effect를 고정했다. | +| `REV-031` | Medium | 처리 완료 | 시리즈에 연결된 콘텐츠를 soft delete하면 추가 적격성 guard 때문에 연결을 해제할 수 없다. | `P4-R2`, `P4-R2-GATE` | 해제는 실제 owner link만 검증하고 add/search 전용 active/release/duration 적격성은 적용하지 않는다. | +| `REV-032` | Medium | 처리 완료 | OpenAPI 필수 시리즈 생성 `image`가 nullable binding이라 공통 missing-part 오류 계약을 우회한다. | `P4-R2`, `P4-R2-GATE` | 생성 image를 non-null binding으로 바꾸고 exact exception·KO/EN/JA·no-side-effect를 고정했다. | +| `REV-033` | High | 처리 완료 | Community 최대 고정 수가 lock 없는 count-then-update라 실제 병렬 요청에서 3개를 초과할 수 있다. | `P5-R2`, `P5-R2-GATE` | 기존 owner row pessimistic lock으로 고정/해제를 직렬화하고 결정적 병렬 회귀 테스트를 추가했다. | +| `REV-034` | Medium | 처리 완료 | OpenAPI 필수 캐릭터 생성 `image`를 빈 part로 보내면 외부 생성과 DB 저장이 진행되고 이미지 없는 캐릭터가 생성된다. | `P2-R7`, `P2-R7-GATE` | facade에서 empty file을 외부 API·DB·S3·event 전에 400으로 거부하고 no-side-effect를 고정했다. | +| `REV-035` | Medium | 처리 완료 | 캐릭터 수정의 `isActive=true` 단독 요청이 OpenAPI·레거시에서는 유효하지만 v2 no-change guard에서 400으로 거부된다. | `P2-R7`, `P2-R7-GATE` | non-null `isActive`를 변경 요청으로 인정해 레거시 200 `data: null` no-op parity를 복구했다. | +| `REV-036` | High | 처리 완료 | 미래 예약 오디오 콘텐츠 상세의 `releaseDate`가 mapper에서 항상 null로 고정되어 레거시 locale별 공개 예정 시각을 숨긴다. | `P3-R11`, `P3-R11-GATE` | 미래/과거와 KO/EN/JA 레거시 표시 규칙을 mapper에 최소 이관하고 상세 회귀를 고정했다. | +| `REV-037` | Medium | 처리 완료 | 시리즈 생성·수정의 빈 image가 legacy service로 전달되어 0-byte S3 upload와 cover 생성·교체를 유발한다. | `P4-R3`, `P4-R3-GATE` | 생성 empty file은 거부하고 수정 empty file은 생략으로 정규화해 S3/DB/event 경계를 고정했다. | +| `REV-038` | Low | 처리 완료 | 완료 증거와 Gate가 존재하는 `Task 2.13`, `Task 3.21`, `Task 4.9`, `Task 7.5` 헤더가 `[ ]`로 남아 상단 `구현 완료`·완료 수와 모순된다. | `P7-R4` | 기존 완료 기록을 보존하며 네 Task 헤더와 상태표·Progress만 동기화했다. | +| `REV-039` | Low | 처리 완료 | Phase 4 endpoint 설명은 콘텐츠 해제를 `DELETE .../contents` + request body로 적었지만 OpenAPI와 controller는 `DELETE .../contents/{contentId}` + body 없음이다. | `P7-R4` | Phase 4 설명만 기계 검증 가능한 OpenAPI와 실제 route에 맞췄다. | +| `REV-040` | Medium | 처리 완료 | 캐릭터 생성 관계의 OpenAPI 필수 정수 `importance`가 누락·null이어도 Kotlin/Jackson primitive 기본값 `0`으로 역직렬화되어 mutation이 진행될 수 있다. | `P2-R8`, `P2-R8-GATE` | v2 생성 경계에서 primitive null/누락을 400으로 거부하고 400/no-side-effect 회귀를 추가했다. | +| `REV-041` | High | 처리 완료 | 오디오 생성의 필수 `price` 누락·null과 non-null primitive의 explicit null이 JVM 기본값으로 보정되어 파일 업로드·DB mutation이 진행될 수 있고 문서 기본값 의미도 바뀔 수 있다. | `P3-R12`, `P3-R12-GATE` | v2 생성 경계에서 primitive null/누락을 400으로 거부하고 optional 생략 기본값과 no-side-effect 회귀를 추가했다. | +| `REV-042` | Medium | 처리 완료 | 시리즈 생성의 non-null `genreId`, `isAdult`에 explicit null이 들어와도 primitive 기본값 `0`/`false`로 보정될 수 있으며, 특히 `isAdult: null`은 정상 생성으로 이어질 수 있다. | `P4-R4`, `P4-R4-GATE` | v2 생성 경계에서 explicit null을 400으로 거부하고 필드 생략 기본값은 유지했다. | +| `REV-043` | High | 처리 완료 | 커뮤니티 생성 필수 boolean 누락·null과 `price: null`이 false·0으로 보정되고, 수정 `isFixed: null`은 생략과 구분되지 않아 유효 요청처럼 처리될 수 있다. | `P5-R3`, `P5-R3-GATE` | v2 create/update 경계에서 required/non-null primitive를 검증하고 생략 의미와 no-side-effect를 고정했다. | +| `REV-044` | High | 처리 완료 | 캐릭터 등록 화면에서 사용할 신규 v2 원작 검색 operation이 없다. | `P2-R9`, `P2-R9-GATE` | 기존 무페이징 검색과 `OriginalWorkResponse`를 재사용하는 관리자 endpoint를 구현했다. | +| `REV-045` | High | 처리 완료 | target AI 소유 오디오 콘텐츠의 댓글·답글 조회/작성/수정/삭제 operation이 없다. | `P3-R13`, `P3-R13-GATE` | actor·owner·parent 검증과 row-only soft delete를 포함한 5개 operation을 구현했다. | +| `REV-046` | Medium | 처리 완료 | 시리즈 등록 화면에서 사용할 신규 v2 활성 장르 목록 operation이 없다. | `P4-R5`, `P4-R5-GATE` | 기존 활성/`orders` 조회와 장르 response를 재사용하는 관리자 endpoint를 구현했다. | +| `REV-047` | Medium | 처리 완료 | 시리즈 상세 `data`가 목록 item과 다른 레거시 상세 schema를 반환한다. | `P4-R6`, `P4-R6-GATE` | 상세를 목록 item의 단일 객체와 동일한 11개 필드·타입으로 정합화했다. | +| `REV-048` | High | 처리 완료 | target AI 소유 커뮤니티 게시글의 댓글·답글 조회/작성/수정/삭제 operation이 없다. | `P5-R5`, `P5-R5-GATE` | actor·owner·parent 검증과 row-only soft delete를 포함한 5개 operation을 구현했다. | +| `REV-049` | High | 처리 완료 | target AI 채널에서 팬이 작성한 FanTalk root를 삭제할 관리자 operation이 없다. | `P6-R2`, `P6-R2-GATE` | 팬 작성 root만 soft delete하고 creator reply row를 보존하는 DELETE를 구현했다. | +| `REV-050` | High | 처리 완료 | 오디오 생성은 `timezone` body와 로컬 날짜를 받고 상세·댓글·답글 GET은 `timezone` query 및 locale/legacy 날짜 문자열을 사용해 최신 UTC 계약과 다르다. | `P3-R14`, `P3-R14-GATE` | v2 생성 DTO·내부 UTC instant 경계와 세 GET의 UTC mapping을 최소 구현하고 legacy/public 회귀를 고정했다. | +| `REV-051` | High | 처리 완료 | 커뮤니티 댓글·답글 GET이 `timezone` query와 timezone별 `date` 표시 문자열을 사용해 최신 UTC 계약과 다르다. | `P5-R6`, `P5-R6-GATE` | v2 GET에서 timezone을 제거하고 기존 `date` 필드를 UTC로 mapping하며 legacy/public 회귀를 고정했다. | +| `REV-052` | High | 처리 완료 | 오디오 댓글·답글 목록의 `page`, `size`가 OpenAPI에서는 optional 기본값 0/20이지만 controller와 facade는 둘 다 명시한 요청만 허용한다. | `P3-R15`, `P3-R15-GATE` | 두 GET의 전체·부분 생략 기본값을 복구하고 미지 query·범위 오류·UTC/ownership 회귀를 고정했다. | +| `REV-053` | High | 처리 완료 | 커뮤니티 댓글 작성·수정 mapping이 `application/json`을 강제하지 않아 OpenAPI의 JSON-only request와 미지원 media type 415 계약을 보장하지 못한다. | `P5-R7`, `P5-R7-GATE` | POST·PUT에 JSON `consumes`를 추가하고 415 header/envelope/no-side-effect를 actual endpoint로 고정했다. | +| `REV-054` | High | 처리 완료 | FanTalk 답변 작성 mapping이 `application/json`을 강제하지 않아 OpenAPI의 JSON-only request와 미지원 media type 415 계약을 보장하지 못한다. | `P6-R3`, `P6-R3-GATE` | reply POST에 JSON `consumes`를 추가하고 415 header/envelope/no-side-effect와 정상 축약 응답을 회귀했다. | +| `REV-055` | High | 처리 완료 | Character 생성·수정 multipart의 `request` part는 OpenAPI상 `application/json`이지만 `@RequestPart String` binding이 part media type을 강제하지 않아 415 계약을 보장하지 못한다. | `P2-R10`, `P2-R10-GATE` | controller part header에서 JSON 호환 여부를 확인해 POST·PUT의 415 `Accept`/envelope와 external/S3/DB/event no-side-effect를 고정했다. | +| `REV-056` | High | 처리 완료 | AudioContent 생성·수정 multipart의 `request` part가 같은 이유로 `text/plain`도 수용하며 실제 수정 테스트가 이를 200으로 기대한다. | `P3-R16`, `P3-R16-GATE` | POST·PUT controller part header에서 JSON 호환 여부를 강제하고 text/plain·Content-Type 누락 KO/EN/JA 415 `Accept`/envelope와 S3·DB·event no-side-effect를 고정했다. | +| `REV-057` | High | 처리 완료 | Series 생성·수정 multipart의 `request` part는 OpenAPI상 `application/json`이지만 `@RequestPart String` binding이 part media type을 강제하지 않아 415 계약을 보장하지 못한다. | `P4-R7`, `P4-R7-GATE` | POST·PUT controller part header에서 JSON 호환 여부를 강제하고 text/plain·Content-Type 누락 KO/EN/JA 415 `Accept`/envelope와 S3/DB/event no-side-effect를 고정했다. | +| `REV-058` | High | 처리 완료 | Community 게시글 생성·수정 multipart의 `request` part는 OpenAPI상 `application/json`이지만 `@RequestPart String` binding이 part media type을 강제하지 않아 415 계약을 보장하지 못한다. | `P5-R8`, `P5-R8-GATE` | POST·PUT controller part header에서 JSON 호환 여부를 강제하고 text/plain·Content-Type 누락 KO/EN/JA 415 `Accept`/envelope와 S3/DB no-side-effect를 고정했다. | +| `REV-059` | High | 처리 완료 | 선택한 AI 캐릭터가 작성한 FanTalk 답변의 내용·활성 상태를 수정할 V2 관리자 operation이 없다. | `P6-R4`, `P6-R4-GATE` | 레거시 `PUT /explorer/profile/cheers` 계약을 유지하고 target AI·활성 root·direct reply로 한정한 PUT을 구현했다. | +| `REV-060` | Medium | 처리 완료 | Character 생성·수정 multipart schema는 `additionalProperties: false`지만 controller가 전체 part 이름을 검증하지 않아 `image`, `request` 외 part를 무시하고 mutation을 진행한다. | `P2-R11`, `P2-R11-GATE` | operation별 허용 part 집합을 검사하고 미정의 part 400/no-side-effect를 고정했다. | +| `REV-061` | Medium | 처리 완료 | AudioContent 생성·수정도 미정의 multipart part를 무시한다. 수정은 `audioFile`, `contentFile`만 명시적으로 거부해 다른 이름의 추가 part가 정상 mutation을 통과한다. | `P3-R17`, `P3-R17-GATE` | 생성·수정의 서로 다른 허용 part 집합을 검사하고 기존 수정 파일 교체 거부를 같은 경계로 통합했다. | +| `REV-062` | Medium | 처리 완료 | Series 생성·수정 multipart schema는 `image`, `request` 외 part를 금지하지만 controller는 추가 part를 검사하지 않는다. | `P4-R8`, `P4-R8-GATE` | operation별 허용 part 집합과 미정의 part 400/no-side-effect 회귀를 추가했다. | +| `REV-063` | Medium | 처리 완료 | Community 생성·수정은 허용 part 집합이 다른데 전체 part 이름을 검사하지 않아 특히 수정의 `audioFile` 등 미정의 part를 무시하고 mutation을 진행한다. | `P5-R9`, `P5-R9-GATE` | 생성·수정의 허용 part 집합을 각각 검사하고 미정의 part 400/no-side-effect를 고정했다. | +| `REV-064` | Low | 처리 완료 | OpenAPI와 controller는 37개 구현 완료인데 `plan-task.md` Endpoint Contract Summary와 `api-contract.md`는 36개 구현·FanTalk 답변 수정 1개 planned 상태로 남아 있다. | `P7-R9`, `P7-R9-GATE` | 두 규범 설명의 집계·endpoint 상태·client 설명을 37개 구현 완료로 동기화했다. | +| `REV-065` | Medium | 처리 완료 | Character 생성·수정의 미정의 multipart 검사가 `fileMap.keys`만 보므로 filename 없는 일반 form-field part가 `{image, request}` allow-list를 우회한다. | `P2-R12`, `P2-R12-GATE` | 파일 map과 servlet 전체 part 이름을 모두 검사하고 일반 form-field 미정의 part 400/no-side-effect를 고정했다. | +| `REV-066` | Medium | 처리 완료 | AudioContent 생성·수정도 `fileMap.keys`만 검사해 filename 없는 일반 form-field 미정의 part를 허용한다. | `P3-R18`, `P3-R18-GATE` | operation별 파일 map과 servlet 전체 part 이름 집합, 일반 form-field 회귀를 추가했다. | +| `REV-067` | Medium | 처리 완료 | Series 생성·수정도 `fileMap.keys`만 검사해 filename 없는 일반 form-field 미정의 part를 허용한다. | `P4-R9`, `P4-R9-GATE` | 파일 map과 servlet 전체 part 이름을 `{image, request}`와 대조하고 400/no-side-effect를 고정했다. | +| `REV-068` | Medium | 처리 완료 | Series 생성·수정의 active genre 검사가 양수에만 존재 여부를 확인해 `genreId <= 0`을 legacy non-null repository 경계로 전달하고 예상 밖 500을 유발할 수 있다. | `P4-R10`, `P4-R10-GATE` | 0 이하 또는 비활성·미존재 장르를 legacy 호출 전에 공통 400으로 거부했다. | +| `REV-069` | Medium | 처리 완료 | Community 생성·수정도 `fileMap.keys`만 검사해 filename 없는 일반 form-field 미정의 part를 허용한다. | `P5-R10`, `P5-R10-GATE` | operation별 파일 map과 servlet 전체 part 이름 집합, 일반 form-field 회귀를 추가했다. | +| `REV-070` | Low | 처리 완료 | FanTalk 삭제에서 OpenAPI·구현·테스트는 동일 target의 이미 비활성인 팬 root를 성공 no-op으로 처리하지만 PRD는 400으로 기술하고 계약 설명도 상충한다. | `P6-R5`, `P6-R5-GATE` | OpenAPI/runtime의 no-op 계약에 맞춰 PRD와 `api-contract.md` 설명만 동기화했다. | +| `REV-071` | Low | 처리 완료 | 완료 Gate가 존재하는 `REV-060`~`REV-064` 일부가 finding 표에서 여전히 `확정`으로 남아 상단 구현 완료 상태와 모순된다. | `P7-R10`, `P7-R10-GATE` | 후속 Gate 완료 뒤 기존·신규 finding 상태와 Phase 집계를 실제 결과에 맞췄다. | +| `REV-072` | High | 처리 완료 | v2 오디오 생성은 parsed request overload를 호출하지만 기존 preview 시간 쌍·형식·최소 15초 검증은 문자열 request overload에만 있어 잘못된 입력이 DB 저장과 S3 upload·event publish 경계로 진행된다. | `P3-R19`, `P3-R19-GATE`, `P7-R11`, `P7-R11-GATE` | 기존 `validatePreviewTime` 호출을 두 생성 경로가 공유하는 overload로 이동하고 actual endpoint·legacy 회귀와 no-side-effect를 고정한 뒤 37개 operation 통합 재판정을 완료했다. | ## 검증 기록 +- Phase 1~7 9차 정적 리뷰(2026-07-29): PRD, plan, OpenAPI 37개 operation과 현재 + controller/facade/service/repository/test 소스를 사용자 지시에 따라 컴파일·테스트 실행 없이 대조했다. + `AiCharacterAdminAudioContentFacade.create`는 parsed request overload를 호출하지만 기존 creator 생성의 + `validatePreviewTime`은 문자열 request overload에서만 호출되는 `REV-072`를 확정했다. 한쪽만 있는 preview, + 잘못된 형식, 15초 미만 입력을 거부하는 v2 회귀 테스트는 없고 정상 값만 존재했다. Phase 1·2·4·5·6에는 신규 + finding이 없으며 Phase 7 완료 판정은 Phase 3 보완 뒤 재수행한다. OpenAPI JSON 문법, operation 37개·고유 + operationId 37개·`implemented` 37개를 `jq`로 확인했다. production/test/PRD/OpenAPI는 변경하지 않았고 + `Task 3.29`, `Task 7.13`과 Phase별 리뷰 기록만 추가했다. +- Phase 1~7 8차 정적 리뷰(2026-07-29): PRD, plan, OpenAPI 37개 operation과 현재 + controller/facade/repository/test 소스를 사용자 지시에 따라 컴파일·테스트 실행 없이 대조했다. Character, + AudioContent, Series, Community controller의 미정의 multipart 검사가 `fileMap.keys`에 한정되어 filename 없는 일반 + form-field part를 확인하지 못하는 `REV-065`~`REV-067`, `REV-069`를 확정했다. Series의 `genreId <= 0`이 active + genre 사전 검증을 우회하는 `REV-068`, FanTalk 비활성 root 삭제 설명이 OpenAPI·runtime과 상충하는 `REV-070`, + 완료 Gate와 finding 상태가 어긋나는 `REV-071`도 확정했다. Phase 1은 신규 finding이 없다. 테스트 명령은 실행하지 + 않았고, `rg`·`jq`·`nl`과 `javap`만 사용했다. 로컬 Spring Web 5.3.29 bytecode에서 filename 없는 part가 file map이 + 아니라 parameter 이름 집합으로 분류됨을, 기존 compile output에서는 legacy genre repository의 Kotlin non-null + check를 정적으로 확인했다. production/test/OpenAPI는 변경하지 않았으며 신규 Task와 Phase별 리뷰 문서만 추가했다. OpenAPI는 + operation 37개·고유 operationId 37개·`implemented` 37개였고, code fence 짝과 신규 미완료 Task/Gate를 확인했다. + `./gradlew tasks --all`은 컴파일·테스트 없이 `BUILD SUCCESSFUL in 1s`, `git diff --check`는 출력 없이 성공했다. +- Phase 1~7 7차 정적 리뷰(2026-07-29): PRD, plan, OpenAPI 37개 operation과 현재 controller/facade/test를 + 사용자 지시에 따라 컴파일·테스트 실행 없이 대조했다. OpenAPI의 8개 multipart schema는 모두 + `additionalProperties: false`이고 operation별 허용 part가 명시돼 있지만 네 domain controller는 전체 part 이름 + 집합을 검증하지 않아 `REV-060`~`REV-063`을 확정했다. OpenAPI 37개 `implemented`와 controller 37개 mapping에 + 비해 계획 요약·계약 설명이 36개 구현/1개 planned인 `REV-064`도 확정했다. Phase 1·6은 신규 finding이 없으며, + production/test/OpenAPI는 변경하지 않았다. 정적 assertion은 37개 operationId·37개 `implemented`·내부 `$ref`, + 8개 multipart `additionalProperties: false`, controller mapping 37개를 확인했고, 신규 Goal/Task ID는 각각 1개로 + 유일했다. `./gradlew tasks --all`은 `BUILD SUCCESSFUL in 1s`, `git diff --check`와 trailing whitespace 검사는 + 출력 없이 성공했으며 문서 code fence도 모두 짝이 맞았다. +- `P7-R8` / `P7-R8-GATE` 검증(2026-07-29): `REV-052`~`REV-059` 처리 뒤 OpenAPI 37개 + operationId/고유 operationId/status `implemented` 37개와 controller mapping 37개를 대조했다. 선행 Gate 미체크와 + `REV-052`~`REV-059` 미처리 항목은 없었고, dependency·DDL 변경 파일도 없었다. 영향 14개 operation과 공통 + error/authorization focused 회귀는 `BUILD SUCCESSFUL in 1m 26s`, 전체 `./gradlew test`는 + `BUILD SUCCESSFUL in 8m 8s`, `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 1s`, `git diff --check`는 + 출력이 없었다. Phase 7 Gate 기준에 따라 문서 상태를 `구현 완료`로 갱신했다. +- `P6-R4` / `P6-R4-GATE` 검증(2026-07-29): `AiCharacterAdminFanTalkReplyUpdateTest`와 + `AiCharacterAdminFanTalkReplyUpdateContractTest`에 답변 수정 success/no-op/재활성화, target/root/direct-reply + ownership, malformed/unknown-field/415 `Accept`/DB·event no-side-effect 회귀를 추가했다. RED는 신규 focused + 명령에서 15건이 미구현 route 404로 `BUILD FAILED in 49s`였다. PUT route, strict request DTO, active root와 + target AI writer/creator direct reply를 검증하는 repository query, non-null field만 반영하는 facade를 추가한 뒤 + 분리 focused 재실행은 `BUILD SUCCESSFUL in 42s`, FanTalk/common/legacy 영향 범위 회귀는 + `BUILD SUCCESSFUL in 1m 2s`였다. OpenAPI status 집계는 37개 operation 모두 `implemented`, `ktlintCheck`는 + import/format 수정 뒤 `BUILD SUCCESSFUL in 25s`, `git diff --check`는 출력이 없었다. 신규 dependency·DDL과 + legacy/public API 변경은 없다. 전체 `./gradlew test`는 변경 범위가 v2 FanTalk reply update와 FanTalk/common/legacy + 영향 범위에 포함되므로 생략했다. +- `P6-R3` / `P6-R3-GATE` 검증(2026-07-29): `AiCharacterAdminFanTalkReplyContractTest`에 KO/EN/JA + `text/plain` reply POST 415 envelope, `Accept`, reply insert/event no-side-effect 회귀를 추가했다. RED는 focused + 명령에서 3개 invocation이 `status().isUnsupportedMediaType` 기대 실패로 `BUILD FAILED in 33s`였다. reply POST + mapping에 `consumes = [MediaType.APPLICATION_JSON_VALUE]`를 추가한 뒤 같은 focused 재실행은 + `BUILD SUCCESSFUL in 41s`, FanTalk/common 영향 범위와 `AiCharacterAdminErrorContractTest` 회귀는 + `BUILD SUCCESSFUL in 47s`였다. 전체 `./gradlew test`는 변경 범위가 v2 FanTalk reply POST media type 경계와 + 공통 오류 계약에 한정되고 focused/영향 범위 회귀가 이를 포함하므로 생략했다. +- `P5-R8` / `P5-R8-GATE` 검증(2026-07-29): `AiCharacterAdminCommunityPostCreateTest`와 + `AiCharacterAdminCommunityPostUpdateTest`에 KO/EN/JA `text/plain` 및 Content-Type 누락 `request` part 415 envelope, + `Accept`, DB/S3 no-side-effect 회귀를 추가했다. RED는 focused 명령에서 12개 invocation이 + `status().isUnsupportedMediaType` 기대 실패로 `BUILD FAILED in 56s`였다. POST·PUT controller 경계에 + JSON 호환 part media type 검사를 추가한 뒤 같은 focused 재실행은 `BUILD SUCCESSFUL in 59s`, community/common + 영향 범위와 `AiCharacterAdminErrorContractTest` 회귀는 `BUILD SUCCESSFUL in 55s`였다. 전체 `./gradlew test`는 + 변경 범위가 v2 community 게시글 multipart request part 경계와 공통 오류 계약에 한정되고 focused/영향 범위 회귀가 + 이를 포함하므로 생략했다. +- `P5-R7` / `P5-R7-GATE` 검증(2026-07-29): `AiCharacterAdminCommunityPostCommentTest`에 KO/EN/JA `text/plain` + POST·PUT 415 envelope/`Accept`/작성 insert·수정 row·event no-side-effect 회귀를 추가했다. RED는 같은 focused 명령에서 + 3개 locale invocation이 `status().isUnsupportedMediaType` 기대 실패로 `BUILD FAILED in 30s`였다. 두 댓글 mapping에 + `consumes = [MediaType.APPLICATION_JSON_VALUE]`를 추가한 뒤 focused 재실행은 `BUILD SUCCESSFUL in 30s`, community/common + 영향 범위와 `AiCharacterAdminErrorContractTest` 회귀는 `BUILD SUCCESSFUL in 57s`, `./gradlew ktlintCheck`는 + `BUILD SUCCESSFUL in 12s`였다. `git diff --check`는 출력 없음을 확인했다. 전체 `./gradlew test`는 변경 범위가 v2 + community 댓글 JSON media type 경계와 공통 오류 계약에 한정되고 focused/영향 범위 회귀가 이를 포함하므로 생략했다. +- FanTalk 답변 수정 계약·계획 문서 검증(2026-07-29): production/test 파일은 변경하지 않고 PRD, OpenAPI 2.3.0, + 계약 설명, plan과 Phase 6·7 리뷰에 `REV-059`, `Task 6.9` / `P6-R4`·Gate를 추가했다. OpenAPI는 전체 37개 + operation, 기존 `implemented` 36개와 신규 `planned` 1개로 구성하며 신규 PUT의 request는 optional/nullable + `content`, `isActive`, 성공 `data`는 `CreatorChannelFanTalkResponse` 필드 형태다. JSON 문법·내부 `$ref`· + operationId·200 response·상태 집계, Markdown 구조와 `git diff --check`를 정적으로 확인했다. + `./gradlew tasks --all`은 최초 sandbox cache lock 권한 실패 후 허용된 재실행에서 `BUILD SUCCESSFUL in 742ms`였다. + 사용자 지시에 따라 컴파일·테스트·lint는 실행하지 않았다. +- Phase 1~7 6차 정적 리뷰 검증(2026-07-29): 현재 working tree를 기준으로 PRD·plan·OpenAPI와 + Phase 1~7 production/test 코드를 대조했다. `jq`로 OpenAPI JSON 문법, operation 36개, 고유 operationId 36개를 + 확인했고 controller mapping도 36개다. OpenAPI 공통 `Page`/`Size`는 optional 기본값 0/20이나 오디오 댓글·답글 + GET 두 곳은 기본값이 없고 facade가 query 이름의 정확한 집합을 요구한다. OpenAPI가 `application/json` request와 + 415를 선언한 mutation 중 커뮤니티 댓글 POST·PUT과 FanTalk reply POST 세 곳은 mapping에 JSON `consumes`가 없다. + OpenAPI와 계약 설명이 Character·AudioContent·Series·Community 생성·수정 8개 multipart의 `request` part를 + `application/json`으로 고정하지만 모든 controller는 `@RequestPart String`으로 받아 part media type을 강제하지 않는다. + AudioContent 수정 테스트에는 `text/plain` request part를 보내 200을 기대하는 실제 증거도 존재한다. + 변경 파일 기준 신규 dependency·migration·DDL 경로는 없었다. 문서 정적 검증만 수행했으며 사용자 지시에 따라 + Gradle, 컴파일, 테스트는 실행하지 않았다. +- UTC 날짜 계약 문서 반영 검증(2026-07-29): production/test 파일은 변경하지 않고 PRD, OpenAPI 2.2.0, + 계약 설명, plan과 Phase 3·5·7 리뷰만 갱신했다. `jq`로 JSON 문법, 내부 `$ref` 누락 0건, operationId 중복 0건, + 200 response 누락 0건, 전체 36개 operation과 `implemented` 30개/`alignment-required` 6개/`planned` 0개, + 정확한 영향 operation 6개를 확인했다. query `timezone` parameter와 `components.parameters.Timezone`, 생성 + `AudioContentCreateRequest.timezone`은 모두 0건이며 생성·상세 `releaseDate`와 오디오·커뮤니티 댓글 `date`는 + `date-time` + `Z$`로 확인했다. plan Task 집계는 Phase 1~7 순서대로 7/7, 15/15, 23/24, 12/12, 11/12, + 7/7, 8/9이며 미완료 Task는 `Task 3.24`, `Task 5.12`, `Task 7.9` 세 건이다. `git diff --check`는 출력이 + 없었고 `./gradlew tasks --all`은 첫 sandbox 실행에서 Gradle cache lock 권한으로 실패한 뒤 허용된 재실행에서 + `BUILD SUCCESSFUL in 896ms`였다. 사용자 지시에 따라 컴파일과 테스트는 실행하지 않았다. +- UTC 날짜 계약 구현 완료 검증(2026-07-29): `P3-R14`, `P5-R6`, `P7-R7`을 완료했다. 오디오 focused 재실행은 + `BUILD SUCCESSFUL in 52s`, 커뮤니티 댓글 focused `--rerun-tasks` 재실행은 `BUILD SUCCESSFUL in 4m 33s`였다. + OpenAPI 집계는 36개 operation, 36개 `implemented`, 0개 `alignment-required`, query `timezone` parameter 0개, + `components.parameters.Timezone` 0개였고, controller mapping은 36개였다. `P3-R14-GATE`와 `P5-R6-GATE`의 + 영향 범위 회귀와 `ktlintCheck`, `git diff --check` 성공 기록을 대조해 Phase 7 상태를 `구현 완료`로 동기화했다. +- `P6-R2` 검증(2026-07-29): 팬 작성 FanTalk root DELETE endpoint를 actual endpoint로 구현했다. RED는 + `AiCharacterAdminFanTalkDeleteTest` 신규 6건이 미구현 route로 실패했다. 구현 후 누락 ID 계약을 포함한 focused + 재실행은 `BUILD SUCCESSFUL in 29s`였다. Oracle 리뷰에서 DELETE endpoint의 ADMIN 이중 인가 matrix 누락을 지적받아 + `AiCharacterAdminAuthorizationTest`에 FanTalk delete request를 추가했고, authorization 단독 재실행은 + `BUILD SUCCESSFUL in 29s`, FanTalk/common 영향 범위 회귀는 `BUILD SUCCESSFUL in 58s`였다. + 최종 `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 14s`, `git diff --check`는 출력 없음이었다. 전체 `./gradlew test`는 + 변경 범위가 v2 FanTalk 삭제 endpoint와 FanTalk/common 회귀에 포함되어 생략했다. +- `P5-R5` / `P5-R5-GATE` 검증(2026-07-29): 커뮤니티 댓글 CRUD 5개 operation을 actual endpoint로 구현했다. + RED는 `AiCharacterAdminCommunityPostCommentTest` 신규 7건이 미구현 route로 실패했다. 구현 후 focused 재실행은 + `BUILD SUCCESSFUL in 2m`, community/common 영향 범위 회귀는 `BUILD SUCCESSFUL in 1m 26s`였다. + `./gradlew ktlintCheck`는 facade import 순서 1건 실패 후 정리해 `BUILD SUCCESSFUL in 22s`였고, + `git diff --check`는 출력이 없었다. 전체 `./gradlew test`는 변경 범위가 v2 community 댓글 endpoint와 community/common + 회귀에 포함되어 생략했다. +- `P4-R6` / `P4-R6-GATE` 검증(2026-07-29): 시리즈 상세 `data`를 목록 item과 동일한 11개 field/type으로 + 정합화했다. RED는 `AiCharacterAdminSeriesQueryTest`의 상세 schema 테스트가 기존 레거시 상세 필드와 달라 실패했다. + 구현 후 focused 단일 테스트는 `BUILD SUCCESSFUL in 43s`, query/contract focused 회귀는 `BUILD SUCCESSFUL in 38s`, + series/common 영향 범위 회귀는 `BUILD SUCCESSFUL in 1m 19s`였다. `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 23s`, + `git diff --check`는 출력이 없었다. 전체 `./gradlew test`는 변경 범위가 v2 series 상세 response mapper와 series/common + 회귀에 포함되어 생략했다. +- `P4-R5` / `P4-R5-GATE` 검증(2026-07-29): 시리즈 등록용 장르 목록 endpoint를 actual endpoint로 구현했다. + RED는 `AiCharacterAdminSeriesGenreTest` 신규 2건이 미구현 route로 실패했다. 구현 후 focused 재실행은 + `BUILD SUCCESSFUL in 1m 24s`, series/common 영향 범위 회귀는 `BUILD SUCCESSFUL in 1m 38s`였다. + `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 29s`, `git diff --check`는 출력이 없었다. 전체 `./gradlew test`는 + 변경 범위가 v2 series reference endpoint와 series/common 회귀에 포함되어 생략했다. +- `P3-R13` / `P3-R13-GATE` 검증(2026-07-29): 오디오 콘텐츠 댓글 CRUD 5개 operation을 actual endpoint로 구현했다. + RED는 `AiCharacterAdminAudioContentCommentTest` 신규 7건이 미구현 route의 404/405로 실패했다. 구현 후 focused 재실행은 + `BUILD SUCCESSFUL in 3m 16s`, content/common 영향 범위 회귀는 `BUILD SUCCESSFUL in 3m 47s`였다. + `./gradlew ktlintCheck`는 신규 테스트 import 순서 1건 실패 후 정리해 `BUILD SUCCESSFUL in 32s`였고, + `git diff --check`는 출력이 없었다. 전체 `./gradlew test`는 변경 범위가 v2 content 댓글 endpoint와 content/common + 회귀에 포함되어 생략했다. +- 후속 기능 문서 보완 검증(2026-07-29): 사용자 지시에 따라 Gradle·컴파일·테스트는 실행하지 않았다. + `jq empty`로 OpenAPI JSON 문법을 확인했고 operation/status assertion은 전체 36개, + `implemented` 22개, `planned` 13개, `alignment-required` 1개와 고유 operationId 36개를 확인해 `true`였다. + 내부 `$ref` 누락은 0건이고 시리즈 상세 `data`는 `SeriesListItem`을 참조하며 구 `SeriesDetailResponse` schema가 + 없음을 확인했다. Phase별 Task 집계는 1~7 순서로 7/15/23/12/11/7/8이며 상단 완료/전체 수와 일치했고, + 계약 표 집계도 22/13/1과 일치했다. 문서 경로의 trailing whitespace 검사와 추적 문서 + `git diff --check`는 출력이 없었다. +- `P5-R4` / `P5-R4-GATE` 검증(2026-07-29): Community 목록의 `timezone` query 제거와 + `totalCount/page/size/hasNext/items` wrapper 계약을 actual endpoint로 고정했다. RED는 + `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostQueryTest'`에서 + 신규 3건이 400/직접 배열 응답 차이로 `BUILD FAILED`였다. controller/facade의 `timezone` parameter와 검증을 제거하고, + repository에 active owner count query를 추가해 wrapper를 구성한 뒤 같은 query focused는 `BUILD SUCCESSFUL in 1m 28s`였다. + 최종 focused query+contract는 `BUILD SUCCESSFUL in 37s`, community/common 영향 범위 회귀는 `BUILD SUCCESSFUL in 1m 6s`, + OpenAPI jq assertion은 `true`, `./gradlew ktlintCheck`는 들여쓰기 1건 수정 후 `BUILD SUCCESSFUL in 12s`였다. + `git diff --check`는 출력이 없었다. 전체 `./gradlew test`는 변경 범위가 v2 community 목록과 공통 authorization/error + 회귀에 포함되어 생략했다. +- `P5-R3` / `P5-R3-GATE` 검증(2026-07-29): 커뮤니티 생성 `isCommentAvailable`/`isAdult` 누락·null, `price:null`, 수정 `isFixed:null` actual endpoint RED는 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostCreateTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostUpdateTest --tests "*shouldRejectMissingOrNullPrimitiveCreateFieldsWithoutSideEffects" --tests "*shouldRejectNullIsFixedUpdateWithoutSideEffects"`에서 신규 6건이 `status().isBadRequest` 기대 실패로 `BUILD FAILED`였다. v2 community strict reader에 `FAIL_ON_NULL_FOR_PRIMITIVES`, `FAIL_ON_MISSING_CREATOR_PROPERTIES`를 추가하고 `isFixed:null` explicit null guard를 추가한 뒤 같은 명령은 `BUILD SUCCESSFUL in 41s`였다. 리뷰 보완으로 `price` 생략 기본값 0과 `isFixed` 생략 시 고정 상태 보존 assertion을 추가한 뒤 create/update focused는 `BUILD SUCCESSFUL in 34s`, community/common 영향 범위 회귀는 `BUILD SUCCESSFUL in 1m 2s`, `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 13s`였다. `git diff --check`는 출력이 없었다. 전체 `./gradlew test`는 변경 범위가 v2 community request reader와 community create/update tests에 한정되고 community/common 회귀가 영향 범위를 포함하므로 생략했다. +- `P4-R4` / `P4-R4-GATE` 검증(2026-07-29): 시리즈 생성 `genreId:null`, `isAdult:null` actual POST RED는 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.AiCharacterAdminSeriesMutationTest --tests "*shouldRejectNullPrimitiveCreateFieldsBeforeSideEffects"`에서 신규 2개 invocation이 `status().isBadRequest` 기대 실패로 `BUILD FAILED`였다. v2 series strict reader에 `FAIL_ON_NULL_FOR_PRIMITIVES`를 추가한 뒤 같은 명령은 `BUILD SUCCESSFUL`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.AiCharacterAdminSeriesMutationTest`는 `BUILD SUCCESSFUL`, `./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.*" --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest`는 `BUILD SUCCESSFUL in 1m 12s`, `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 20s`였다. `git diff --check`는 출력이 없었다. 전체 `./gradlew test`는 변경 범위가 v2 series request reader와 series mutation test에 한정되고 series/common 회귀가 영향 범위를 포함하므로 생략했다. +- `P2-R8` / `P2-R8-GATE` 검증(2026-07-29): 관계 `importance` 누락·null actual POST RED는 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest --tests "*shouldRejectMissingRelationshipImportanceBeforeSideEffects" --tests "*shouldRejectNullRelationshipImportanceBeforeSideEffects"`에서 신규 2건이 `status().isBadRequest` 기대 실패로 `BUILD FAILED`였다. v2 character strict reader에 `FAIL_ON_NULL_FOR_PRIMITIVES`를 추가한 뒤 같은 명령은 `BUILD SUCCESSFUL in 48s`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest`는 `BUILD SUCCESSFUL in 53s`, `./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*" --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest`는 `BUILD SUCCESSFUL in 1m 38s`, `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 22s`였다. `git diff --check`는 출력이 없었다. 전체 `./gradlew test`는 변경 범위가 v2 character request reader와 character mutation test에 한정되고 character/common 회귀가 영향 범위를 포함하므로 생략했다. +- `P3-R12` / `P3-R12-GATE` 검증(2026-07-29): 오디오 생성 `price` 누락·null과 primitive field explicit null actual POST RED는 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest --tests "*shouldRejectMissingOrNullPriceBeforeUpload" --tests "*shouldRejectNullPrimitiveFieldsBeforeUpload"`에서 8개 invocation이 `status().isBadRequest` 기대 실패로 `BUILD FAILED`였다. `themeId:null`은 기존 missing-theme guard로 이미 400이었다. v2 content strict reader에 `FAIL_ON_NULL_FOR_PRIMITIVES`와 `FAIL_ON_MISSING_CREATOR_PROPERTIES`를 추가한 뒤 같은 명령은 `BUILD SUCCESSFUL in 42s`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest`는 `BUILD SUCCESSFUL in 36s`, `./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*" --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest`는 `BUILD SUCCESSFUL in 1m 28s`, `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 18s`였다. `git diff --check`는 출력이 없었다. 전체 `./gradlew test`는 변경 범위가 v2 content create request reader와 content create test에 한정되고 content/common 회귀가 영향 범위를 포함하므로 생략했다. +- Community 목록 계약·계획 동기화(2026-07-29): 사용자 확정에 따라 `timezone` query를 제거하고 + `totalCount/page/size/hasNext/items` schema와 `Task 5.10` / `P5-R4`, `P5-R4-GATE`를 문서에 반영했다. + 사용자 요청 범위가 계획 보강이므로 Gradle, 컴파일, 테스트는 실행하지 않았다. `jq empty`, 내부 `$ref` 해석, + 23개 operation·고유 operationId를 확인했고 현재 구현 상태는 `implemented` 22개, + `implemented-contract-alignment-required` 1개(Community GET)였다. timezone parameter 부재와 response required 필드 + 5개를 assertion으로 확인했고 Task/의존 순서, Markdown code fence 균형을 점검했다. `git diff --check`는 출력이 없었다. +- Phase 1~7 5차 정적 리뷰(2026-07-29): multipart JSON DTO의 primitive required/nullability를 + OpenAPI와 production strict reader, 로컬 Jackson Kotlin/databind 2.13.5 source까지 대조해 `REV-040`~`REV-043` + 4건을 확정했다. Phase 1·6은 신규 finding 없음, Phase 7은 후속 통합 재판정 요청으로 판정했다. 사용자 요청에 따라 + Gradle, 컴파일, 테스트는 실행하지 않았다. `jq`로 OpenAPI JSON과 23개 operation/status를 확인하고, controller + mapping은 Character 4 + AudioContent 5 + Series 9 + Community 3 + FanTalk 2, 합계 23개를 확인했다. + 신규 Task 5개와 Gate 연결, 문서 code fence 균형을 확인했고 `git diff --check`는 출력이 없었다. +- `P7-R4` 문서 정합화 검증(2026-07-29): 완료 증거가 존재하는 네 Task 헤더와 상단 상태표를 완료로 동기화하고, 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 `rg`는 Character 4 + AudioContent 5 + Series 9 + Community 3 + FanTalk 2, 합계 23개를 확인했다. production/test/OpenAPI 변경은 없었다. +- Phase 1~7 4차 정적 리뷰(2026-07-29): PRD, plan-task, OpenAPI, production/test를 정적 대조해 기능상 신규 finding은 + 없었고 문서 정합성 `REV-038`~`REV-039`를 확정했다. 사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다. + `jq empty`와 23개 operation·고유 operationId·`implemented`·200 response assertion은 성공했고 controller mapping은 + Character 4 + AudioContent 5 + Series 9 + Community 3 + FanTalk 2로 23개였다. `git diff --check`는 출력이 없었고 + `build.gradle.kts`, migration/DDL 경로 변경도 없었다. +- `P2-R7`~`P7-R3-GATE` 후속 보완 검증(2026-07-29): `REV-034`~`REV-037`을 각각 RED로 재현한 뒤 최소 production 수정으로 GREEN을 확인했다. focused 명령은 character mutation, audio query, series mutation 순서로 모두 최종 `BUILD SUCCESSFUL`이었다. 통합 검증으로 `./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 23개 operation/status `jq` assertion은 `true`, controller mapping count는 23, `git diff --check`는 출력이 없었고 신규 dependency/DDL 파일 변경은 없었다. +- Phase 1~7 3차 정적 리뷰(2026-07-28): empty multipart, optional boolean, 미래 예약일 표시를 + production/legacy/OpenAPI/test까지 역추적해 `REV-034`~`REV-037` 4건을 확정했다. Phase 1·5·6은 신규 finding 없음, + Phase 7은 23개 operation/mapping/status 유지와 통합 재판정 요청으로 판정했다. 사용자 요청에 따라 Gradle, + 컴파일, 테스트는 실행하지 않았으며 `rg`, `sed`, `jq`, `git diff` 기반 정적 대조만 수행했다. OpenAPI + operation/status assertion은 `true`, controller mapping 집계는 23개였고, 변경 문서의 code fence 균형과 + `git diff --check`는 출력 없이 통과했다. +- Phase 1~7 후속 정적 리뷰(2026-07-28): 정상·순차 경로 밖의 의미 입력, soft-delete link, required multipart, + fixed-count concurrency를 production/legacy/test까지 역추적해 `REV-030`~`REV-033` 4건을 확정했다. Phase 1·2·6은 + 신규 finding 없음, Phase 7은 23개 operation/mapping/status 유지와 통합 재판정 요청으로 판정했다. 사용자가 현재 + compile/test 통과 사실을 제공하고 재실행하지 말 것을 요청했으므로 `./gradlew test`와 compile 명령은 실행하지 않았다. + 문서 가이드 확인용 `./gradlew tasks --all`만 실행해 `BUILD SUCCESSFUL`을 확인했고, `jq` 집계로 OpenAPI 23개 + operation/status `implemented`, `rg` 집계로 관리자 controller mapping 23개를 확인했다. 변경 문서의 + `git diff --check`, trailing whitespace와 code fence 균형 점검도 출력·오류 없이 종료했다. +- Phase 1~7 정적 리뷰(2026-07-28): PRD, plan-task, OpenAPI 23개 operation과 현재 controller/facade/repository/DTO/test를 + 정적 대조해 `REV-021`~`REV-029` 9건을 확정하고 Phase별 신규 Task/Gate 및 review 문서로 추적했다. `rg`, `jq`, + `git diff` 계열의 읽기·정적 점검만 사용했으며, 사용자가 현재 compile/test 통과 사실을 제공하고 재실행을 금지했으므로 + `./gradlew`, 컴파일, 테스트는 실행하지 않았다. - 실행 계획 동기화 검증(2026-07-28): OpenAPI 23개 고유 operation과 Character 4, AudioContent 5, Series 9, Community 3, FanTalk 2 분류를 현재 Endpoint Contract Summary 및 `P23-CONTRACT-2`~Phase 7과 대조했다. Goal ID 중복은 없고 PRD Open Questions는 `없음`이며 Task 3.17·3.18에 추가한 production/test/characterization 파일의 존재를 확인했다. @@ -3142,10 +7422,18 @@ v2 관리자 API를 제공한다. 독립 레거시 DTO/controller/service 대조 및 생성물 재검증 결과 Critical 0, Important 0이었다. - Phase 2·3 6차 보완 재점검 focused 검증(2026-07-28): `./gradlew test --rerun-tasks --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentUpdateTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentControllerTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentOwnershipTest` 실행 결과 `BUILD SUCCESSFUL in 4m 19s`였다. 다섯 XML 합계 130건의 failure/error/skipped는 모두 0이었다. - Phase 2·3 6차 보완 재점검 문서 명령 유효성(2026-07-28): `./gradlew tasks --all` 실행 결과 `test`, `ktlintCheck`, `tasks`가 존재했고 `BUILD SUCCESSFUL in 858ms`였다. +- `P6-R1` RED/GREEN 검증(2026-07-28): `./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.AiCharacterAdminFanTalkQueryTest" --tests "kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.AiCharacterAdminFanTalkReplyContractTest"` 최초 실행은 pagination 400과 reply unknown-field 허용으로 4건 실패했다. 공개 v2 query policy 재사용과 reply strict reader 적용 후 동일 명령 재실행은 `BUILD SUCCESSFUL in 3m 14s`였다. +- `P6-R1-GATE` fresh 검증(2026-07-28): `./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`는 `BUILD SUCCESSFUL in 1m 36s`, `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 35s`였고, `git diff --check`는 출력이 없었다. 전체 `./gradlew test`는 변경 범위가 FanTalk facade/controller/test와 문서에 한정되고 FanTalk/common 회귀가 영향 범위를 포함하므로 생략했다. +- `P7-R1` 문서/metadata 검증(2026-07-28): OpenAPI operation/status `jq` assertion은 `true`, 신규 prefix controller method mapping은 Character 4 + AudioContent 5 + Series 9 + Community 3 + FanTalk 2 = 23개였다. OpenAPI Generator validate는 `No validation issues detected`, TypeScript client 생성은 성공했고 `npx --yes tsc --noEmit --target ES2020 --module commonjs --lib ES2020,DOM /tmp/ai-character-admin-typescript-client/index.ts`는 출력 없이 exit 0이었다. `./gradlew tasks --all`은 `BUILD SUCCESSFUL in 1s`, `git diff --check`는 출력이 없었다. +- `P7-R1-GATE` 최종 검증(2026-07-28): OpenAPI 23개 operation과 23개 `implemented` assertion은 `true`, 신규 prefix controller method mapping은 Character 4 + AudioContent 5 + Series 9 + Community 3 + FanTalk 2 = 23개였고, 미처리 `확정` review finding 검색은 출력이 없었다. OpenAPI Generator validate는 `No validation issues detected`, TypeScript client 생성과 compile은 exit 0, `./gradlew tasks --all`은 `BUILD SUCCESSFUL in 1s`, `git diff --check`는 출력이 없었다. - Phase 2·3 6차 보완 재점검 판정(2026-07-28): `REV-018`~`REV-020`의 production/test 보완과 Gate 완료 증거가 일치했고 추가 production finding은 확정되지 않았다. 하단 종합 표에서만 미처리로 남은 `REV-001`~`REV-009`를 각 소유 Gate·Progress 판정에 맞춰 `처리 완료`로 동기화했다. - `P3-R5-GATE` content/common 회귀(2026-07-28): `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*' --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest` 실행 결과 reviewer gate 보완 후 최종 `BUILD SUCCESSFUL in 2m 21s`였다. - `P3-R5-GATE` lint/diff 검증(2026-07-28): `./gradlew ktlintCheck` 실행 결과 reviewer gate 보완 후 최종 `BUILD SUCCESSFUL in 44s`였고, `git diff --check`는 출력이 없었다. - `P3-R5-GATE` 전체 회귀 생략(2026-07-28): 변경 범위가 Phase 2 문서와 Phase 3 content v2 facade/test에 한정되고 content/common 회귀가 실제 영향 범위를 포함하므로 전체 `./gradlew test`는 실행하지 않았다. +- `P4-T6` focused 검증(2026-07-28): `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.AiCharacterAdminSeriesContractTest --rerun-tasks`는 `BUILD SUCCESSFUL in 3m 41s`였고, `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest --rerun-tasks`는 순차 재실행에서 `BUILD SUCCESSFUL in 3m 3s`였다. 병렬 실행 중 authorization run 1회는 unrelated `DefaultHomeRecommendationQueryRepository` QueryDSL 참조 compile 오류로 실패했으나 같은 명령 순차 재실행으로 영향 범위를 확인했다. +- `P4-T6` series 회귀와 lint/diff 검증(2026-07-28): `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.*' --rerun-tasks`는 `BUILD SUCCESSFUL in 3m 23s`, `./gradlew ktlintCheck --rerun-tasks`는 `BUILD SUCCESSFUL in 25s`였고, `git diff --check`는 출력이 없었다. +- `P4-GATE` fresh 검증(2026-07-28): `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.*' --rerun-tasks`는 `BUILD SUCCESSFUL in 3m 21s`, `./gradlew ktlintCheck --rerun-tasks`는 `BUILD SUCCESSFUL in 24s`였고, `git diff --check`는 출력이 없었다. +- `P5-T1` community baseline 검증(2026-07-28): `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.LegacyCommunityPostCharacterizationTest --rerun-tasks`와 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*' --rerun-tasks`는 각각 `BUILD SUCCESSFUL in 2m 22s`였다. `./gradlew ktlintCheck --rerun-tasks`는 unused import 2건 정리 후 `BUILD SUCCESSFUL in 21s`였고, `git diff --check`는 출력이 없었다. - Phase 2·3 6차 리뷰 fresh focused 검증(2026-07-28): `./gradlew test --rerun-tasks --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentControllerTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentOwnershipTest` 실행 결과 `BUILD SUCCESSFUL in 4m 16s`였다. 다섯 XML 합계 216건의 failure/error/skipped는 모두 0이었다. - Phase 2·3 6차 리뷰 lint/diff 검증(2026-07-28): `./gradlew ktlintCheck --rerun-tasks`는 7개 task가 실행되어 `BUILD SUCCESSFUL in 17s`였고, staged/unstaged `git diff --check`는 출력이 없었다. - Phase 2·3 6차 리뷰 전체 회귀 생략(2026-07-28): production code를 변경하지 않은 read-only review와 문서 후속 Task 등록이며, 5차 보완의 핵심 actual endpoint 216건과 lint로 직접 범위를 확인했으므로 전체 `./gradlew test`는 실행하지 않았다. 실제 `P3-R7` production 수정 후 content/common 영향 범위 회귀를 실행한다. @@ -3369,3 +7657,107 @@ v2 관리자 API를 제공한다. failure/error/skipped 0, `BUILD SUCCESSFUL`(10분 2초)을 확인했다. - Phase 1 최종 보강 후 lint: `./gradlew ktlintCheck --rerun-tasks` 실행 결과 7개 task가 실행됐고 `BUILD SUCCESSFUL`(29초)을 확인했다. +- `P5-T3` RED (2026-07-28): `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostCreateTest --rerun-tasks` 실행 결과 테스트 컴파일 후 6개 테스트가 모두 `POST` 미매핑으로 기대 400/200 대비 실제 405를 반환해 실패했다. 테스트 setup 오류가 아닌 생성 endpoint 미구현을 확인했다. +- `P5-T3` GREEN (2026-07-28): 동일 focused 명령을 재실행해 정상 무료 생성의 target owner/data null, image/audio legacy media upload, 유료·오디오 이미지 누락 legacy 오류, inactive target 선검증, request part 누락 envelope을 포함한 6개 테스트가 `BUILD SUCCESSFUL in 4m 7s`로 통과했다. 리뷰 보완으로 private publisher reflection과 after-commit mock 검증을 제거하고 실패 케이스를 요청 전후 DB count 불변으로 검증하도록 낮춘 뒤 focused 명령을 다시 실행해 `BUILD SUCCESSFUL in 3m 53s`를 확인했다. +- `P5-T3` legacy/community 회귀 (2026-07-28): 병렬 실행 중 legacy 단독 명령 1회가 QueryDSL Q-class 미해결 compile 오류로 실패했으나, 같은 시점의 community package 회귀는 `BUILD SUCCESSFUL in 5m 53s`였다. 이후 legacy 단독 명령을 순차 재실행해 `BUILD SUCCESSFUL in 5m 5s`를 확인했다. +- `P5-T3` lint/diff 검증 (2026-07-28): `./gradlew ktlintCheck --rerun-tasks`는 `BUILD SUCCESSFUL in 46s`였고, `git diff --check`는 출력이 없었다. 구현 범위는 community controller/facade의 생성 endpoint와 생성 통합 테스트이며 P5-T4/P5-T5 endpoint는 추가하지 않았다. + +### `P6-T1` 완료 — 2026-07-28 + +- 상태: 완료 +- 무엇을: `LegacyFanTalkReplyCharacterizationTest`로 legacy `ExplorerService.writeCheers`의 root parent 연결, + writer/creator 저장, response mapper, blank 언어 감지 event, missing parent root 전환, nested/inactive parent 허용, + 중복 creator 답변과 creator/blocked 오류를 고정했다. +- 왜: Phase 6 v2가 legacy 저장·응답·이벤트 의미는 재사용하되 root/active/owner 검증 없는 legacy 허용 범위는 복제하지 않기 때문이다. +- 어떻게: production 변경 없이 focused test를 먼저 실행하고, import 정렬 1건을 수정한 뒤 focused test와 `ktlintCheck`를 재실행했다. +- 결과: 첫 focused 실행은 `BUILD SUCCESSFUL in 9s`, import 정렬 후 focused 재실행은 `BUILD SUCCESSFUL in 28s`, + `ktlintCheck`는 `BUILD SUCCESSFUL in 26s`였다. `git diff --check`는 출력이 없었다. 전체 `./gradlew test`는 production + 변경이 없고 focused test가 직접 legacy service 경계를 실행하므로 생략했다. +- 남은 항목: `P6-T2` FanTalk 관리자 목록 조회 구현. + +### `P6-T5` 완료 — 2026-07-28 + +- 상태: 완료 +- 무엇을: 실제 FanTalk list/reply endpoint의 JWT 비ADMIN과 stale ADMIN claim 거부 행렬, 빈/공백 content와 + malformed/missing body·content binding의 KO/EN/JA 400 `ApiResponse.error` envelope를 추가했다. +- 왜: 공통 prefix 인가만으로는 실제 FanTalk endpoint의 이중 ADMIN 증거가 없었고 빈 content가 저장되는 결함이 있었기 때문이다. +- 어떻게: contract RED에서 빈/공백 3개 locale assertion이 200으로 실패함을 확인한 뒤 `createReply` 시작부에 + `request.content.isBlank()` 공통 guard만 추가했다. existing `AiCharacterAdminFanTalkReplyCreateTest`의 target creator writer/creator + assertion을 재사용해 admin/principal impersonation 없음도 확인했다. +- 결과: `AiCharacterAdminFanTalkReplyContractTest`는 `BUILD SUCCESSFUL in 53s`, + `AiCharacterAdminAuthorizationTest`는 `BUILD SUCCESSFUL in 46s`, FanTalk package는 `BUILD SUCCESSFUL in 50s`, + `ktlintCheck`는 unused import 1건 제거 뒤 `BUILD SUCCESSFUL in 31s`였다. +- 남은 항목: `P6-GATE`. + +### `P6-GATE` 완료 — 2026-07-28 + +- 상태: 완료 +- 무엇을: Phase 6 FanTalk 목록, root reply 저장, target/root/ownership 거부와 보안·오류 회귀를 최종 판정했다. +- 왜: Phase 7로 진행하기 전 P6-T1~P6-T5의 endpoint 계약과 package 회귀 성공 증거를 확정해야 하기 때문이다. +- 어떻게: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.*'`와 + `./gradlew ktlintCheck`를 실행했다. +- 결과: FanTalk package는 `BUILD SUCCESSFUL in 50s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 31s`였다. 전체 + `./gradlew test`는 Phase 7 범위이므로 실행하지 않았다. +- 문서/변경 범위 검증: `./gradlew tasks --all`은 `BUILD SUCCESSFUL in 1s`였고 `test`, `ktlintCheck`, `tasks` task가 + 존재했다. `git diff --check`는 출력 없이 통과했다. +- 남은 항목: `P7-T1` 대기. + +### `P7-T1` 완료 — 2026-07-28 + +- 상태: 완료 +- 무엇을: Phase 1~6 targeted test와 전체 회귀 필요성을 판정했다. +- 왜: 최종 통합 Gate 전에 신규 AI 캐릭터 관리자 API 전체와 공통 JWT/인가 경계의 최신 회귀 결과가 필요하기 때문이다. +- 어떻게: 계획의 targeted 명령인 `./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests + 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*'`를 실행했다. +- 결과: targeted test는 `BUILD SUCCESSFUL in 3m 50s`였고, 10개 task 중 1 executed, 9 up-to-date였다. 직전 fresh 검증으로 + FanTalk package는 `BUILD SUCCESSFUL in 27s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 2s`, `git diff --check`는 출력 없이 + 통과했다. +- 전체 회귀 판정: 이번 Goal은 production 코드를 추가 변경하지 않았고 targeted 범위가 `TokenProviderTest`, 신규 prefix 전체, + `AiCharacterAdminAuthorizationTest`, `AiCharacterAdminErrorContractTest`, Phase 1~6 신규 slice 회귀를 포함한다. 공통 경계나 + legacy/public runtime 변경을 새로 만들지 않았으므로 전체 `./gradlew test`는 생략한다. +- 남은 항목: `P7-T2` API contract와 변경 범위 점검. + +### `P7-T2` 완료 — 2026-07-28 + +- 상태: 완료 +- 무엇을: API contract, 변경 범위, dependency/DDL, 신규 v2 import 방향, source spec acceptance criteria를 read-only로 점검했다. +- 왜: 최종 Gate 전에 신규 기능 추가 없이 계획·PRD·OpenAPI·diff가 같은 상태를 가리키는지 확인하기 위해서다. +- 어떻게: `git diff --name-only`, `git diff --check`, `build.gradle.kts` 확인, DDL/migration 경로 검색, 신규 + `v2/api/admin/aicharacter` import 검색, `api-contract.openapi.json` operationId 검색, PRD §11 acceptance criteria 대조, + `./gradlew ktlintCheck`, `./gradlew tasks --all`을 실행했다. +- 결과: `git diff --check`는 출력 없이 통과했다. `build.gradle.kts` diff는 없고, 변경 파일 중 `.sql`/migration/DDL 경로는 0건이다. + `api-contract.openapi.json`에는 23개 operationId가 있으며 Phase 2~6 구현 범위와 일치한다. 신규 package import 검색에서 기존 + controller 역참조는 없었다. FanTalk facade의 공개 v2 `CreatorChannelFanTalk*Response` import는 계획과 PRD가 명시한 FanTalk 목록 + 응답 형태 재사용 예외라 유지한다. +- Acceptance 대조: PRD §11의 ADMIN 이중 인가, 401/403, API 오류/i18n, 405/406/415/500, CORS/firewall, full-context security, + target resolver, binding exception, no-side-effect, cross-owner, character/content/series/community/FanTalk parity, legacy/public + 회귀, 신규 dependency/DDL 없음은 Phase 1~6 Gate와 `P7-T1` targeted 결과로 모두 추적됐다. +- 검증: `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 1s`, `./gradlew tasks --all`은 `BUILD SUCCESSFUL in 11s`였고 `test`, + `ktlintCheck`, `tasks` task가 존재했다. +- 남은 항목: `P7-GATE` 최종 완료 판정. + +### `P7-GATE` 완료 — 2026-07-28 + +- 상태: 완료 +- 무엇을: Phase 1~7 완료 증거, review finding 종결 상태, 최신 targeted/lint/tasks/diff 결과를 대조해 최종 완료로 판정했다. +- 왜: 신규 AI 캐릭터 관리자 API 구현을 더 진행하지 않고 인수 가능한 상태인지 확인하기 위해서다. +- 어떻게: `REV-001`~`REV-020` 종합 표가 모두 `처리 완료`인지 확인하고, 독립 Oracle read-only 리뷰를 받아 Gate 차단 finding이 + 없음을 확인했다. 이어 `git status --short --untracked-files=all`, `git diff --check`, `./gradlew ktlintCheck`를 fresh 실행했다. +- 결과: Oracle 리뷰는 PASS였고 Blocker/Important finding은 없었다. `git status --short --untracked-files=all`은 Phase 2~6의 + 의도된 tracked/untracked 변경 범위를 보여줬으며 신규 dependency/DDL 경로는 없었다. `git diff --check`는 출력 없이 통과했고, + `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 2s`였다. +- 최종 판정: `P7-T1` targeted test `BUILD SUCCESSFUL in 3m 50s`, `P7-T2` `ktlintCheck`/`tasks --all`/diff 점검, 전체 회귀 + 생략 근거, review finding 처리 완료 기록이 모두 충족되어 문서 상태를 `구현 완료`로 갱신했다. +- 남은 항목: 없음. 커밋은 수행하지 않았다. + +### `P7-GATE` 후속 OpenAPI 불일치 수정 — 2026-07-28 + +- 상태: 완료 +- 무엇을: `removeAiCharacterSeriesContent` 구현을 OpenAPI 원본과 같은 + `DELETE /api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents/{contentId}`로 정합화하고 request body DTO를 제거했다. +- 왜: OpenAPI는 path `contentId`와 body 없음이 기준인데 기존 구현은 `/contents` + JSON body `{contentId}`를 받아 계약과 달랐기 때문이다. +- 어떻게: series content, contract, authorization 테스트를 body 없는 path `contentId` 호출로 먼저 바꿔 RED를 확인한 뒤 controller mapping과 + DTO만 최소 변경했다. 신규 API라 dual route나 하위호환 shim은 추가하지 않았다. +- 결과: RED focused 실행은 기존 `/contents/{contentId}` 미매핑으로 5건이 실패했다. GREEN focused 실행 후 최신 series package 회귀 + `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.*'`는 `BUILD SUCCESSFUL in 24s`, + `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 2s`, `git diff --check`는 출력 없이 통과했다. diff --git a/docs/20260724_AI캐릭터_관리자_API/prd.md b/docs/20260724_AI캐릭터_관리자_API/prd.md index db7c4ba6..480cfb5d 100644 --- a/docs/20260724_AI캐릭터_관리자_API/prd.md +++ b/docs/20260724_AI캐릭터_관리자_API/prd.md @@ -1,14 +1,18 @@ # PRD: AI 캐릭터 관리자 API ## 1. Overview -운영자가 AI 캐릭터용 Member로 직접 로그인하지 않고, `ADMIN` 권한으로 선택한 AI 캐릭터의 크리에이터 채널 자산을 대리 관리하는 신규 v2 관리자 API를 제공한다. +운영자가 AI 캐릭터용 Member로 직접 로그인하지 않고, `ADMIN` 권한으로 선택한 AI 캐릭터의 크리에이터 채널 자산과 팬 상호작용을 +대리 관리하는 신규 v2 관리자 API를 제공한다. --- ## 2. Problem -- AI 캐릭터용 `Member(memberKind = AI_CHARACTER)`는 직접 로그인할 수 없어야 하지만, 운영자는 캐릭터의 콘텐츠, 시리즈, 커뮤니티, FanTalk 답변을 관리해야 한다. +- AI 캐릭터용 `Member(memberKind = AI_CHARACTER)`는 직접 로그인할 수 없어야 하지만, 운영자는 캐릭터의 콘텐츠, 시리즈, + 커뮤니티, 각 자산의 댓글과 FanTalk를 관리해야 한다. - 기존 기능은 `creatorMember.id` 기반으로 흩어져 있으며, 관리자 frontend가 레거시 endpoint를 조합하면 권한, 소유권, soft delete 의미가 일관되지 않을 수 있다. - 기존 creator/admin service 일부에는 소유권 검증이 약한 경로가 있어, 단순 위임만으로는 다른 캐릭터나 HUMAN creator 자원을 변경할 위험이 있다. +- 캐릭터 등록에 필요한 원작 검색과 시리즈 등록에 필요한 장르 목록은 범용 관리자 API에만 있어 캐릭터 관리자 배포 Origin에서 + 호출할 수 없다. - 기존 legacy/public API 계약은 유지해야 하므로 신규 관리자 표면은 별도 v2 경계로 제공되어야 한다. --- @@ -16,10 +20,13 @@ ## 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`를 받지 않고, 캐릭터 생성은 새 `ChatCharacter`를 만드는 endpoint라 path `characterId`를 받지 않는다. +- 모든 신규 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에서 관리한다. +- 캐릭터, 오디오 콘텐츠와 댓글, 시리즈, 커뮤니티 게시글과 댓글, FanTalk 목록·답변 작성·수정·팬 원글 삭제, 오디오 + signed URL을 신규 관리자 API에서 관리한다. - 기존 legacy/public endpoint의 URI, 성공·오류 HTTP status, response body, message/i18n을 포함한 외부 계약은 변경하지 않는다. 단, 캐릭터 관리자 frontend가 기존 관리자 인증을 재사용할 수 있도록 `/admin/member/login`, `/member/logout`의 CORS 허용 Origin만 path-specific으로 확장한다. @@ -38,14 +45,16 @@ - 기존 soft delete 의미 변경은 포함하지 않으며, 변경이 필요하면 재확인한다. - 물리 삭제와 연관 데이터 cascade 삭제는 포함하지 않는다. - 신규 DB schema/DDL 또는 `ChatCharacter`-`Member` 관계 모델 변경은 포함하지 않는다. -- 라이브, DM, 후원, 정산, 알림 설정, 랭킹 관리, 콘텐츠 구매/좋아요/댓글, 커뮤니티 구매/좋아요/댓글 관리는 포함하지 않는다. -- FanTalk 원글 작성, 일반 사용자 대리 작성, nested reply 작성은 포함하지 않는다. +- 라이브, 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 답변을 관리하는 관리자 +- 운영자: AI 캐릭터를 대신해 캐릭터 프로필, 콘텐츠, 시리즈, 커뮤니티 게시글, 각 자산의 댓글과 FanTalk를 관리하는 관리자 - 관리자 frontend: 신규 v2 AI 캐릭터 관리자 API만으로 In-Scope 작업을 수행해야 하는 클라이언트 - 서버 개발자: 기존 creator 기능을 회귀시키지 않으면서 AI 캐릭터 대리 관리 경계를 유지해야 하는 개발자 @@ -53,10 +62,17 @@ ## 6. User Stories - 운영자는 AI 캐릭터 목록을 검색하고 상세 정보를 확인한 뒤 생성, 수정, 비활성화하고 싶다. +- 운영자는 캐릭터 등록 시 soft delete되지 않은 원작을 검색해 `originalWorkId`를 선택하고 싶다. - 운영자는 선택한 AI 캐릭터 소유 오디오 콘텐츠를 조회, 생성, 수정, soft delete하고 관리자 화면에서 재생 가능한 signed URL을 받고 싶다. +- 운영자는 선택한 AI 캐릭터의 오디오 콘텐츠에 달린 댓글·답글을 조회하고, 캐릭터 명의로 작성·수정하며, 부적절한 댓글·답글을 + soft delete하고 싶다. - 운영자는 선택한 AI 캐릭터 소유 시리즈를 조회, 생성, 수정, soft delete하고 콘텐츠 연결/해제/순서를 관리하고 싶다. +- 운영자는 시리즈 등록 시 활성 장르 목록에서 `genreId`를 선택하고 싶다. - 운영자는 선택한 AI 캐릭터 소유 커뮤니티 게시글을 작성, 수정, 고정/해제, soft delete하고 싶다. -- 운영자는 선택한 AI 캐릭터의 FanTalk 목록과 기존 creator reply를 확인한 뒤 활성 root FanTalk에 답변하고 싶다. +- 운영자는 선택한 AI 캐릭터의 커뮤니티 게시글에 달린 댓글·답글을 조회하고, 캐릭터 명의로 작성·수정하며, 부적절한 댓글·답글을 + soft delete하고 싶다. +- 운영자는 선택한 AI 캐릭터의 FanTalk 목록과 기존 creator reply를 확인한 뒤 활성 root FanTalk에 답변하고, 기존 답변의 + 내용·활성 상태를 수정하거나 팬이 작성한 원글을 soft delete하고 싶다. - 서버는 다른 AI 캐릭터나 HUMAN creator의 resource ID가 전달되면 변경 없이 4xx로 거부해야 한다. --- @@ -70,7 +86,9 @@ - JWT가 없거나 잘못됐거나 만료·폐기된 경우는 401, JWT role과 현재 DB role 중 하나라도 ADMIN이 아닌 경우는 403으로 처리한다. - JWT에는 `ROLE_ADMIN`이 남아 있지만 현재 DB role이 강등된 stale claim도 403으로 거부한다. -- 모든 domain write/read는 `characterId`로 `ChatCharacter`를 조회한 뒤 연결된 `creatorMember`를 사용한다. +- 선택한 캐릭터 자원을 다루는 domain write/read는 `characterId`로 `ChatCharacter`를 조회한 뒤 연결된 `creatorMember`를 + 사용한다. 원작 검색과 시리즈 장르 목록은 target 없는 reference endpoint이므로 `characterId`를 받거나 target을 해석하지 + 않는다. - `creatorMember`는 도메인 소유권/작성자 판단에만 사용하고 Spring Security principal로 교체하지 않는다. - `creatorMember` 누락, role 불일치, memberKind 불일치 요청은 4xx로 거부한다. - 요청 중 누락 Member 생성, role/memberKind 자동 보정 같은 lazy repair는 하지 않는다. @@ -84,12 +102,16 @@ #### 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 @@ -102,24 +124,48 @@ - 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. 커뮤니티 게시글 관리 @@ -128,12 +174,28 @@ - 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 답변 +### Feature F. FanTalk 관리 #### Requirements - 선택한 AI 캐릭터의 FanTalk 목록과 각 root 글의 creator reply 목록을 관리자 전용 endpoint로 조회한다. @@ -143,10 +205,29 @@ - 요청은 `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 삭제·수정이나 별도 이벤트를 발생시키지 않는다. --- @@ -188,18 +269,46 @@ - request/response의 기계 검증 가능한 단일 기준은 같은 디렉터리의 `api-contract.openapi.json`이다. 설명과 레거시 근거는 `api-contract.md`에 기록한다. - 신규 endpoint는 레거시 API의 request/response 필드명, 타입, optional/nullable, 기본값과 성공 `data` 형태를 그대로 - 이관한다. `characterId`, `contentId`, `seriesId`, `postId`, `fanTalkId`처럼 신규 path로 이동한 ID만 request body에서 - 중복 제거한다. + 이관한다. `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`를 사용한다. + `fanTalkId`, `replyId`, `creatorMemberId`, `content`, `createdAtUtc`를 사용한다. FanTalk 답변 수정은 레거시 + `CreatorChannelFanTalkResponse` 필드 형태를 유지한다. +- 신규 댓글 작성·수정·삭제와 FanTalk 원글 삭제의 성공 응답은 모두 `ApiResponse.ok(null)`을 사용한다. - request/response 구현 DTO는 신규 v2 AI character admin API 전용으로 둘 수 있지만, JSON 외부 계약은 `api-contract.openapi.json`의 레거시 필드명과 형태를 유지한다. @@ -231,7 +340,14 @@ - `HttpMessageNotReadableException`, `MethodArgumentNotValidException`, `MissingServletRequestPartException`의 exact exception type과 KO/EN/JA 400 envelope 직접 검증 여부 - target/ownership 실패 시 no-side-effect 테스트 존재 여부 -- character/content/series/community/FanTalk slice별 targeted test 통과 여부 +- 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 회귀 테스트 통과 여부 @@ -260,17 +376,47 @@ - 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. Open Questions +## 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 - 없음. diff --git a/docs/20260724_AI캐릭터_관리자_API/reviews/phase1-common-security-review.md b/docs/20260724_AI캐릭터_관리자_API/reviews/phase1-common-security-review.md new file mode 100644 index 00000000..db236c20 --- /dev/null +++ b/docs/20260724_AI캐릭터_관리자_API/reviews/phase1-common-security-review.md @@ -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 요구사항 충족, 추가 수정 없음. + +**남은 항목:** 없음. diff --git a/docs/20260724_AI캐릭터_관리자_API/reviews/phase2-character-review.md b/docs/20260724_AI캐릭터_관리자_API/reviews/phase2-character-review.md index 118b0239..8b873530 100644 --- a/docs/20260724_AI캐릭터_관리자_API/reviews/phase2-character-review.md +++ b/docs/20260724_AI캐릭터_관리자_API/reviews/phase2-character-review.md @@ -9,7 +9,7 @@ | 리뷰 일자 | 2026-07-27 | | 리뷰어 | Sisyphus | | 기준 문서 | `docs/20260724_AI캐릭터_관리자_API/prd.md`, `docs/20260724_AI캐릭터_관리자_API/plan-task.md` | -| 리뷰 상태 | 판정 완료 | +| 리뷰 상태 | 후속 수정 및 Gate 완료 | ## 2. 리뷰 목적과 범위 @@ -581,3 +581,433 @@ Endpoint Contract Summary를 source of truth로 사용하는 client는 제공 **최종 결론:** Phase 2 6차 리뷰 종결 **남은 항목:** `P3-R7` → `P3-R8` → `P3-R5-GATE`. Phase 4는 진행하지 않는다. + +## 15. 7차 정적 리뷰 및 판정 — 2026-07-28 + +### 리뷰 정보와 검증 범위 + +- 기준 commit/working tree: `2f93e2c9` + 현재 Phase 2~7 working tree +- 기준 문서: PRD Feature B, `plan-task.md`, `api-contract.openapi.json` +- 리뷰 상태: 판정 완료, 후속 수정 goal 필요 +- 검증 방식: controller/facade/mapper/test 호출을 정적으로 추적했다. 사용자 요청에 따라 Gradle, 컴파일, 테스트는 + 실행하지 않았다. + +### 추가 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-021` | Low | 처리 완료 | mutation에서 사용하지 않는 전체 response mapping 수행 | `Task 2.12` | `P2-R6` | + +### REV-021 — mutation의 미사용 response mapping + +- **심각도:** Low +- **상태:** 처리 완료 +- **관련 요구사항:** PRD Feature B, 레거시 mutation 성공 응답 유지 +- **관련 계약:** Character POST/PUT의 성공 `data`는 `null` +- **소유 Task:** `Task 2.12`, `P2-R6` + +**관찰 내용** + +controller는 create/update facade 반환값을 사용하지 않고 항상 `ApiResponse.ok(null)`을 반환한다. 그러나 facade는 두 +mutation 마지막에 전체 `AiCharacterAdminCharacterResponse`를 생성한다. 외부 계약에 필요 없는 객체 그래프 mapping이 +write 성공 뒤 추가 실패 지점과 유지보수 비용을 만든다. + +**근거** + +- 코드: `AiCharacterAdminCharacterController.kt:34`~`50`은 facade 호출 뒤 exact `data: null`을 반환한다. +- 코드: `AiCharacterAdminCharacterFacade.kt:62`, `:116`은 response DTO 반환형을 선언한다. +- 코드: 같은 파일 `:112`, `:163`은 controller가 버리는 `characterMapper.toResponse(...)`를 수행한다. +- 계약: OpenAPI Character mutation은 `NullSuccess`를 사용한다. + +**권장 조치** + +`P2-R6`에서 create/update facade 반환형을 `Unit`으로 축소하고 두 mapper 호출만 제거한다. controller와 공개 schema, +business pipeline은 변경하지 않고 기존 mutation exact `data: null` 회귀로 동작 불변을 확인한다. + +### plan·goal 전환 + +`plan-task.md` Phase 2에 `Task 2.12` / `P2-R6`과 `P2-R6-GATE`를 추가했다. 이전 완료 Task/Gate는 다시 열지 않는다. + +### 7차 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 문서·구현 대조 | 충족 | Character 4개 operation과 controller/facade 정적 추적 | +| 후보 판정 | 충족 | `REV-021` 확정 | +| plan 반영 | 충족 | `Task 2.12`, `P2-R6`, `P2-R6-GATE` 추가 | +| 실행 검증 | 미실행 | 사용자 요청에 따라 compile/test 미실행 | + +**최종 결론:** 수정 goal 필요 + +**남은 항목:** `P2-R6` 실행 후 `P2-R6-GATE`에서 Phase 2를 재판정한다. + +### P2-R6-GATE 종료 판정 — 2026-07-28 + +- 무엇을: `REV-021`의 캐릭터 POST/PUT 미사용 response mapping 제거를 최종 판정했다. +- 왜: mutation 성공 응답은 `data: null`인데 facade가 controller가 사용하지 않는 상세 DTO를 만들고 있었기 때문이다. +- 어떻게: `AiCharacterAdminCharacterFacade.create/update` 반환형을 `Unit`으로 축소하고 마지막 `characterMapper.toResponse(...)` 호출만 제거했다. 이후 mutation focused, character/common 회귀, `ktlintCheck`, `git diff --check`를 실행했다. +- 결과: mutation focused는 `BUILD SUCCESSFUL in 1m 54s`, character/common 회귀는 `BUILD SUCCESSFUL in 1m 57s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 33s`, `git diff --check`는 출력 없음이었다. + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| `REV-021` 처리 | 충족 | facade 반환형 축소와 미사용 mapper 호출 제거 | +| actual endpoint 회귀 | 충족 | POST/PUT mutation `data: null` focused test 성공 | +| 영향 범위 회귀 | 충족 | character/common 회귀, lint, diff check 성공 | +| 범위 준수 | 충족 | business pipeline, controller response, schema 변경 없음 | + +**최종 결론:** Phase 2 7차 리뷰 종결 + +**남은 항목:** `P3-R9` 실행 후 `P3-R9-GATE`에서 Phase 3을 재판정한다. + +## 16. 8차 정적 리뷰 및 판정 — 2026-07-28 + +### 리뷰 정보와 범위 + +- 기준 commit/working tree: `2f93e2c9` + 현재 working tree +- 기준 문서: PRD Feature B, plan Phase 2, OpenAPI Character 4개 operation +- 검토 범위: controller/facade/DTO/mapper, 외부 API·S3·DB/event 순서와 관련 actual endpoint 테스트 +- 검증 방식: 정적 호출·schema 대조. 사용자 요청에 따라 컴파일과 테스트는 실행하지 않았다. + +### 발견 사항과 판정 + +확정 발견 사항 없음. + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| operation/schema | 충족 | Character 4개 mapping과 OpenAPI request/response 형태 일치 | +| target/ownership | 충족 | detail/update가 공통 resolver와 character target을 사용 | +| mutation 응답 | 충족 | POST/PUT `data: null`, 미사용 상세 mapping 제거 상태 유지 | +| 외부 부작용 경계 | 신규 finding 없음 | 기존 선검증·rollback/비보상 결정과 테스트 존재 | +| plan 전환 | 해당 없음 | Phase 2 신규 Task 불필요 | + +**최종 결론:** Phase 2 추가 수정 없음 + +**남은 항목:** 없음. `P7-R2` 통합 재판정에서 기존 Character/common 회귀만 확인한다. + +## 17. 9차 정적 리뷰 및 판정 — 2026-07-28 + +### 리뷰 정보와 범위 + +- 기준 commit/working tree: `2f93e2c9` + 현재 working tree +- 기준 문서: PRD Feature B, plan Phase 2, OpenAPI Character 4개 operation +- 검토 범위: controller/facade/mapper, multipart 생성, optional update field와 레거시 controller/service parity +- 검증 방식: 정적 호출·schema·테스트 대조. 컴파일과 테스트는 실행하지 않았다. + +### 발견 사항 + +#### `REV-034` — Medium — 캐릭터 생성의 빈 필수 image 허용 + +- OpenAPI `CharacterCreateMultipart`는 `image`를 required로 선언한다. +- controller는 non-null part 존재까지만 강제하고, facade는 `image?.isEmpty == false`일 때만 upload한다. +- 빈 part는 외부 API 생성과 DB 저장을 먼저 수행한 뒤 image upload를 건너뛰므로 이미지 없는 캐릭터와 관련 부작용을 남긴다. +- 기존 mutation 테스트는 image part 누락은 다루지만 빈 part를 다루지 않는다. + +**권장 조치:** facade 진입 직후 빈 image를 400 `common.error.invalid_request`로 거부하고 외부 API, DB, S3, +event 0회를 actual endpoint로 고정한다. + +#### `REV-035` — Medium — `isActive=true` 단독 수정의 레거시 parity 위반 + +- OpenAPI `CharacterUpdateRequest.isActive`는 nullable boolean이며 `true`도 유효하다. +- 레거시 `AdminChatCharacterController.hasChanges`는 `isActive != null`을 변경 요청으로 인정해 200 + `data: null` pipeline을 수행한다. +- v2 mapper는 `request.isActive == false`만 external change로 인정하므로 `{"isActive":true}` 단독 요청이 + facade no-change guard에서 400이 된다. + +**권장 조치:** non-null `isActive`를 변경 요청으로 인정하되 기존 `false` 비활성화 의미와 일반 update pipeline은 +변경하지 않는다. + +### plan·goal 전환 + +`plan-task.md` Phase 2에 `Task 2.13` / `P2-R7`과 `P2-R7-GATE`를 추가했다. 두 finding은 같은 facade/mapper와 +mutation test 범위이므로 하나의 최소 보완 Task로 묶었다. + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| operation/schema | route 유지 | Character 4개 mapping은 유지되나 두 runtime 의미 불일치 존재 | +| multipart create | 수정 필요 | 빈 required image가 외부·DB 부작용 뒤 무시됨 | +| update parity | 수정 필요 | `isActive=true` 단독 요청이 legacy와 다른 400 | +| plan 반영 | 충족 | `Task 2.13`, `P2-R7`, `P2-R7-GATE` 추가 | +| 실행 검증 | 미실행 | 사용자 요청에 따라 컴파일·테스트 미실행 | + +**최종 결론:** Phase 2 후속 수정 필요 + +**남은 항목:** `P2-R7` → `P2-R7-GATE`. + +## 18. 9차 후속 수정 및 Gate — 2026-07-29 + +- 무엇을: `REV-034`, `REV-035`를 처리했다. +- 왜: 빈 필수 `image`가 외부 생성·DB 저장 뒤 무시되고, `isActive=true` 단독 수정이 레거시와 달리 400으로 거부됐기 때문이다. +- 어떻게: + - RED: `AiCharacterAdminCharacterControllerMutationTest`에 빈 image POST 400/no-side-effect와 `isActive=true` PUT 200 `data:null` actual endpoint 테스트를 추가했다. focused 실행에서 신규 2건이 실패했다. + - GREEN: create facade가 empty image를 400으로 거부하고, mapper가 non-null `isActive`를 변경 요청으로 인정하도록 최소 수정했다. + - 검증: focused mutation test, `./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*'`, 전체 `./gradlew test`, `./gradlew ktlintCheck`, OpenAPI/mapping/diff 점검을 실행했다. +- 결과: `REV-034`, `REV-035` 처리 완료. 빈 image는 외부 API·DB·S3·event 전에 400으로 종료되고, `isActive=true` 단독 PUT은 200 `data:null`로 통과한다. + +**최종 결론:** Phase 2 9차 리뷰 종결 + +**남은 항목:** 없음. + +## 19. 10차 정적 리뷰 및 판정 — 2026-07-29 + +### 리뷰 범위와 방식 + +- 기준 commit/working tree: `2f93e2c9` + 현재 working tree +- 기준 문서: PRD Feature B, plan Phase 2, OpenAPI Character 4개 operation +- 검토 범위: 목록·상세·생성·수정 controller/facade/mapper, 레거시 parity와 최신 `REV-034`~`REV-035` 보완 +- 검증 방식: 코드·schema·테스트 정적 대조. 컴파일과 테스트는 실행하지 않았다. + +### 발견 사항과 판정 + +Character runtime의 신규 확정 finding은 없다. 빈 필수 image는 외부·DB·S3·event 전에 거부되고, +`isActive=true` 단독 수정은 레거시 200 `data: null` 의미를 유지한다. + +`Task 2.13` 헤더가 `[ ]`로 남은 문제는 기능 문제가 아닌 전체 완료 상태 기록 불일치였고, +Phase 7 `REV-038` / `Task 7.6`에서 완료 상태로 동기화했다. + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| operation/schema | 충족 | Character 4개 route와 OpenAPI field·multipart 경계 일치 | +| ownership/인가 | 충족 | 목록 외 target route와 공통 ADMIN 이중 인가 유지 | +| mutation parity | 충족 | create/update/soft delete와 최신 empty/no-op 보완 유지 | +| Phase 2 기능 Task | 해당 없음 | 신규 production 수정 불필요 | +| 문서 상태 | 충족 | `REV-038`, `P7-R4`에서 완료 헤더 동기화 | + +**최종 결론:** Phase 2 기능 추가 수정 없음 + +**남은 항목:** 없음. + +## 20. 11차 정적 리뷰 및 판정 — 2026-07-29 + +### 확인된 문제 + +#### `REV-040` — 필수 관계 정수의 누락·null이 기본값으로 보정될 수 있음 + +- **심각도:** Medium +- **상태:** 처리 완료 +- **계약:** OpenAPI `CharacterRelationship`은 `importance`를 required non-null integer로 정의한다. +- **구현:** `ChatCharacterRelationshipRequest.importance`는 Kotlin `Int`이고, v2 character facade의 strict reader는 + `FAIL_ON_UNKNOWN_PROPERTIES`와 `FAIL_ON_NULL_FOR_PRIMITIVES`를 활성화한다. +- **근거:** 사용 중인 Jackson Kotlin/databind 2.13.5에서 `FAIL_ON_NULL_FOR_PRIMITIVES` 기본값은 비활성화되어 + Kotlin/JVM primitive의 누락·null이 `0`으로 역직렬화될 수 있다. +- **영향:** 잘못된 관계 입력이 400으로 거부되지 않고 외부 캐릭터 생성·DB mutation으로 이어질 수 있다. + +### 보완 계획 + +| 항목 | 판정 | +|---|---| +| 신규 Task | `Task 2.14` / `P2-R8` | +| Gate | `P2-R8-GATE` | +| RED | `importance` 누락·null actual POST와 외부 API·DB·S3·event 결과 | +| GREEN | v2 생성 경계의 primitive null/누락 400 변환 완료 | +| 범위 제한 | 전역 mapper·레거시 DTO·OpenAPI 변경 없음 | + +### 처리 결과 + +- `AiCharacterAdminCharacterControllerMutationTest`에 관계 `importance` 누락·null actual POST와 외부 API·DB·S3·event no-side-effect 회귀를 추가했다. +- `AiCharacterAdminCharacterFacade.readRequest()`에 `FAIL_ON_NULL_FOR_PRIMITIVES`를 추가해 전역 mapper·레거시 DTO·OpenAPI 변경 없이 v2 경계에서 400으로 변환했다. +- RED: 신규 2건은 보완 전 `status().isBadRequest` 기대에서 실패했다. +- GREEN/GATE: 보완 후 focused, character/common 영향 범위, `ktlintCheck`, `git diff --check`를 fresh 실행했다. + +**최종 결론:** Phase 2는 `REV-040` 처리 완료 + +**다음 Goal:** `P3-R12`. + +## 21. 등록 참조 API 후속 검토 — 2026-07-29 + +### 확인 결과 + +- **`REV-044` / High / 구현 대기:** 캐릭터 등록용 원작 검색은 레거시 + `AdminOriginalWorkController.search`와 `AdminOriginalWorkService.searchOriginalWorksAll`에 존재하지만 신규 v2 + 캐릭터 관리자 route에는 없다. +- 검색 계약은 필수 `searchTerm`, 제목·콘텐츠 타입·카테고리 부분 검색, soft delete 제외, 무페이징 + `OriginalWorkResponse` 직접 배열로 확정됐다. +- target 없는 reference endpoint지만 신규 prefix의 ADMIN 이중 인가·오류·CORS 경계는 동일하게 적용한다. + +### plan 전환 + +- 신규 Task: `Task 2.15` / `P2-R9` +- Gate: `P2-R9-GATE` +- 범위 밖: 원작 CRUD, pagination·정렬 추가, 레거시 endpoint 변경 + +사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다. + +**최종 결론:** Phase 2 원작 검색 구현 필요 + +**다음 Goal:** `P2-R9`. + +## 22. 12차 통합 정적 리뷰 및 판정 — 2026-07-29 + +### 리뷰 범위와 방식 + +- 기준 문서: PRD Feature B, OpenAPI Character 5개 operation +- 검토 범위: Character controller/facade/strict request reader, 원작 검색, 외부 API·S3·DB 선검증 경계 +- 기준 상태: 현재 working tree +- 검증 방식: 문서·코드·관련 테스트 정적 대조. 사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다. + +### 확인 결과 + +| 항목 | 판정 | 근거 | +|---|---|---| +| route/operation | 충족 | Character 5개 OpenAPI operation과 실제 mapping 유지 | +| request 전체 media type | 충족 | 생성·수정 mapping은 multipart/form-data로 제한 | +| pagination/reference query | 충족 | 목록 기본값과 원작 검색 필수 `searchTerm` 계약 일치 | +| mutation 경계 | 충족 | strict JSON, 필수 image, target/원작 검증과 기존 외부/S3/DB 순서 유지 | + +### `REV-055` — High — 처리 완료 + +- OpenAPI와 계약 설명은 생성·수정 multipart의 `request` part Content-Type을 `application/json`으로 고정한다. +- controller는 기존 `@RequestPart("request") request: String` strict reader 전달을 유지하면서 multipart part header에서 + `application/json` 호환 여부를 확인한다. +- `text/plain`과 content type 누락은 `HttpMediaTypeNotSupportedException`으로 공통 415 오류 계약에 연결해 + localized `ApiResponse.error`와 `Accept: application/json`을 반환한다. +- POST·PUT actual endpoint의 KO/EN/JA 12개 case는 external API·S3·DB·event 부작용 없이 415를 반환하고, + request part 누락은 기존 400으로 유지한다. + +### plan 전환 + +- 신규 Task: `Task 2.16` / `P2-R10` +- Gate: `P2-R10-GATE` +- 최소 수정: 기존 strict String reader는 유지하고 v2 multipart 경계에서 part-level JSON media type만 강제 +- 완료 조건: POST·PUT 정상 JSON 회귀, 미지원/누락 media type의 KO/EN/JA 415 envelope, `Accept` header, + external/S3/DB/event no-side-effect + +### `P2-R10` / `P2-R10-GATE` 완료 판정 — 2026-07-29 + +- RED: focused mutation test에서 새 `text/plain`·content type 누락 POST·PUT 12개 415 기대 case가 수정 전 실패했다. +- GREEN: controller의 part-level JSON compatibility 확인 뒤 기존 String payload를 facade strict reader에 그대로 전달했다. +- GATE: focused mutation test는 `BUILD SUCCESSFUL in 31s`, Character/common 영향 범위 회귀는 `BUILD SUCCESSFUL in 1m 2s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 18s`, `git diff --check`는 출력 없이 성공했다. +- 전체 `./gradlew test`는 controller part 경계와 직접 영향 Character/common 회귀를 실행했으므로 생략했다. + +**최종 결론:** `REV-055` 처리 완료, Phase 2 완료. + +**다음 Goal:** `P3-R15`. + +## 23. 13차 통합 정적 리뷰 및 판정 — 2026-07-29 + +### 리뷰 범위와 방식 + +- 기준 문서: PRD Feature B, OpenAPI `CharacterCreateMultipart`·`CharacterUpdateMultipart` +- 검토 범위: Character POST·PUT controller의 multipart binding·part media type 검사와 mutation 테스트 +- 검증 방식: 현재 working tree의 문서·코드·테스트를 정적으로 대조했다. 사용자 요청에 따라 컴파일과 테스트는 + 실행하지 않았다. + +### `REV-060` — Medium — 미정의 multipart part를 무시하고 mutation 진행 + +- OpenAPI의 두 Character multipart schema는 `additionalProperties: false`이고 허용 이름을 `image`, `request`로 + 한정한다. +- controller는 선언된 `@RequestPart`를 binding하고 `request`의 `application/json` 여부만 확인한다. + `MultipartHttpServletRequest`의 전체 part 이름 집합은 검사하지 않는다. +- 따라서 정상 `image`·`request`와 `unexpected` part를 함께 보내도 추가 part는 무시되고 create/update facade가 + 실행될 수 있다. +- 잘못된 입력이 성공 mutation으로 이어지므로 계약 정합성 문제로 확정하되, 추가 part 자체를 사용하거나 저장하지는 + 않으므로 심각도는 Medium으로 판정한다. + +### plan 전환 + +| 항목 | 내용 | +|---|---| +| 신규 Task | `Task 2.17` / `P2-R11` | +| Gate | `P2-R11-GATE` | +| RED | POST·PUT의 미정의 file/text part와 외부 API·S3·DB·event 결과 | +| GREEN | 실제 part 이름을 `{image, request}`와 비교해 초과 이름을 공통 400으로 거부 | +| 범위 제한 | OpenAPI·legacy/public·전역 multipart resolver 변경 없음 | + +### `P2-R11` / `P2-R11-GATE` 처리 결과 — 2026-07-29 + +- RED: `AiCharacterAdminCharacterControllerMutationTest`에 Character POST·PUT의 `unexpected` multipart part KO/EN/JA 400/no-side-effect actual endpoint test를 추가했고, 기존 구현은 6개 invocation 모두 400 기대 대비 200으로 실패했다. +- GREEN: `AiCharacterAdminCharacterController`에서 `MultipartHttpServletRequest.fileMap.keys`가 `{image, request}`의 부분집합인지 확인하고 초과 part를 400 `common.error.invalid_request`로 거부했다. +- Gate: undefined part, 기존 request part 415, request part 누락 400 focused 회귀는 `BUILD SUCCESSFUL in 1m 41s`, Character/common 영향 범위 회귀는 `BUILD SUCCESSFUL in 1m 36s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 30s`, `git diff --check`는 출력 없이 성공했다. + +**최종 결론:** `REV-060` 처리 완료, Phase 2 13차 리뷰 종결. + +**다음 Goal:** `P3-R17`. + +## 24. 14차 통합 정적 리뷰 및 판정 — 2026-07-29 + +### 리뷰 범위와 방식 + +- 기준 문서: OpenAPI `CharacterCreateMultipart`·`CharacterUpdateMultipart`의 + `additionalProperties: false`, 허용 part `{image, request}` +- 검토 범위: Character POST·PUT controller의 part allow-list와 미정의 part 회귀 테스트 +- 기준 상태: 현재 working tree +- 리뷰어/상태: Codex / 판정 완료 +- 검증 방식: 문서·코드·테스트 소스 정적 대조. 사용자 지시에 따라 컴파일과 테스트는 실행하지 않았다. + +### `REV-065` — Medium — 일반 form-field multipart part가 allow-list 우회 + +- `AiCharacterAdminCharacterController.kt:71-74`는 `MultipartHttpServletRequest.fileMap.keys`만 검사한다. + filename 없는 일반 form-field part는 file map 대상이 아니므로 `{image, request}` 외 이름을 검출하지 못한다. +- 로컬 의존성 Spring Web 5.3.29의 `StandardMultipartHttpServletRequest.parseRequest` bytecode도 filename이 있는 + part만 multipart file map에 넣고, filename이 없는 part 이름은 별도 parameter 집합에 넣음을 확인했다. +- 기존 `AiCharacterAdminCharacterControllerMutationTest.kt:424-469`는 filename이 있는 + `MockMultipartFile("unexpected", ...)`만 사용해 이 경계를 고정하지 않는다. +- 따라서 `additionalProperties: false` 계약을 일반 form-field part가 우회해 facade mutation으로 진행할 수 있다. + 입력 자체를 저장하지는 않지만 계약 위반과 부작용 가능성이 있어 Medium으로 판정한다. + +### plan 전환 + +| 항목 | 내용 | +|---|---| +| 신규 Task | `Task 2.18` / `P2-R12` | +| Gate | `P2-R12-GATE` | +| RED | filename 없는 `unexpected` part의 POST·PUT 400/no-side-effect | +| GREEN | servlet 전체 part 이름을 `{image, request}`와 비교 | +| 범위 제한 | Character controller/test만 최소 변경, OpenAPI·전역 resolver·legacy/public 변경 없음 | + +**최종 결론:** Phase 2 보완 필요 — `REV-065` 확정 + +**다음 Goal:** `P2-R12`. + +## 24. 8차 후속 수정 및 Gate — 2026-07-29 + +- 무엇을: `REV-065`의 Character POST·PUT filename 없는 일반 form-field multipart part 우회를 보완했다. +- 왜: 기존 `fileMap.keys` 검사만으로는 `{image, request}` 외 일반 form-field part를 mutation 전 거부하지 못했기 때문이다. +- 어떻게: `AiCharacterAdminCharacterControllerMutationTest`에 `shouldRejectFilenameLessUndefinedMultipartPartBeforeSideEffects` KO/EN/JA POST·PUT actual endpoint 회귀를 추가하고, controller가 `fileMap.keys`와 servlet `parts` 이름을 모두 검사하게 했다. +- 결과: RED 묶음에서 신규 multipart/genre 36건 실패를 확인했고, 보완 후 focused GREEN 묶음은 `BUILD SUCCESSFUL in 1m 17s`였다. 영향 범위 회귀와 lint 결과는 `P7-R10-GATE`에 통합 기록한다. + +**최종 결론:** `REV-065` 처리 완료. Phase 2 후속 Gate 완료. + +**남은 항목:** 없음. + +## 25. 15차 정적 리뷰 및 판정 — 2026-07-29 + +### 리뷰 범위와 방식 + +- 기준 문서: PRD Feature C, OpenAPI Character 5개 operation +- 검토 범위: 목록·원작 검색·상세·생성·수정, target/owner, multipart·strict JSON, external/S3/event 순서 +- 검증 방식: 현재 working tree의 문서·production·test 소스를 정적으로 대조했다. 사용자 지시에 따라 컴파일과 + 테스트는 실행하지 않았다. + +### 판정 + +- Character 5개 OpenAPI operation과 controller mapping, target/owner 선검증, strict multipart/JSON 경계를 대조했다. +- 기존 완료 finding 이후 새 계약 불일치나 확정 가능한 production 결함은 확인되지 않았다. +- Phase 3의 `REV-072`는 Character 경로에 영향을 주지 않는다. + +### plan 전환 + +- 신규 Phase 2 finding 및 Task/Gate 없음. + +**최종 결론:** Phase 2 추가 수정 없음. + +**남은 항목:** Phase 3 보완 뒤 `P7-R11` 통합 재판정. + +## 26. 16차 정적 리뷰 및 판정 — 2026-07-30 + +### 리뷰 범위와 방식 + +- 기준 문서: PRD Feature B, OpenAPI Character 5개 operation +- 검토 범위: 목록·원작 검색·상세·생성·수정, AI target, strict multipart/JSON, external API·S3·DB·event 경계 +- 검증 방식: 현재 working tree의 문서·production·test 소스를 정적으로 대조했다. 사용자 지시에 따라 컴파일과 + 테스트는 실행하지 않았다. + +### 판정 + +- Character 5개 operation과 controller mapping, active AI 목록 필터와 target 없는 원작 검색 계약이 일치한다. +- 생성·수정은 exact multipart part와 JSON media type, 필수/nullable·미지 필드, 원작·타입·중복 선검증을 유지한다. +- external API, S3, DB와 event의 기존 호출·rollback/비보상 경계가 계획의 특성화 결과와 일치한다. +- 신규 확정 finding이 없어 Phase 2 회귀 수정 Task/Gate를 추가하지 않는다. + +**최종 결론:** Phase 2 요구사항 충족, 추가 수정 없음. + +**남은 항목:** 없음. diff --git a/docs/20260724_AI캐릭터_관리자_API/reviews/phase3-audio-content-review.md b/docs/20260724_AI캐릭터_관리자_API/reviews/phase3-audio-content-review.md index a9351cfe..6c783cb4 100644 --- a/docs/20260724_AI캐릭터_관리자_API/reviews/phase3-audio-content-review.md +++ b/docs/20260724_AI캐릭터_관리자_API/reviews/phase3-audio-content-review.md @@ -9,7 +9,7 @@ | 리뷰 일자 | 2026-07-27 | | 리뷰어 | Sisyphus | | 기준 문서 | `docs/20260724_AI캐릭터_관리자_API/prd.md`, `docs/20260724_AI캐릭터_관리자_API/plan-task.md` | -| 리뷰 상태 | 판정 완료 | +| 리뷰 상태 | 후속 수정 및 Gate 완료 | ## 2. 리뷰 목적과 범위 @@ -726,3 +726,622 @@ production code는 변경하지 않는다. **최종 결론:** Phase 3 6차 리뷰 종결 **남은 항목:** 없음. 다음은 사용자 진행 지시 후 `P4-T1`이다. + +## 15. 7차 정적 리뷰 및 판정 — 2026-07-28 + +### 리뷰 정보와 검증 범위 + +- 기준 commit/working tree: `2f93e2c9` + 현재 Phase 2~7 working tree +- 기준 문서: PRD Feature C, `plan-task.md`, `api-contract.openapi.json` +- 리뷰 상태: 판정 완료, 후속 수정 goal 필요 +- 검증 방식: production/test 전체 호출 검색과 repository/DTO 정적 추적을 수행했다. 사용자 요청에 따라 Gradle, + 컴파일, 테스트는 실행하지 않았다. + +### 추가 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-022` | Low | 처리 완료 | 관리자 content repository에 호출되지 않는 확장 코드 잔존 | `Task 3.19` | `P3-R9` | + +### REV-022 — 호출되지 않는 repository 확장과 전용 enum + +- **심각도:** Low +- **상태:** 처리 완료 +- **관련 요구사항:** PRD Feature C +- **관련 계약:** 현재 5개 AudioContent operation의 실제 조회/생성/수정 경계 +- **소유 Task:** `Task 3.19`, `P3-R9` + +**관찰 내용** + +현재 facade가 사용하는 관리자 전용 repository method는 owner-scoped 상세 조회 +`findByIdAndCreatorMemberId` 하나다. 동일 repository의 page 조회, series ID 조회/교체, 활성 series 검사와 private helper는 +production/test 호출자가 없고, `AiCharacterAdminAudioContentStatus`도 이 미사용 코드에서만 참조된다. + +**근거** + +- 코드: `AiCharacterAdminAudioContentRepository.kt:50`~`56`의 owner-scoped 상세 조회는 실제 facade 호출 대상이다. +- 코드: 같은 파일 `:25`~`:48`, `:58`~`:142`의 나머지 public/private method는 전체 호출 검색 결과 외부 참조가 없다. +- 코드: `AiCharacterAdminAudioContentDto.kt:28`~`31`의 status enum은 위 미사용 page query에서만 참조된다. + +**권장 조치** + +`P3-R9`에서 실제 사용 중인 상세 조회만 보존하고 호출 0건 method, helper, enum 및 그로 인해 unused가 된 import만 제거한다. +legacy repository나 콘텐츠/series 동작은 변경하지 않고 상세·ownership 회귀로 동작 불변을 확인한다. + +### plan·goal 전환 + +`plan-task.md` Phase 3에 `Task 3.19` / `P3-R9`과 `P3-R9-GATE`를 추가했다. 이전 완료 Task/Gate는 다시 열지 않는다. + +### 7차 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 문서·구현 대조 | 충족 | AudioContent 5개 operation과 facade/repository 호출 정적 추적 | +| 후보 판정 | 충족 | `REV-022` 확정 | +| plan 반영 | 충족 | `Task 3.19`, `P3-R9`, `P3-R9-GATE` 추가 | +| 실행 검증 | 미실행 | 사용자 요청에 따라 compile/test 미실행 | + +**최종 결론:** 수정 goal 필요 + +**남은 항목:** `P2-R6-GATE` 후 `P3-R9`를 실행하고 `P3-R9-GATE`에서 Phase 3을 재판정한다. + +### P3-R9-GATE 종료 판정 — 2026-07-28 + +- 무엇을: `REV-022`의 관리자 오디오 repository 미사용 확장 제거를 최종 판정했다. +- 왜: 실제 facade 호출 대상은 owner-scoped 상세 조회 하나뿐이고, 나머지 page/series helper와 전용 status enum은 현재 5개 AudioContent operation에 쓰이지 않기 때문이다. +- 어떻게: `AiCharacterAdminAudioContentRepository`에서 상세 조회 외 method와 helper를 제거하고 `AiCharacterAdminAudioContentStatus`를 삭제했다. package-scoped 호출 검색, 상세·ownership focused, content/common 회귀, `ktlintCheck`, `git diff --check`를 실행했다. +- 결과: 대상 package 호출 검색은 출력이 없었다. 상세·ownership focused는 `BUILD SUCCESSFUL in 3m 39s`, content/common 회귀는 `BUILD SUCCESSFUL in 2m 22s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 31s`, `git diff --check`는 출력 없음이었다. + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| `REV-022` 처리 | 충족 | 미사용 repository method/helper/status enum 제거 | +| static 호출 검색 | 충족 | 대상 admin content package에서 제거 대상 호출 0건 | +| owner-scoped 상세 회귀 | 충족 | detail/ownership focused test 성공 | +| 영향 범위 회귀 | 충족 | content/common 회귀, lint, diff check 성공 | +| 범위 준수 | 충족 | legacy repository/service, 콘텐츠·시리즈 동작 변경 없음 | + +**최종 결론:** Phase 3 7차 리뷰 종결 + +**남은 항목:** `P4-R1` 실행 후 `P4-R1-GATE`에서 Phase 4를 재판정한다. + +## 16. 8차 정적 리뷰 및 판정 — 2026-07-28 + +### 리뷰 정보와 검증 범위 + +- 기준 commit/working tree: `2f93e2c9` + 현재 working tree +- 기준 문서: PRD Feature C·API Expectations, plan Phase 3, OpenAPI AudioContent 5개 operation +- 검토 범위: 생성 request strict parse와 legacy `AudioContentService` 호출, Java time 변환, prefix 예외 handler, + 생성 actual endpoint 테스트 +- 검증 방식: 요청값 → facade → legacy service → 예외 handler와 side-effect 순서를 정적으로 역추적했다. + 사용자 요청에 따라 컴파일과 테스트는 실행하지 않았다. + +### 추가 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-030` | Medium | 처리 완료 | 잘못된 생성 날짜·시간대가 client 오류가 아닌 500으로 반환됨 | `Task 3.20` | `P3-R10` | + +### REV-030 — 생성 날짜·시간대 의미 오류가 500으로 분류됨 + +- **심각도:** Medium +- **상태:** 처리 완료 +- **관련 요구사항:** PRD Feature C, API Expectations의 request/domain 오류 400 +- **관련 계약:** `AudioContentCreateRequest.releaseDate`는 `yyyy-MM-dd HH:mm`, `timezone`은 시간대 ID +- **소유 Task:** `Task 3.20`, `P3-R10` + +**관찰 내용** + +facade의 strict reader는 JSON 문법·타입·미지 필드만 검증하고 파싱한 request를 버린다. 이후 legacy service가 +`releaseDate`를 `LocalDateTime`으로 변환하고 `ZoneId.of(timezone)`을 호출한다. 잘못된 값은 +`DateTimeParseException`/`ZoneRulesException` 등 `DateTimeException`으로 빠지며, prefix handler는 이를 client 오류로 +분류하지 않아 500 `common.error.unknown`을 반환한다. + +**근거** + +- 코드: `AiCharacterAdminAudioContentFacade.kt:65`~`:80`은 strict parse 뒤 같은 raw JSON을 legacy service에 전달한다. +- 코드: `AudioContentService.kt:226`~`:230`에서 날짜 형식과 `ZoneId`를 변환한다. +- 코드: `AiCharacterAdminExceptionHandler.kt:55`~`:71`은 `DateTimeException`을 400 분기에 포함하지 않는다. +- 계약: OpenAPI `AudioContentCreateRequest`의 `releaseDate` 설명과 기본 `timezone`. +- 계획: 기존 `Task 3.13`은 invalid date 400 증거를 완료 조건으로 적었지만 실제 테스트는 현재 계약 밖 + `releaseDateUtc` 미지 필드만 검증한다. +- 테스트: 현재 생성 테스트에는 유효한 날짜·시간대와 제거된 `releaseDateUtc`만 있고 실제 `releaseDate` 형식·`timezone` + 의미 오류가 없다. + +**정적 재현 절차** + +1. 유효한 `coverImage`, `contentFile`, theme과 필수 JSON field를 준비한다. +2. `releaseDate="not-a-date"` 또는 `timezone="Invalid/Zone"`으로 생성 요청을 보낸다. +3. strict JSON parse는 통과하고 legacy Java time 변환이 예외를 던진다. +4. 현재 handler 분류는 500이며 요구 결과는 side effect 없는 400 `common.error.invalid_request`다. + +**영향** + +형식상 JSON은 맞지만 의미가 잘못된 client 입력이 서버 장애로 기록·응답된다. 실제 업로드 이전에 실패하므로 현재 경로의 +DB/S3/event 변경 가능성은 낮지만, 오류 계약과 운영 장애 지표가 왜곡된다. + +**권장 조치** + +공통 handler를 넓히지 말고 facade가 strict parse한 생성 DTO를 재사용해 날짜 형식과 `ZoneId`만 legacy 호출 전에 검증한다. +두 입력의 KO/EN/JA 400과 DB/S3/event 0건을 actual endpoint로 고정한다. + +**판정 기록** + +- 2026-07-28 — 코드·OpenAPI·handler·테스트 정적 추적으로 확정. 테스트는 사용자 요청에 따라 미실행. +- 2026-07-28 — `P3-R10`에서 `AiCharacterAdminAudioContentCreateTest`에 `releaseDate="not-a-date"`와 + `timezone="Invalid/Zone"`의 KO/EN/JA actual endpoint matrix를 추가했다. RED는 신규 6건이 400 기대 assertion에서 실패했고, + facade가 strict parse 결과의 `releaseDate`/`timezone`을 legacy service 호출 전에 Java time API로 검증하도록 수정한 뒤 + create focused, create+controller focused, content/common 영향 범위 회귀와 `ktlintCheck`가 모두 성공했다. production 변경은 + v2 audio content facade 경계에 한정했고 OpenAPI schema와 legacy `AudioContentService`는 변경하지 않았다. + +### plan·goal 전환 + +`plan-task.md` Phase 3에 `Task 3.20` / `P3-R10`과 `P3-R10-GATE`를 추가했다. 기존 완료 Task/Gate는 유지한다. + +### 8차 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| operation/schema 대조 | 충족 | AudioContent 5개 mapping과 OpenAPI schema 유지 | +| 후보 판정 | 충족 | `REV-030` 원인·오류 분류·테스트 공백 확인 및 처리 완료 | +| plan 반영 | 충족 | `Task 3.20`, `P3-R10`, `P3-R10-GATE` | +| 실행 검증 | 미실행 | 사용자 요청에 따라 컴파일·테스트 미실행 | + +**최종 결론:** `REV-030` 처리 완료, Phase 3 8차 리뷰 종결 + +**남은 항목:** 없음. 다음은 `P4-R2` 실행 후 Phase 4를 재판정한다. + +### P3-R10-GATE 종료 판정 — 2026-07-28 + +- 무엇을: 오디오 생성의 잘못된 `releaseDate` 형식과 `timezone` 의미 오류를 400 `common.error.invalid_request`로 복구했다. +- 왜: 형식상 JSON은 유효하지만 의미가 잘못된 client 입력이 legacy Java time 변환까지 내려가 500으로 반환되는 계약 위반을 막기 위해서다. +- 어떻게: v2 facade에서 strict parse 결과를 재사용해 `yyyy-MM-dd HH:mm`과 `ZoneId`를 legacy service 호출 전에 검증했다. +- 결과: RED는 신규 6건 실패로 재현됐고, GREEN 후 create focused는 `BUILD SUCCESSFUL in 1m 4s`, create+controller focused는 + `BUILD SUCCESSFUL in 1m 16s`, content/common 회귀는 `BUILD SUCCESSFUL in 2m 58s`, `ktlintCheck`는 + `BUILD SUCCESSFUL in 38s`였다. + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| `REV-030` 처리 | 충족 | invalid `releaseDate`/`timezone` actual endpoint KO/EN/JA 400 추가 | +| side-effect 차단 | 충족 | DB count, S3 putObject 0회, event no-interaction 단언 | +| 영향 범위 회귀 | 충족 | content/common 회귀와 lint 성공 | +| 범위 준수 | 충족 | OpenAPI schema, legacy service, upload/processing pipeline 변경 없음 | + +**최종 결론:** Phase 3 8차 리뷰 종결 + +**남은 항목:** 없음. 다음은 `P4-R2`다. + +## 17. 9차 정적 리뷰 및 판정 — 2026-07-28 + +### 리뷰 정보와 범위 + +- 기준 commit/working tree: `2f93e2c9` + 현재 working tree +- 기준 문서: PRD Feature C, plan Phase 3, OpenAPI AudioContent 5개 operation +- 검토 범위: 상세 facade/mapper, 레거시 상세 response 파생 규칙, 예약일·locale·signed URL 테스트 +- 검증 방식: 코드·문서·테스트 정적 추적. 컴파일과 테스트는 실행하지 않았다. + +### 발견 사항 + +#### `REV-036` — High — 미래 예약 콘텐츠 상세의 releaseDate 소실 + +- OpenAPI 상세 response는 nullable `releaseDate`를 레거시 `GetAudioContentDetailResponse` 필드로 유지한다. +- 레거시 `AudioContentService`는 미래 예약일을 UTC에서 Asia/Seoul로 변환하고 + `content.release_date.format`의 KO/EN/JA 형식 문자열을 반환하며, 공개 시각이 지나면 null을 반환한다. +- v2 `AiCharacterAdminAudioContentMapper.toResponse`는 콘텐츠 상태와 locale에 관계없이 `releaseDate = null`로 + 고정한다. +- 현재 상세 테스트의 예약일은 점검일보다 과거라 null 분기만 검증해 미래 분기 누락을 발견하지 못한다. + +**영향:** 예약 공개 전 관리자 상세에서 공개 예정 시각이 숨겨지고 레거시 response 의미와 OpenAPI 이관 원칙을 위반한다. + +**권장 조치:** 기존 `SodaMessageSource`와 `LangContext`를 사용해 미래 여부, UTC→Asia/Seoul 변환, locale별 포맷을 +mapper에 최소 이관하고 미래·과거 KO/EN/JA actual endpoint를 고정한다. + +### plan·goal 전환 + +`plan-task.md` Phase 3에 `Task 3.21` / `P3-R11`과 `P3-R11-GATE`를 추가했다. + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| operation/schema | route 유지 | AudioContent 5개 mapping과 response 필드는 존재 | +| 미래 예약일 | 수정 필요 | mapper가 `releaseDate`를 무조건 null로 설정 | +| 과거 예약일 | 충족 | null 반환은 레거시 의미와 일치 | +| plan 반영 | 충족 | `Task 3.21`, `P3-R11`, `P3-R11-GATE` 추가 | +| 실행 검증 | 미실행 | 사용자 요청에 따라 컴파일·테스트 미실행 | + +**최종 결론:** Phase 3 후속 수정 필요 + +**남은 항목:** `P2-R7-GATE` 후 `P3-R11` → `P3-R11-GATE`. + +## 18. 9차 후속 수정 및 Gate — 2026-07-29 + +- 무엇을: `REV-036`을 처리했다. +- 왜: 미래 예약 콘텐츠 상세의 `releaseDate`가 항상 null이라 레거시 locale별 공개 예정 시각을 숨겼기 때문이다. +- 어떻게: + - RED: `AiCharacterAdminAudioContentQueryTest`에 미래 예약일 KO/EN/JA와 과거 null actual endpoint 테스트를 추가했다. focused 실행에서 미래 3개 locale이 null 반환으로 실패했다. + - GREEN: `AiCharacterAdminAudioContentMapper`가 `SodaMessageSource`, `LangContext`를 사용해 레거시 `content.release_date.format`과 UTC→Asia/Seoul 변환을 적용하도록 최소 수정했다. + - 검증: focused query test, targeted aicharacter 회귀, 전체 `./gradlew test`, `ktlintCheck`, OpenAPI/mapping/diff 점검을 실행했다. +- 결과: `REV-036` 처리 완료. 미래 예약일은 KO/EN/JA 형식 문자열로 반환하고 과거 예약일은 null을 유지한다. + +**최종 결론:** Phase 3 9차 리뷰 종결 + +**남은 항목:** 없음. + +## 19. 10차 정적 리뷰 및 판정 — 2026-07-29 + +### 리뷰 범위와 방식 + +- 기준 commit/working tree: `2f93e2c9` + 현재 working tree +- 기준 문서: PRD Feature C, plan Phase 3, OpenAPI AudioContent 5개 operation +- 검토 범위: 테마·목록·상세·생성·수정 facade/mapper/repository, signed URL과 최신 예약일 보완 +- 검증 방식: 코드·schema·테스트 정적 대조. 컴파일과 테스트는 실행하지 않았다. + +### 발견 사항과 판정 + +AudioContent runtime의 신규 확정 finding은 없다. owner-scoped 상세, 레거시 목록/생성/수정 DTO, +빈 파일 경계, signed URL과 미래·과거 KO/EN/JA 예약일 의미가 유지된다. + +`Task 3.21` 헤더가 `[ ]`로 남은 문제는 Phase 7 `REV-038` / `Task 7.6`에서 완료 상태로 동기화했다. + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| operation/schema | 충족 | AudioContent 5개 route와 OpenAPI field·multipart 경계 일치 | +| ownership | 충족 | target owner content만 상세·수정 가능 | +| signed URL/private path | 충족 | 공통 CloudFront 정책 재사용과 private path 비노출 | +| 예약 공개일 | 충족 | 미래 locale별 표시, 과거 null 유지 | +| 문서 상태 | 충족 | `REV-038`, `P7-R4`에서 완료 헤더 동기화 | + +**최종 결론:** Phase 3 기능 추가 수정 없음 + +**남은 항목:** 없음. + +## 20. 11차 정적 리뷰 및 판정 — 2026-07-29 + +### 확인된 문제 + +#### `REV-041` — 오디오 생성 primitive의 required·null 계약 미강제 + +- **심각도:** High +- **상태:** 처리 완료 +- **계약:** OpenAPI `AudioContentCreateRequest`는 `price`를 required non-null integer로 정의하고, + `themeId`, 각 boolean primitive도 nullable로 선언하지 않는다. +- **구현:** `CreateAudioContentRequest`의 해당 값은 Kotlin primitive이며, v2 content facade의 strict reader는 + `FAIL_ON_UNKNOWN_PROPERTIES`, `FAIL_ON_NULL_FOR_PRIMITIVES`, `FAIL_ON_MISSING_CREATOR_PROPERTIES`를 활성화한 뒤 원본 JSON을 legacy service로 전달한다. +- **근거:** Jackson Kotlin/databind 2.13.5 기본 설정에서 primitive 누락·null은 `0`/`false`로 보정될 수 있다. +- **영향:** 필수 `price` 누락·null이 400 없이 생성으로 이어질 수 있고, `isFullDetailVisible` 같은 필드의 explicit null은 + 문서·DTO의 생략 기본값과 다른 값으로 처리될 수 있다. + +### 보완 계획 + +| 항목 | 판정 | +|---|---| +| 신규 Task | `Task 3.22` / `P3-R12` | +| 시작 조건 | `P2-R8-GATE` | +| Gate | `P3-R12-GATE` | +| RED | required `price` 누락·null, non-null primitive null과 S3·DB·event 무변경 | +| GREEN | v2 생성 경계의 primitive null/누락 400 변환과 optional 생략 기본값 유지 완료 | +| 범위 제한 | 전역 mapper·레거시 service·OpenAPI 변경 없음 | + +### 처리 결과 + +- `AiCharacterAdminAudioContentCreateTest`에 `price` 누락·null, primitive field explicit null actual POST와 파일 업로드·DB·event no-side-effect 회귀를 추가했다. +- `AiCharacterAdminAudioContentFacade.readRequest()`에 `FAIL_ON_NULL_FOR_PRIMITIVES`와 `FAIL_ON_MISSING_CREATOR_PROPERTIES`를 추가해 전역 mapper·레거시 service·OpenAPI 변경 없이 v2 경계에서 400으로 변환했다. +- RED: 신규 8개 invocation은 보완 전 `status().isBadRequest` 기대에서 실패했다. `themeId:null`은 기존 missing-theme guard로 이미 400이었다. +- GREEN/GATE: 보완 후 focused, content/common 영향 범위, `ktlintCheck`, `git diff --check`를 fresh 실행했다. + +**최종 결론:** Phase 3는 `REV-041` 처리 완료 + +**다음 Goal:** `P4-R4`. + +## 21. 오디오 콘텐츠 댓글 후속 검토 — 2026-07-29 + +### 확인 결과 + +- **`REV-045` / 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 3.23` / `P3-R13` +- Gate: `P3-R13-GATE` +- 범위 밖: 캐릭터 직접 댓글, hard delete·cascade, legacy/public endpoint 변경 + +사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다. + +**최종 결론:** Phase 3 오디오 콘텐츠 댓글 CRUD 구현 필요 + +**다음 Goal:** `P3-R13`. + +## 22. 오디오 콘텐츠 댓글 구현 및 Gate — 2026-07-29 + +- 무엇을: `REV-045`를 처리했다. +- 왜: 신규 v2 관리자 경계에 target 소유 오디오 콘텐츠의 댓글·답글 조회/작성/수정/삭제 5개 operation이 없었기 때문이다. +- 어떻게: + - RED: `AiCharacterAdminAudioContentCommentTest`에 root/reply 조회, target AI 작성, target 작성 row 수정, owner 범위 row soft delete, 잘못된 parent/timezone/page/size/unknown field/cross-resource 계약 7건을 추가했고 미구현 route의 404/405로 실패했다. + - GREEN: `AiCharacterAdminAudioContentController`에 5개 route를 추가하고, facade에서 target active owner content, 같은 콘텐츠의 활성 root parent, target 작성 수정 권한을 선검증한 뒤 기존 `AudioContentCommentService`를 재사용했다. + - Gate: focused 댓글 테스트, content/common 영향 범위 회귀, `ktlintCheck`, `git diff --check`를 fresh 실행했다. +- 결과: `REV-045` 처리 완료. 삭제는 작성자와 관계없이 target 소유 콘텐츠의 해당 row만 soft delete하고 cascade하지 않으며, 이미 비활성인 row는 200 no-op을 유지한다. + +**최종 결론:** Phase 3 오디오 콘텐츠 댓글 후속 기능 종결 + +**다음 Goal:** `P4-R5`. + +## 23. UTC 날짜 계약 변경 리뷰 및 판정 — 2026-07-29 + +### 리뷰 범위와 방식 + +- 기준 문서: PRD Feature C, OpenAPI 2.2.0 AudioContent 10개 operation, `DEC-UTC-DATE-001` +- 검토 범위: 오디오 생성 request, 상세 GET, 댓글·답글 GET의 controller/facade/DTO/mapper/repository +- 검증 방식: 문서·코드·테스트 정적 대조. 사용자 요청에 따라 컴파일과 테스트는 실행하지 않았다. + +### `REV-050` — High — 오디오 4개 operation의 timezone/UTC 계약 불일치 + +- 생성은 현재 레거시 `CreateAudioContentRequest`를 typealias로 사용해 `timezone` body와 + `yyyy-MM-dd HH:mm` 로컬 `releaseDate`를 받는다. +- 상세 GET은 필수 `timezone` query를 받지만 facade에서 사용하지 않는다. 상세 `releaseDate`는 미래 예약일에 + UTC를 Asia/Seoul로 바꾼 locale 문자열이며 현재/과거는 null이다. +- 댓글·답글 GET도 필수 `timezone` query를 받고 레거시 목록 service/repository의 표시 문자열을 반환한다. +- 승인된 최신 계약은 생성 body와 세 GET에서 `timezone`을 제거하고, 생성의 nullable `releaseDate`, 상세의 + 기존 nullable `releaseDate`, 댓글의 기존 `date`를 ISO-8601 UTC(`Z`)로 사용한다. + +### 판정 + +| 항목 | 결과 | 근거 | +|---|---|---| +| route 수 | 유지 | AudioContent 10개 operation 자체는 변경 없음 | +| 생성 request | 처리 완료 | v2 전용 DTO가 `timezone`을 거부하고 UTC `releaseDate`만 내부 경계에 전달 | +| 상세 response | 처리 완료 | query를 제거하고 미래 예약일만 UTC `Z`로 반환 | +| 댓글·답글 response | 처리 완료 | `page`/`size`만 받고 기존 `date`를 UTC `Z`로 mapping | +| legacy/public 격리 | 충족 | 기존 controller/service의 timezone 계약을 유지 | +| OpenAPI 상태 | 처리 완료 | 영향 4개 operation을 `implemented`로 동기화 | + +### plan·goal 전환 + +- 신규 Task: `Task 3.24` / `P3-R14` +- Gate: `P3-R14-GATE` +- 완료 조건: 생성·상세·댓글·답글 actual endpoint UTC exact JSON, 상세 기존 null/노출 조건과 댓글 + pagination/ownership 보존, legacy/public 회귀 +- 범위 밖: 오디오 목록 날짜, 로컬 시각+timezone 병행 지원, 신규 dependency·DDL + +### `P3-R14` / `P3-R14-GATE` 처리 결과 + +- RED: create/query/comment focused actual endpoint 테스트는 48개 중 9개가 기존 `timezone` 필수와 legacy 날짜 포맷으로 + 실패했다. +- GREEN: v2 생성 DTO가 `timezone`을 미지 필드로 거부하고 UTC instant를 `LocalDateTime`으로 한 번 변환해 내부 생성 + overload로 전달한다. 상세와 root/reply 목록은 기존 null·pagination·ownership·filter 의미를 유지하면서 `releaseDate`와 + `date`만 `toUtcIso()`로 반환한다. +- Gate: focused는 `BUILD SUCCESSFUL in 2m 13s`, parent 재실행은 `BUILD SUCCESSFUL in 52s`, content/common·legacy 영향 + 범위 회귀는 `BUILD SUCCESSFUL in 1m 35s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 23s`였다. 당시 OpenAPI는 34개 + `implemented`, 2개 `alignment-required`였고, 후속 `P5-R6` 뒤 36개 모두 `implemented`로 통합됐다. `git diff --check`는 + 출력이 없었다. + +**최종 결론:** `REV-050` 처리 완료, Phase 3 UTC 계약 정합화 완료 + +**다음 Goal:** `P5-R6`. + +## 24. 12차 통합 정적 리뷰 및 판정 — 2026-07-29 + +### 리뷰 범위와 방식 + +- 기준 문서: PRD Feature C, OpenAPI AudioContent 10개 operation과 공통 `Page`/`Size` +- 검토 범위: audio controller/facade의 query binding·검증, 관련 댓글 actual endpoint 테스트 +- 기준 상태: 현재 working tree +- 검증 방식: 문서·코드·테스트 정적 대조. 사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다. + +### `REV-052` — High — 오디오 댓글·답글 목록의 optional pagination 계약 위반 + +- OpenAPI의 `Page`, `Size`는 `required: false`이고 각각 기본값 `0`, `20`이다. +- 댓글 목록과 답글 목록 controller의 `page`, `size`에는 `defaultValue`가 없어 두 query를 생략하면 MVC binding + 단계에서 400이 된다. +- facade의 `validateCommentQuery()`도 실제 query 이름 집합이 정확히 `page`, `size` 두 개일 때만 허용하므로, + controller 기본값만 추가해도 전체 또는 부분 생략 요청을 거부한다. +- 현재 댓글 테스트는 query 전체 생략을 400으로 기대해 계약 불일치를 회귀로 고정하고 있다. +- 영향은 두 GET의 정상 요청 가용성에 직접 미치므로 High로 판정한다. + +### 검증 근거 + +| 근거 | 확인 내용 | +|---|---| +| OpenAPI | 두 GET이 공통 optional `Page`/`Size`를 참조 | +| controller | 댓글·답글 모두 기본값 없는 `@RequestParam page`, `size` | +| facade | query 이름의 부분집합이 아니라 정확한 집합 일치 요구 | +| test | query 전체 생략 요청을 400으로 기대 | + +### plan 전환 + +- 신규 Task: `Task 3.25` / `P3-R15` +- Gate: `P3-R15-GATE` +- 최소 수정: 두 controller query 기본값과 facade의 미지 query 거부 조건만 정합화 +- 완료 조건: 전체·부분 생략 200/default, 범위 오류·미지 query 400, 기존 UTC/ownership/pagination 회귀 +- 범위 밖: OpenAPI·legacy/public API·FanTalk query policy 변경 + +### `P3-R15` / `P3-R15-GATE` 처리 결과 — 2026-07-29 + +- RED: `AiCharacterAdminAudioContentCommentTest` 9건 중 댓글·답글 전체 생략 테스트 2건이 `isOk` 기대에서 실패해 `BUILD FAILED in 45s`였다. +- GREEN: 두 controller 목록의 `page`, `size`에 각각 `0`, `20` 기본값을 적용하고, facade는 `page`, `size`의 부분집합만 허용해 미지 query·음수 page·1 미만 size의 기존 400 `ApiResponse.error` 경계를 유지했다. +- Gate: focused는 `BUILD SUCCESSFUL in 40s`, content package와 `AiCharacterAdminErrorContractTest` 영향 범위 회귀는 `BUILD SUCCESSFUL in 2m 58s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 37s`였다. 전체 `./gradlew test`는 직접 영향 범위 회귀가 controller/facade와 actual endpoint 변경을 포함하므로 실행하지 않았다. + +**현재 결론:** `REV-052` 처리 완료. Phase 3 완료 판정에는 별도 `REV-056` / `P3-R16` 보완이 남아 있다. + +### `REV-056` — High — AudioContent multipart request part의 JSON media type 미강제 + +- OpenAPI와 계약 설명은 생성·수정 multipart의 `request` part Content-Type을 `application/json`으로 고정한다. +- 두 controller는 `@RequestPart("request") request: String`으로 받아 part 자체의 media type을 검사하지 않는다. +- 실제 `AiCharacterAdminAudioContentControllerTest`와 update 테스트는 유효 JSON을 `text/plain` request part로 + 보내 200을 기대하므로 계약 불일치가 실행 테스트에도 고정돼 있다. +- 기존 정상 strict parsing·file/series/UTC 의미를 유지하면서 part-level media type만 415로 차단해야 한다. + +### 추가 plan 전환 + +- 신규 Task: `Task 3.26` / `P3-R16` +- Gate: `P3-R16-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/processing/event no-side-effect + +**최종 결론:** Phase 3은 `REV-052`, `REV-056` 수정 전 완료 판정 불가 + +**다음 Goal:** `P3-R15` (`P2-R10-GATE` 완료 후). + +### `P3-R16` / `P3-R16-GATE` 처리 결과 — 2026-07-29 + +- RED: `AiCharacterAdminAudioContentCreateTest`, `AiCharacterAdminAudioContentUpdateTest`, `AiCharacterAdminAudioContentControllerTest`의 83건 중 text/plain·Content-Type 누락 415 기대 13건이 기존 200으로 실패해 `BUILD FAILED in 1m 15s`였다. +- GREEN: Character `P2-R10`의 `MultipartHttpServletRequest` header 검사 패턴을 AudioContent POST·PUT controller에만 적용했다. 기존 JSON String strict reader와 facade는 변경하지 않았다. +- Gate: POST·PUT actual endpoint는 KO/EN/JA의 text/plain·Content-Type 누락에 localized `ApiResponse.error` 415와 `Accept: application/json`, DB/S3/event 무변경을 확인했다. JSON 정상 경로, 생성 필수 part 400, UTC/file/series 회귀도 유지했다. focused는 `BUILD SUCCESSFUL in 56s`, content package와 `AiCharacterAdminErrorContractTest` 영향 범위 회귀는 `BUILD SUCCESSFUL in 1m 55s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 16s`, `git diff --check`는 출력이 없었다. 전체 `./gradlew test`는 직접 영향 범위 회귀가 변경 slice를 포함하므로 실행하지 않았다. + +**처리 결과:** `REV-056` 처리 완료. + +## 25. 13차 통합 정적 리뷰 및 판정 — 2026-07-29 + +### 리뷰 범위와 방식 + +- 기준 문서: PRD Feature C, OpenAPI `AudioContentCreateMultipart`·`AudioContentUpdateMultipart` +- 검토 범위: AudioContent POST·PUT controller의 multipart binding, 파일 교체 거부와 관련 mutation 테스트 +- 검증 방식: 현재 working tree의 문서·코드·테스트를 정적으로 대조했다. 사용자 요청에 따라 컴파일과 테스트는 + 실행하지 않았다. + +### `REV-061` — Medium — operation별 허용 목록 밖 multipart part를 일반적으로 거부하지 않음 + +- OpenAPI는 생성에 `contentFile`, `coverImage`, `request`, 수정에 `coverImage`, `request`만 정의하고 두 schema 모두 + `additionalProperties: false`다. +- controller는 전체 part 이름을 검사하지 않는다. 수정의 `audioFile`, `contentFile`만 별도 nullable 인자로 받아 facade에서 + 거부하므로, `unexpected` 같은 다른 이름의 part는 무시된다. +- 생성·수정 모두 정의되지 않은 part를 포함한 요청이 정상 mutation으로 진행될 수 있어 계약 위반을 확정했다. + +### plan 전환 + +| 항목 | 내용 | +|---|---| +| 신규 Task | `Task 3.27` / `P3-R17` | +| Gate | `P3-R17-GATE` | +| RED | 생성·수정 미정의 part와 S3·DB·processing/event 결과 | +| GREEN | 생성 `{contentFile, coverImage, request}`, 수정 `{coverImage, request}` exact allow-list | +| 회귀 | 기존 `audioFile`·`contentFile` 수정 거부, 필수/빈 파일, request part 415 | + +**처리 결과 (2026-07-29 / P3-R17):** + +- AudioContent POST는 multipart part 이름을 `{contentFile, coverImage, request}`로 제한하고, PUT은 `{coverImage, request}`로 제한하도록 controller 경계에 allow-list를 추가했다. +- PUT controller/facade의 `audioFile`, `contentFile` nullable 인자는 제거했고, 기존 파일 교체 거부는 동일한 미정의 part 검증 경계로 통합했다. +- RED에서 생성·수정 `unexpected` part KO/EN/JA 테스트 6개가 기존 정상 mutation 경로로 실패함을 확인했고, GREEN 후 focused/영향 범위 회귀, `ktlintCheck`, `git diff --check`를 통과했다. + +**Gate 결과 (2026-07-29 / P3-R17-GATE):** + +- Focused multipart 회귀, Phase 3 content 영향 범위 회귀, `ktlintCheck`, `git diff --check`를 fresh 실행했다. +- 생성·수정 미정의 part 400/no-side-effect, 수정 `audioFile`·`contentFile` 교체 미지원, 정상/필수/빈 파일/request 415 경계가 유지됨을 확인했다. + +**최종 결론:** `REV-061` resolved. Phase 3 완료. + +**다음 Goal:** `P4-R8`. + +## 26. 14차 통합 정적 리뷰 및 판정 — 2026-07-29 + +### 리뷰 범위와 방식 + +- 기준 문서: OpenAPI AudioContent create/update multipart schema의 operation별 허용 part와 + `additionalProperties: false` +- 검토 범위: AudioContent POST·PUT controller의 allow-list와 미정의 part·파일 교체 회귀 테스트 +- 기준 상태: 현재 working tree +- 리뷰어/상태: Codex / 판정 완료 +- 검증 방식: 문서·코드·테스트 소스 정적 대조. 사용자 지시에 따라 컴파일과 테스트는 실행하지 않았다. + +### `REV-066` — Medium — 일반 form-field multipart part가 allow-list 우회 + +- `AiCharacterAdminAudioContentController.kt:81-88`은 operation별 허용 집합을 받지만 실제 검사는 + `fileMap.keys`에 한정한다. +- 기존 create/update 회귀는 각각 `AiCharacterAdminAudioContentCreateTest.kt:251-266`, + `AiCharacterAdminAudioContentUpdateTest.kt:145-159`의 filename이 있는 `MockMultipartFile`만 사용한다. +- filename 없는 일반 form-field `unexpected`는 생성 `{contentFile, coverImage, request}`, 수정 + `{coverImage, request}` 계약을 우회할 수 있어 Medium으로 확정한다. + +### plan 전환 + +| 항목 | 내용 | +|---|---| +| 신규 Task | `Task 3.28` / `P3-R18` | +| Gate | `P3-R18-GATE` | +| RED | filename 없는 미정의 part의 POST·PUT 400과 S3·DB·processing·event no-side-effect | +| GREEN | servlet 전체 part 이름을 operation별 allow-list와 비교 | +| 범위 제한 | 파일 교체 의미·OpenAPI·전역 resolver·legacy/public 변경 없음 | + +**최종 결론:** Phase 3 보완 필요 — `REV-066` 확정 + +**다음 Goal:** `P3-R18` (`P2-R12-GATE` 완료 후). + +## 25. 10차 후속 수정 및 Gate — 2026-07-29 + +- 무엇을: `REV-066`의 AudioContent POST·PUT filename 없는 일반 form-field multipart part 우회를 보완했다. +- 왜: 생성 `{contentFile, coverImage, request}`, 수정 `{coverImage, request}` 외 일반 form-field part가 기존 파일 map 검사만으로는 거부되지 않았기 때문이다. +- 어떻게: 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-066` 처리 완료. Phase 3 후속 Gate 완료. + +**남은 항목:** 없음. + +## 26. 15차 정적 리뷰 및 판정 — 2026-07-29 + +### 리뷰 범위와 방식 + +- 기준 문서: PRD Feature D의 기존 creator 동작 재사용, OpenAPI 오디오 생성 preview 필드, + plan의 기존 preview 오류 key 유지 조건 +- 검토 범위: `AiCharacterAdminAudioContentFacade.create`, `AudioContentService.createAudioContent` 두 overload, + v2 오디오 생성 actual endpoint 테스트 +- 검증 방식: 현재 working tree의 문서·production·test 소스를 정적으로 교차 대조했다. 사용자 지시에 따라 + 컴파일과 테스트는 실행하지 않았다. + +### `REV-072` — High — v2 오디오 생성이 기존 preview 시간 검증을 우회 + +- v2 facade는 strict JSON parse와 UTC `releaseDate` 변환 후 `CreateAudioContentRequest`와 파싱된 날짜를 받는 + `AudioContentService.createAudioContent` overload를 호출한다. +- 기존 `previewStartTime`·`previewEndTime`의 쌍, `HH:mm:ss` 형식, 최소 15초 검증 호출은 문자열 request를 받는 + legacy overload에만 있다. v2가 호출하는 공유 대상 overload에는 검증 호출이 없다. +- 해당 overload는 검증 없이 DB를 저장하고 cover/audio를 S3에 업로드한 뒤 두 값이 모두 있으면 그대로 metadata에 + 넣고 event를 발행한다. 따라서 한쪽만 있는 값은 조용히 무시되고, 형식 오류·15초 미만 값은 metadata로 전달될 수 있다. +- v2 테스트에는 정상 `00:00:05`~`00:00:25` 입력만 있고 세 거부 규칙의 actual endpoint 회귀가 없다. +- 기존 creator 검증/parity와 부작용 선검증을 깨뜨리므로 High로 확정한다. + +### plan 전환 + +| 항목 | 내용 | +|---|---| +| 신규 Task | `Task 3.29` / `P3-R19` | +| Gate | `P3-R19-GATE` | +| RED | 시작만 입력, 형식 오류, 15초 미만의 400 및 DB/S3/event no-side-effect | +| GREEN | 기존 검증 호출을 두 경로가 공유하는 parsed request overload로 이동 | +| 회귀 | KO/EN/JA 기존 오류 key, 정상 preview metadata, legacy/public 생성 | + +### `REV-072` 처리 결과 — 2026-07-30 + +- `AudioContentService.createAudioContent(CreateAudioContentRequest, ...)` 시작부로 `validatePreviewTime` 호출을 이동해 legacy + string request 생성과 v2 parsed request 생성이 같은 preview 검증을 정확히 한 번 공유한다. +- v2 actual endpoint에 한쪽만 입력, 형식 오류, 15초 미만 preview의 KO/EN/JA 400 응답과 DB/S3/event no-side-effect를 + 추가했고, 정상 15초 이상 preview는 audio upload metadata의 `preview_start_time`·`preview_end_time` 보존을 확인했다. +- 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.LegacyCreatorAdminAudioContentCharacterizationTest` + → `BUILD SUCCESSFUL in 39s`; Phase 3 content와 legacy AudioContent 영향 범위 회귀 → `BUILD SUCCESSFUL in 1m 22s`; + `./gradlew ktlintCheck` → `BUILD SUCCESSFUL in 32s`; `git diff --check` → 출력 없음. + +**최종 결론:** `REV-072` 처리 완료. Phase 3 완료. + +**다음 Goal:** `P7-R11` 통합 재판정 완료. + +## 27. 16차 정적 리뷰 및 판정 — 2026-07-30 + +### 리뷰 범위와 방식 + +- 기준 문서: PRD Feature C, OpenAPI AudioContent 10개 operation +- 검토 범위: 테마·목록·상세·생성·수정, signed URL·UTC 날짜, 댓글 CRUD, owner/actor/parent와 multipart 경계 +- 검증 방식: 현재 working tree의 문서·production·test 소스를 정적으로 대조했다. 사용자 지시에 따라 컴파일과 + 테스트는 실행하지 않았다. + +### 판정 + +- AudioContent 10개 operation과 controller mapping, owner-scoped 조회·수정 및 active 콘텐츠 댓글 경계가 일치한다. +- 생성의 UTC `releaseDate`, 필수 파일, strict request, preview 쌍·형식·최소 15초 검증이 공유 service 경계에 유지된다. +- 댓글 작성의 동일 콘텐츠 활성 원댓글, target AI 수정 권한, row-only soft delete와 UTC 응답 계약이 유지된다. +- 신규 확정 finding이 없어 Phase 3 회귀 수정 Task/Gate를 추가하지 않는다. + +**최종 결론:** Phase 3 요구사항 충족, 추가 수정 없음. + +**남은 항목:** 없음. diff --git a/docs/20260724_AI캐릭터_관리자_API/reviews/phase4-series-review.md b/docs/20260724_AI캐릭터_관리자_API/reviews/phase4-series-review.md new file mode 100644 index 00000000..465e49b6 --- /dev/null +++ b/docs/20260724_AI캐릭터_관리자_API/reviews/phase4-series-review.md @@ -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 요구사항 충족, 추가 수정 없음. + +**남은 항목:** 없음. diff --git a/docs/20260724_AI캐릭터_관리자_API/reviews/phase5-community-review.md b/docs/20260724_AI캐릭터_관리자_API/reviews/phase5-community-review.md new file mode 100644 index 00000000..2f6bb0dd --- /dev/null +++ b/docs/20260724_AI캐릭터_관리자_API/reviews/phase5-community-review.md @@ -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 요구사항 충족, 추가 수정 없음. + +**남은 항목:** 없음. diff --git a/docs/20260724_AI캐릭터_관리자_API/reviews/phase6-fantalk-review.md b/docs/20260724_AI캐릭터_관리자_API/reviews/phase6-fantalk-review.md new file mode 100644 index 00000000..6493bc03 --- /dev/null +++ b/docs/20260724_AI캐릭터_관리자_API/reviews/phase6-fantalk-review.md @@ -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 요구사항 충족, 추가 수정 없음. + +**남은 항목:** 없음. diff --git a/docs/20260724_AI캐릭터_관리자_API/reviews/phase7-integration-review.md b/docs/20260724_AI캐릭터_관리자_API/reviews/phase7-integration-review.md new file mode 100644 index 00000000..fc4366fe --- /dev/null +++ b/docs/20260724_AI캐릭터_관리자_API/reviews/phase7-integration-review.md @@ -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:** 없음.